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:
Sam Becker 2025-07-04 12:19:45 -05:00 committed by GitHub
parent a59af8a505
commit b7cb6715b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
96 changed files with 964 additions and 834 deletions

View File

@ -140,6 +140,7 @@ Application behavior can be changed by configuring the following environment var
- `recipes` (default)
- `films` (default)
- `focal-lengths`
- `NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS = 1` prevents images displaying when hovering over category links:
- `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content
- `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
@ -157,7 +158,6 @@ Application behavior can be changed by configuring the following environment var
#### Display
- `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links
- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)
- `NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS = 1` shows images when hovering over category links like cameras and lenses (⚠️ setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES = 1` strongly recommended for responsive hover interactions)
- `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal

View File

@ -5,10 +5,10 @@ import Badge from '@/components/Badge';
import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import AppGrid from '@/components/AppGrid';
import EntityLink from '@/components/primitives/EntityLink';
import EntityLink from '@/components/entity/EntityLink';
import LabeledIcon from '@/components/primitives/LabeledIcon';
import PhotoFilmIcon from '@/film/PhotoFilmIcon';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { useEffect, useState } from 'react';
import { FaCamera, FaHandSparkles, FaUserAltSlash } from 'react-icons/fa';

View File

@ -10,11 +10,11 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { GetPhotosOptions } from '@/photo/db';
import { PhotoQueryOptions } from '@/photo/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));

View File

@ -9,12 +9,12 @@ import { getPhotos } from '@/photo/db/query';
import PhotoFeedPage from '@/photo/PhotoFeedPage';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { GetPhotosOptions } from '@/photo/db';
import { PhotoQueryOptions } from '@/photo/db';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_FEED_INITIAL,
}));

View File

@ -11,11 +11,11 @@ import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { SortProps } from '@/photo/db/sort';
import { getSortOptionsFromParams } from '@/photo/db/sort-path';
import { GetPhotosOptions } from '@/photo/db';
import { PhotoQueryOptions } from '@/photo/db';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));

View File

@ -10,12 +10,12 @@ import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { USER_DEFAULT_SORT_OPTIONS } from '@/app/config';
import { GetPhotosOptions } from '@/photo/db';
import { PhotoQueryOptions } from '@/photo/db';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: INFINITE_SCROLL_GRID_INITIAL,
}));

View File

@ -13,7 +13,7 @@ import {
SITE_FEEDS_ENABLED,
ADMIN_DEBUG_TOOLS_ENABLED,
} from '@/app/config';
import AppStateProvider from '@/state/AppStateProvider';
import AppStateProvider from '@/app/AppStateProvider';
import ToasterWithThemes from '@/toast/ToasterWithThemes';
import PhotoEscapeHandler from '@/photo/PhotoEscapeHandler';
import { Metadata } from 'next/types';
@ -21,7 +21,7 @@ import { ThemeProvider } from 'next-themes';
import Nav from '@/app/Nav';
import Footer from '@/app/Footer';
import CommandK from '@/cmdk/CommandK';
import SwrConfigClient from '@/state/SwrConfigClient';
import SwrConfigClient from '@/swr/SwrConfigClient';
import AdminBatchEditPanel from '@/admin/AdminBatchEditPanel';
import ShareModals from '@/share/ShareModals';
import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
@ -29,7 +29,7 @@ import { revalidatePath } from 'next/cache';
import RecipeModal from '@/recipe/RecipeModal';
import ThemeColors from '@/app/ThemeColors';
import AppTextProvider from '@/i18n/state/AppTextProvider';
import OGTooltipProvider from '@/components/og/OGTooltipProvider';
import SharedHoverProvider from '@/components/shared-hover/SharedHoverProvider';
import '../tailwind.css';
@ -97,7 +97,7 @@ export default function RootLayout({
<ThemeColors />
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient>
<OGTooltipProvider>
<SharedHoverProvider>
<div className={clsx(
'mx-3 mb-3',
'lg:mx-6 lg:mb-6',
@ -135,7 +135,7 @@ export default function RootLayout({
<Footer />
</div>
<CommandK />
</OGTooltipProvider>
</SharedHoverProvider>
</SwrConfigClient>
<Analytics debug={false} />
<SpeedInsights debug={false} />

View File

@ -13,12 +13,12 @@ import PhotoFeedPage from '@/photo/PhotoFeedPage';
import PhotoGridPage from '@/photo/PhotoGridPage';
import { getDataForCategoriesCached } from '@/category/cache';
import { getPhotosMetaCached } from '@/photo/cache';
import { GetPhotosOptions } from '@/photo/db';
import { PhotoQueryOptions } from '@/photo/db';
export const dynamic = 'force-static';
export const maxDuration = 60;
const getPhotosCached = cache((options: GetPhotosOptions) => getPhotos({
const getPhotosCached = cache((options: PhotoQueryOptions) => getPhotos({
...options,
limit: GRID_HOMEPAGE_ENABLED
? INFINITE_SCROLL_GRID_INITIAL

View File

@ -10,8 +10,8 @@
},
"dependencies": {
"@ai-sdk/openai": "^1.3.22",
"@aws-sdk/client-s3": "3.840.0",
"@aws-sdk/s3-request-presigner": "3.840.0",
"@aws-sdk/client-s3": "3.842.0",
"@aws-sdk/s3-request-presigner": "3.842.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-tooltip": "^1.2.7",
@ -28,9 +28,9 @@
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^12.22.0",
"framer-motion": "^12.23.0",
"nanoid": "^5.1.5",
"next": "15.3.4",
"next": "15.3.5",
"next-auth": "5.0.0-beta.28",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
@ -39,16 +39,16 @@
"react-icons": "^5.5.0",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.2",
"sonner": "^2.0.5",
"swr": "^2.3.3",
"sonner": "^2.0.6",
"swr": "^2.3.4",
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.5",
"viewerjs": "^1.11.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@next/bundle-analyzer": "15.3.4",
"@next/eslint-plugin-next": "^15.3.4",
"@next/bundle-analyzer": "15.3.5",
"@next/eslint-plugin-next": "^15.3.5",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11",
@ -63,10 +63,10 @@
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.30.1",
"eslint-config-next": "15.3.4",
"eslint-config-next": "15.3.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^30.0.3",
"jest-environment-jsdom": "^30.0.2",
"jest": "^30.0.4",
"jest-environment-jsdom": "^30.0.4",
"postcss": "8.5.6",
"tailwindcss": "4.1.11",
"ts-node": "^10.9.2",

494
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -82,8 +82,9 @@ export default function AdminAppConfigurationClient({
imageQuality,
isBlurEnabled,
// Categories
categoryVisibility,
hasCategoryVisibility,
categoryVisibility,
showCategoryImageHover,
collapseSidebarCategories,
hideTagsWithOnePhoto,
// Sort
@ -94,7 +95,6 @@ export default function AdminAppConfigurationClient({
// Display
showKeyboardShortcutTooltips,
showExifInfo,
showCategoryImageHover,
showZoomControls,
showTakenAtTimeHidden,
showSocial,
@ -582,6 +582,19 @@ export default function AdminAppConfigurationClient({
(default: {`"${DEFAULT_CATEGORY_KEYS.join(',')}"`}):
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_VISIBILITY'])}
</ChecklistRow>
<ChecklistRow
title="Show image hovers"
status={showCategoryImageHover}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to prevent images
displaying when hovering over category links:
{renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Collapsible sidebar"
status={collapseSidebarCategories}
@ -667,25 +680,6 @@ export default function AdminAppConfigurationClient({
Set environment variable to {'"1"'} to hide EXIF data:
{renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])}
</ChecklistRow>
<ChecklistRow
title="Show category image hovers"
status={showCategoryImageHover}
optional
>
<div className="flex flex-col gap-2">
<div>
Set environment variable to {'"1"'} to show images when hovering
over category links like cameras and lenses:
{renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])}
</div>
<div>
Static optimization strongly recommended
for responsive hover interactions:
{/* eslint-disable-next-line max-len */}
{renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])}
</div>
</div>
</ChecklistRow>
<ChecklistRow
title="Show zoom controls"
status={showZoomControls}

View File

@ -1,4 +1,4 @@
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import clsx from 'clsx/lite';
import { LuCog } from 'react-icons/lu';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';

View File

@ -11,7 +11,7 @@ import {
PATH_ADMIN_UPLOADS,
PATH_GRID_INFERRED,
} from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { IoArrowDown, IoArrowUp, IoCloseSharp } from 'react-icons/io5';
import { clsx } from 'clsx/lite';
import AdminAppInfoIcon from './AdminAppInfoIcon';

View File

@ -3,7 +3,7 @@
import Note from '@/components/Note';
import LoaderButton from '@/components/primitives/LoaderButton';
import AppGrid from '@/components/AppGrid';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5';
import { useEffect, useRef, useState } from 'react';

View File

@ -19,7 +19,7 @@ import ProgressButton from '@/components/primitives/ProgressButton';
import { UrlAddStatus } from './AdminUploadsClient';
import PhotoTagFieldset from './PhotoTagFieldset';
import DeleteUploadButton from './DeleteUploadButton';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { pluralize } from '@/utility/string';
import FieldsetFavs from '@/photo/form/FieldsetFavs';
import FieldsetHidden from '@/photo/form/FieldsetHidden';

View File

@ -5,7 +5,7 @@ import ResponsiveText from '@/components/primitives/ResponsiveText';
import clsx from 'clsx/lite';
import ClearCacheButton from '@/admin/ClearCacheButton';
import { usePathname } from 'next/navigation';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import LinkWithLoaderBackground from '@/components/LinkWithLoaderBackground';

View File

@ -11,7 +11,7 @@ import {
isPathAdminInfo,
isPathTopLevelAdmin,
} from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { differenceInMinutes } from 'date-fns';
import { usePathname } from 'next/navigation';

View File

@ -22,7 +22,7 @@ import { isPathFavs, isPhotoFav, TAG_HIDDEN } from '@/tag';
import { usePathname } from 'next/navigation';
import { BiTrash } from 'react-icons/bi';
import MoreMenu from '@/components/more/MoreMenu';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';

View File

@ -10,7 +10,7 @@ import { Photo } from '@/photo';
import { StorageListResponse } from '@/platforms/storage';
import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { pluralize } from '@/utility/string';
import IconBroom from '@/components/icons/IconBroom';

View File

@ -9,7 +9,7 @@ import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import Link from 'next/link';
import PhotoDate from '@/photo/PhotoDate';
import EditButton from './EditButton';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton';

View File

@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoRecipeGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
export default function AdminRecipeForm({
recipe,

View File

@ -2,7 +2,7 @@
import LoaderButton from '@/components/primitives/LoaderButton';
import { RecipeProps } from '@/recipe';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { TbChecklist } from 'react-icons/tb';
export default function AdminShowRecipeButton(props: RecipeProps) {

View File

@ -7,7 +7,7 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ReactNode, useMemo, useState } from 'react';
import { renamePhotoTagGloballyAction } from '@/photo/actions';
import { parameterize } from '@/utility/string';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
export default function AdminTagForm({
tag,

View File

@ -2,7 +2,7 @@
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { clearCacheAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { BiTrash } from 'react-icons/bi';
export default function ClearCacheButton() {

View File

@ -1,7 +1,7 @@
'use client';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { ComponentProps, useCallback } from 'react';
import { BiTrash } from 'react-icons/bi';

View File

@ -3,7 +3,7 @@
import LoaderButton from '@/components/primitives/LoaderButton';
import { photoQuantityText } from '@/photo';
import { deletePhotosAction } from '@/photo/actions';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { toastSuccess, toastWarning } from '@/toast';
import { ComponentProps, useState } from 'react';
import DeleteButton from './DeleteButton';

View File

@ -1,6 +1,6 @@
'use client';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import SignInForm from '@/auth/SignInForm';
import clsx from 'clsx/lite';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';

View File

@ -36,7 +36,7 @@ import AdminLink from '../AdminLink';
import AdminEmptyState from '../AdminEmptyState';
import { pluralize } from '@/utility/string';
import Tooltip from '@/components/Tooltip';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import ScoreCardContainer from '@/components/ScoreCardContainer';
import IconLens from '@/components/icons/IconLens';
import IconCamera from '@/components/icons/IconCamera';

View File

@ -1,4 +1,4 @@
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import clsx from 'clsx/lite';
import { FaCircle } from 'react-icons/fa6';

View File

@ -4,7 +4,7 @@ import Container from '@/components/Container';
import LoaderButton from '@/components/primitives/LoaderButton';
import AppGrid from '@/components/AppGrid';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import clsx from 'clsx/lite';
import { IoCloseSharp } from 'react-icons/io5';

View File

@ -20,7 +20,6 @@ export type AppStateContextType = {
previousPathname?: string
hasLoaded?: boolean
hasLoadedWithAnimations?: boolean
swrTimestamp?: number
invalidateSwr?: () => void
nextPhotoAnimation?: AnimationConfig
setNextPhotoAnimation?: (animationConfig?: AnimationConfig) => void

View File

@ -7,11 +7,11 @@ import {
useCallback,
useRef,
} from 'react';
import { AppStateContext } from './AppState';
import { AppStateContext } from '../app/AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames';
import { getAuthAction } from '@/auth/actions';
import useSWR from 'swr';
import useSWR, { useSWRConfig } from 'swr';
import {
HIGH_DENSITY_GRID,
IS_DEVELOPMENT,
@ -30,9 +30,16 @@ import { useRouter, usePathname } from 'next/navigation';
import { isPathProtected, PATH_ROOT } from '@/app/paths';
import { INITIAL_UPLOAD_STATE, UploadState } from '@/admin/upload';
import { RecipeProps } from '@/recipe';
import { getCountsForCategoriesCachedAction } from '@/category/actions';
import { nanoid } from 'nanoid';
import { toastSuccess } from '@/toast';
import { getCountsForCategoriesCachedAction } from '@/category/actions';
import {
canKeyBePurged,
canKeyBePurgedAndRevalidated,
SWR_KEY_GET_ADMIN_DATA,
SWR_KEY_GET_AUTH,
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
} from '@/swr';
export default function AppStateProvider({
children,
@ -52,8 +59,6 @@ export default function AppStateProvider({
useState(false);
const [hasLoadedWithAnimations, setHasLoadedWithAnimations] =
useState(false);
const [swrTimestamp, setSwrTimestamp] =
useState(Date.now());
const [nextPhotoAnimation, _setNextPhotoAnimation] =
useState<AnimationConfig>();
const setNextPhotoAnimation = useCallback((animation?: AnimationConfig) => {
@ -123,10 +128,14 @@ export default function AppStateProvider({
return () => clearTimeout(timeout);
}, []);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
const { mutate } = useSWRConfig();
const invalidateSwr = useCallback(() => {
mutate(canKeyBePurged, undefined, { revalidate: false });
mutate(canKeyBePurgedAndRevalidated, undefined, { revalidate: true });
}, [mutate]);
const { data: categoriesWithCounts } = useSWR(
'getDataForCategories',
SWR_KEY_GET_COUNTS_FOR_CATEGORIES,
getCountsForCategoriesCachedAction,
);
@ -134,7 +143,7 @@ export default function AppStateProvider({
data: auth,
error: authError,
isLoading: isCheckingAuth,
} = useSWR('getAuth', getAuthAction);
} = useSWR(SWR_KEY_GET_AUTH, getAuthAction);
useEffect(() => {
if (auth === null || authError) {
setUserEmail(undefined);
@ -152,7 +161,7 @@ export default function AppStateProvider({
mutate: refreshAdminData,
isLoading: isLoadingAdminData,
} = useSWR(
isUserSignedIn ? 'getAdminData' : null,
isUserSignedIn ? SWR_KEY_GET_ADMIN_DATA : null,
getAdminDataAction,
);
const updateAdminData = useCallback(
@ -212,7 +221,6 @@ export default function AppStateProvider({
previousPathname,
hasLoaded,
hasLoadedWithAnimations,
swrTimestamp,
invalidateSwr,
nextPhotoAnimation,
setNextPhotoAnimation,

View File

@ -8,7 +8,7 @@ import {
PATH_GRID_INFERRED,
} from '@/app/paths';
import IconSearch from '../components/icons/IconSearch';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import {
GRID_HOMEPAGE_ENABLED,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,

View File

@ -11,7 +11,7 @@ import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus';
import { signOutAction } from '@/auth/actions';
import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import Spinner from '@/components/Spinner';
import { useAppText } from '@/i18n/state/client';

View File

@ -20,7 +20,7 @@ import {
} from './config';
import { useRef } from 'react';
import useStickyNav from './useStickyNav';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
const NAV_HEIGHT_CLASS = NAV_CAPTION
? 'min-h-[4rem] sm:min-h-[5rem]'

View File

@ -263,6 +263,8 @@ export const SHOW_FILMS =
CATEGORY_VISIBILITY.includes('films');
export const SHOW_FOCAL_LENGTHS =
CATEGORY_VISIBILITY.includes('focal-lengths');
export const SHOW_CATEGORY_IMAGE_HOVERS =
process.env.NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS !== '1';
export const COLLAPSE_SIDEBAR_CATEGORIES =
process.env.NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES !== '1';
export const HIDE_TAGS_WITH_ONE_PHOTO =
@ -287,8 +289,6 @@ export const SHOW_KEYBOARD_SHORTCUT_TOOLTIPS =
process.env.NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS !== '1';
export const SHOW_EXIF_DATA =
process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1';
export const SHOW_CATEGORY_IMAGE_HOVERS =
process.env.NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS === '1';
export const SHOW_ZOOM_CONTROLS =
process.env.NEXT_PUBLIC_HIDE_ZOOM_CONTROLS !== '1';
export const SHOW_TAKEN_AT_TIME =
@ -415,6 +415,7 @@ export const APP_CONFIGURATION = {
hasCategoryVisibility:
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),
categoryVisibility: CATEGORY_VISIBILITY,
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
collapseSidebarCategories: COLLAPSE_SIDEBAR_CATEGORIES,
hideTagsWithOnePhoto: HIDE_TAGS_WITH_ONE_PHOTO,
// Sort
@ -425,7 +426,6 @@ export const APP_CONFIGURATION = {
// Display
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
showExifInfo: SHOW_EXIF_DATA,
showCategoryImageHover: SHOW_CATEGORY_IMAGE_HOVERS,
showZoomControls: SHOW_ZOOM_CONTROLS,
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,

View File

@ -17,7 +17,7 @@ import {
KEY_CREDENTIALS_SUCCESS,
} from '.';
import { useSearchParams } from 'next/navigation';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import IconLock from '@/components/icons/IconLock';

View File

@ -30,7 +30,7 @@ export default async function CameraHeader({
entity={<PhotoCamera
{...{ camera }}
contrast="high"
showTooltip={false}
showHover={false}
/>}
entityDescription={
descriptionForCameraPhotos(

View File

@ -1,27 +1,22 @@
'use client';
import { AiFillApple } from 'react-icons/ai';
import { pathForCamera, pathForCameraImage } from '@/app/paths';
import { pathForCamera } from '@/app/paths';
import { Camera, formatCameraText } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import IconCamera from '@/components/icons/IconCamera';
import { isCameraApple } from '@/platforms/apple';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoCamera({
camera,
hideAppleIcon,
countOnHover,
...props
}: {
camera: Camera
hideAppleIcon?: boolean
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
const isApple = isCameraApple(camera);
const showAppleIcon = !hideAppleIcon && isApple;
@ -30,9 +25,7 @@ export default function PhotoCamera({
{...props}
label={formatCameraText(camera)}
path={pathForCamera(camera)}
tooltipImagePath={pathForCameraImage(camera)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ camera }}
icon={showAppleIcon
? <AiFillApple
title="Apple"
@ -43,7 +36,6 @@ export default function PhotoCamera({
size={15}
className="translate-x-[-0.5px] translate-y-[-0.5px]"
/>}
hoverEntity={countOnHover}
/>
);
}

View File

@ -1,8 +1,8 @@
import { createCameraKey, Camera } from '@/camera';
import { createLensKey, Lens } from '@/lens';
import { useAppState } from '@/state/AppState';
import { useCallback } from 'react';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { useAppState } from '@/app/AppState';
export default function useCategoryCounts() {
const { categoriesWithCounts } = useAppState();

View File

@ -41,7 +41,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
import { signOutAction } from '@/auth/actions';

View File

@ -2,7 +2,7 @@
import { ReactNode, useRef } from 'react';
import { Variant, motion } from 'framer-motion';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
const IGNORE_CAN_START = true;

View File

@ -1,6 +1,6 @@
'use client';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx } from 'clsx/lite';
import { HTMLAttributes } from 'react';

View File

@ -8,7 +8,7 @@ import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo';
import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
import ProgressButton from './primitives/ProgressButton';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { useAppText } from '@/i18n/state/client';
export default function ImageInput({

View File

@ -2,7 +2,7 @@
import { clsx } from 'clsx/lite';
import SimpleCheckbox from './primitives/SimpleCheckbox';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import Spinner from './Spinner';
export default function SelectTileOverlay({

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

View File

@ -1,46 +1,50 @@
'use client';
import { ComponentProps, ReactNode, RefObject, useState } from 'react';
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
import LabeledIcon, { LabeledIconType } from '../primitives/LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
import LinkWithStatus from '../LinkWithStatus';
import Spinner from '../Spinner';
import ResponsiveText from './ResponsiveText';
import OGTooltip from '../og/OGTooltip';
import ResponsiveText from '../primitives/ResponsiveText';
import { SHOW_CATEGORY_IMAGE_HOVERS } from '@/app/config';
import EntityHover from './EntityHover';
import { getPhotosCachedAction } from '@/photo/actions';
import { PhotoQueryOptions } from '@/photo/db';
import { MAX_PHOTOS_TO_SHOW_PER_CATEGORY } from '@/image-response';
export interface EntityLinkExternalProps {
ref?: RefObject<HTMLSpanElement | null>
type?: LabeledIconType
badged?: boolean
contrast?: ComponentProps<typeof Badge>['contrast']
showTooltip?: boolean
uppercase?: boolean
prefetch?: boolean
suppressSpinner?: boolean
className?: string
countOnHover?: number
showHover?: boolean
hoverPhotoQueryOptions?: PhotoQueryOptions
}
export default function EntityLink({
ref,
icon,
iconBadge,
iconBadgeStart,
iconBadgeEnd,
label,
labelSmall,
labelComplex,
iconWide,
type,
badged,
contrast = 'medium',
showTooltip = SHOW_CATEGORY_IMAGE_HOVERS,
path = '', // Make link optional for debugging purposes
tooltipImagePath,
tooltipCaption,
showHover = SHOW_CATEGORY_IMAGE_HOVERS,
countOnHover,
hoverPhotoQueryOptions,
prefetch,
title,
action,
hoverEntity,
truncate = true,
className,
classNameIcon,
@ -49,18 +53,15 @@ export default function EntityLink({
debug,
}: {
icon: ReactNode
iconBadge?: ReactNode
iconBadgeStart?: ReactNode
iconBadgeEnd?: ReactNode
label: string
labelSmall?: ReactNode
labelComplex?: ReactNode
iconWide?: boolean
path?: string
tooltipImagePath?: string
tooltipCaption?: ReactNode
prefetch?: boolean
title?: string
action?: ReactNode
hoverEntity?: ReactNode
truncate?: boolean
className?: string
classNameIcon?: string
@ -69,6 +70,8 @@ export default function EntityLink({
} & EntityLinkExternalProps) {
const [isLoading, setIsLoading] = useState(false);
const hasBadgeIcon = Boolean(iconBadgeStart || iconBadgeEnd);
const classForContrast = () => {
switch (contrast) {
case 'low':
@ -84,15 +87,15 @@ export default function EntityLink({
const showHoverEntity =
!isLoading &&
hoverEntity !== undefined &&
!showTooltip;
countOnHover &&
!showHover;
const renderLabel =
<ResponsiveText shortText={labelSmall}>
{labelComplex || label}
{label}
</ResponsiveText>;
const renderLink =
const renderLink = (useForHover?: boolean) =>
<LinkWithStatus
href={path}
className={clsx(
@ -107,28 +110,33 @@ export default function EntityLink({
setIsLoading={setIsLoading}
>
<LabeledIcon {...{
icon,
iconWide,
icon: (hasBadgeIcon && !useForHover) ? undefined : icon,
iconWide: (hasBadgeIcon && !useForHover) ? undefined : iconWide,
prefetch,
title,
type,
type: useForHover ? 'icon-first' : type,
uppercase,
classNameIcon: clsx('text-dim', classNameIcon),
className: useForHover ? 'text-white' : undefined,
classNameIcon: clsx(
!useForHover && 'text-dim',
classNameIcon,
),
debug,
}}>
{badged
{badged && !useForHover
? <Badge
type="small"
contrast={contrast}
className={clsx(
'translate-y-[-0.5px]',
iconBadge && '*:flex *:items-center *:gap-1',
hasBadgeIcon && '*:flex *:items-center *:gap-1',
)}
uppercase
interactive
>
{iconBadge}
{iconBadgeStart}
{renderLabel}
{iconBadgeEnd}
</Badge>
: <span className={clsx(
'text-content',
@ -152,23 +160,28 @@ export default function EntityLink({
className,
)}
>
{showTooltip && tooltipImagePath
? <OGTooltip
title={label}
path={tooltipImagePath}
caption={tooltipCaption}
{showHover && countOnHover && hoverPhotoQueryOptions
? <EntityHover
hoverKey={path}
header={renderLink(true)}
photosCount={countOnHover}
getPhotos={() =>
getPhotosCachedAction({
...hoverPhotoQueryOptions,
limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY,
})}
color={contrast === 'frosted' ? 'frosted' : undefined}
>
{renderLink}
</OGTooltip>
: renderLink}
{renderLink()}
</EntityHover>
: renderLink()}
{action &&
<span className="action">
{action}
</span>}
{showHoverEntity &&
<span className="hidden peer-hover:inline text-dim">
{hoverEntity}
{countOnHover}
</span>}
{isLoading && !suppressSpinner &&
<Spinner

View File

@ -2,7 +2,7 @@
/* eslint-disable jsx-a11y/alt-text */
import { BLUR_ENABLED } from '@/app/config';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { clsx} from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
export default function ImageWithFallback({
className,
classNameImage = 'object-cover h-full',
forceFallbackFade = false,
blurDataURL,
blurCompatibilityLevel = 'low',
priority,
@ -17,12 +18,14 @@ export default function ImageWithFallback({
}: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high'
classNameImage?: string
forceFallbackFade?: boolean
}) {
const { shouldDebugImageFallbacks } = useAppState();
const [isLoading, setIsLoading] = useState(true);
const [didError, setDidError] = useState(false);
const [fadeFallbackTransition, setFadeFallbackTransition] = useState(false);
const [fadeFallbackTransition, setFadeFallbackTransition] =
useState(forceFallbackFade);
const onLoad = useCallback(() => setIsLoading(false), []);
const onError = useCallback(() => setDidError(true), []);

View File

@ -1,5 +1,5 @@
import useMetaThemeColor from '@/utility/useMetaThemeColor';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import {
ComponentProps,
RefObject,

View File

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

View File

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

View File

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

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

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

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

View File

@ -5,7 +5,7 @@ import { descriptionForFilmPhotos } from '.';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFilm from '@/film/PhotoFilm';
import { getRecipePropsFromPhotos } from '@/recipe';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
@ -43,7 +43,7 @@ export default function FilmHeader({
toggleRecipeOverlay={recipeProps
? () => setRecipeModalProps?.(recipeProps)
: undefined}
showTooltip={false}
showHover={false}
/>}
entityDescription={descriptionForFilmPhotos(
photos,

View File

@ -1,33 +1,28 @@
'use client';
import PhotoFilmIcon from './PhotoFilmIcon';
import { pathForFilm, pathForFilmImage } from '@/app/paths';
import { pathForFilm } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import clsx from 'clsx/lite';
import { labelForFilm } from '.';
import { isStringFujifilmSimulation } from '@/platforms/fujifilm/simulation';
import PhotoRecipeOverlayButton from '@/recipe/PhotoRecipeOverlayButton';
import { ComponentProps } from 'react';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoFilm({
film,
type = 'icon-last',
badged = true,
contrast = 'low',
countOnHover,
toggleRecipeOverlay,
isShowingRecipeOverlay,
...props
}: {
film: string
countOnHover?: number
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
const appText = useAppText();
const { small, medium, large } = labelForFilm(film);
return (
@ -36,9 +31,7 @@ export default function PhotoFilm({
label={medium}
labelSmall={small}
path={pathForFilm(film)}
tooltipImagePath={pathForFilmImage(film)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ film }}
icon={<PhotoFilmIcon
film={film}
className={clsx(
@ -57,7 +50,6 @@ export default function PhotoFilm({
toggleRecipeOverlay,
isShowingRecipeOverlay,
}} />}
hoverEntity={countOnHover}
iconWide={isStringFujifilmSimulation(film)}
/>
);

View File

@ -27,7 +27,7 @@ export default async function FocalLengthHeader({
entity={<PhotoFocalLength
focal={focal}
contrast="high"
showTooltip={false}
showHover={false}
/>}
entityDescription={descriptionForFocalLengthPhotos(
photos,

View File

@ -1,34 +1,25 @@
'use client';
import { pathForFocalLength, pathForFocalLengthImage } from '@/app/paths';
import { pathForFocalLength } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import { formatFocalLength } from '.';
import IconFocalLength from '@/components/icons/IconFocalLength';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoFocalLength({
focal,
countOnHover,
...props
}: {
focal: number
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatFocalLength(focal)}
path={pathForFocalLength(focal)}
tooltipImagePath={pathForFocalLengthImage(focal)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ focal }}
icon={<IconFocalLength className="translate-y-[-1px]" />}
hoverEntity={countOnHover}
/>
);
}

View File

@ -30,7 +30,7 @@ export default async function LensHeader({
entity={<PhotoLens
{...{ lens }}
contrast="high"
showTooltip={false}
showHover={false}
/>}
entityDescription={
descriptionForLensPhotos(

View File

@ -1,39 +1,30 @@
'use client';
import { pathForLens, pathForLensImage } from '@/app/paths';
import { pathForLens } from '@/app/paths';
import { Lens, formatLensText } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import IconLens from '@/components/icons/IconLens';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoLens({
lens,
countOnHover,
shortText,
...props
}: {
lens: Lens
countOnHover?: number
shortText?: boolean
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatLensText(lens, shortText ? 'short' : 'medium')}
path={pathForLens(lens)}
tooltipImagePath={pathForLensImage(lens)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ lens }}
icon={<IconLens
size={14}
className="translate-x-[-0.5px]"
/>}
hoverEntity={countOnHover}
/>
);
}

View File

@ -14,10 +14,15 @@ import { getPhotosCachedAction, getPhotosAction } from '@/photo/actions';
import { Photo } from '.';
import { PhotoSetCategory } from '../category';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import useVisible from '@/utility/useVisible';
import { ADMIN_DB_OPTIMIZE_ENABLED } from '@/app/config';
import { SortBy } from './db/sort';
import { SWR_KEY_INFINITE_PHOTO_SCROLL } from '@/swr';
const SIZE_KEY_SEPARATOR = '__';
const getSizeFromKey = (key: string) =>
parseInt(key.split(SIZE_KEY_SEPARATOR)[1]);
export type RevalidatePhoto = (
photoId: string,
@ -55,22 +60,20 @@ export default function InfinitePhotoScroll({
revalidatePhoto?: RevalidatePhoto
}) => ReactNode
} & PhotoSetCategory) {
const { swrTimestamp, isUserSignedIn } = useAppState();
const key = `${swrTimestamp}-${cacheKey}`;
const { isUserSignedIn } = useAppState();
const keyGenerator = useCallback(
(size: number, prev: Photo[]) => prev && prev.length === 0
? null
: [key, size]
, [key]);
: `${SWR_KEY_INFINITE_PHOTO_SCROLL}-${cacheKey}__${size}`
, [cacheKey]);
const fetcher = useCallback((
[_key, size]: [string, number],
keyWithSize: string,
warmOnly?: boolean,
) =>
(useCachedPhotos ? getPhotosCachedAction : getPhotosAction)({
offset: initialOffset + size * itemsPerPage,
offset: initialOffset + getSizeFromKey(keyWithSize) * itemsPerPage,
sortBy,
sortWithPriority,
limit: itemsPerPage,
@ -111,7 +114,7 @@ export default function InfinitePhotoScroll({
useEffect(() => {
if (ADMIN_DB_OPTIMIZE_ENABLED) {
fetcher(['', 0], true);
fetcher(`${SIZE_KEY_SEPARATOR}0`, true);
}
}, [fetcher]);

View File

@ -6,7 +6,7 @@ import PhotoMedium from './PhotoMedium';
import { clsx } from 'clsx/lite';
import AnimateItems from '@/components/AnimateItems';
import { GRID_ASPECT_RATIO } from '@/app/config';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import SelectTileOverlay from '@/components/SelectTileOverlay';
import { ReactNode } from 'react';
import { GRID_GAP_CLASSNAME } from '@/components';
@ -14,7 +14,7 @@ import { GRID_GAP_CLASSNAME } from '@/components';
export default function PhotoGrid({
photos,
selectedPhoto,
photoPriority,
prioritizeInitialPhotos,
animate = true,
canStart,
animateOnFirstLoadOnly,
@ -28,7 +28,7 @@ export default function PhotoGrid({
}: {
photos: Photo[]
selectedPhoto?: Photo
photoPriority?: boolean
prioritizeInitialPhotos?: boolean
animate?: boolean
canStart?: boolean
animateOnFirstLoadOnly?: boolean
@ -90,7 +90,7 @@ export default function PhotoGrid({
photo,
...categories,
selected: photo.id === selectedPhoto?.id,
priority: photoPriority,
priority: prioritizeInitialPhotos ? index < 6 : undefined,
onVisible: index === photos.length - 1
? onLastPhotoVisible
: undefined,

View File

@ -5,7 +5,7 @@ import { PATH_GRID_INFERRED } from '@/app/paths';
import PhotoGridSidebar from './PhotoGridSidebar';
import PhotoGridContainer from './PhotoGridContainer';
import { ComponentProps, useEffect, useRef } from 'react';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import clsx from 'clsx/lite';
import useElementHeight from '@/utility/useElementHeight';
import MaskedScroll from '@/components/MaskedScroll';
@ -42,6 +42,7 @@ export default function PhotoGridPageClient({
count={photosCount}
sortBy={sortBy}
sortWithPriority={sortWithPriority}
prioritizeInitialPhotos
sidebar={
<MaskedScroll
ref={ref}

View File

@ -7,10 +7,14 @@ import { photoQuantityText } from '.';
import { TAG_FAVS, TAG_HIDDEN, addHiddenToTags, limitTagsByCount } from '@/tag';
import PhotoFilm from '@/film/PhotoFilm';
import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { useMemo, useRef } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { CATEGORY_VISIBILITY, HIDE_TAGS_WITH_ONE_PHOTO } from '@/app/config';
import {
CATEGORY_VISIBILITY,
HIDE_TAGS_WITH_ONE_PHOTO,
SHOW_CATEGORY_IMAGE_HOVERS,
} from '@/app/config';
import { clsx } from 'clsx/lite';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import IconCamera from '@/components/icons/IconCamera';
@ -124,7 +128,7 @@ export default function PhotoGridSidebar({
<PhotoYear
key={year}
year={year}
countOnHover={count}
countOnHover={SHOW_CATEGORY_IMAGE_HOVERS ? count : undefined}
type="text-only"
prefetch={false}
contrast="low"

View File

@ -15,7 +15,7 @@ import DivDebugBaselineGrid from '@/components/DivDebugBaselineGrid';
import PhotoPrevNextActions from './PhotoPrevNextActions';
import PhotoLink from './PhotoLink';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { GRID_GAP_CLASSNAME } from '@/components';
import { useAppText } from '@/i18n/state/client';

View File

@ -37,7 +37,7 @@ import { RevalidatePhoto } from './InfinitePhotoScroll';
import { useCallback, useMemo, useRef } from 'react';
import useVisible from '@/utility/useVisible';
import PhotoDate from './PhotoDate';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { LuExpand } from 'react-icons/lu';
import LoaderButton from '@/components/primitives/LoaderButton';
import Tooltip from '@/components/Tooltip';
@ -80,6 +80,7 @@ export default function PhotoLarge({
shouldShareRecipe,
shouldShareFocalLength,
includeFavoriteInAdminMenu,
forceFallbackFade,
onVisible,
showAdminKeyCommands,
}: {
@ -110,6 +111,7 @@ export default function PhotoLarge({
shouldShareRecipe?: boolean
shouldShareFocalLength?: boolean
includeFavoriteInAdminMenu?: boolean
forceFallbackFade?: boolean
onVisible?: () => void
showAdminKeyCommands?: boolean
}) {
@ -232,6 +234,7 @@ export default function PhotoLarge({
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
forceFallbackFade={forceFallbackFade}
/>
</ZoomControls>
<div className={clsx(

View File

@ -4,7 +4,7 @@ import { ReactNode, ComponentProps, RefObject } from 'react';
import { Photo, titleForPhoto } from '@/photo';
import { PhotoSetCategory } from '@/category';
import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { pathForPhoto } from '@/app/paths';
import { clsx } from 'clsx/lite';
import LinkWithStatus from '@/components/LinkWithStatus';

View File

@ -21,6 +21,7 @@ export default function PhotoMedium({
priority,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
className,
forceFallbackFade,
onVisible,
...categories
}: {
@ -29,6 +30,7 @@ export default function PhotoMedium({
priority?: boolean
prefetch?: boolean
className?: string
forceFallbackFade?: boolean
onVisible?: () => void
} & PhotoSetCategory) {
const ref = useRef<HTMLAnchorElement>(null);
@ -66,6 +68,7 @@ export default function PhotoMedium({
classNameImage="object-cover w-full h-full"
alt={altTextForPhoto(photo)}
priority={priority}
forceFallbackFade={forceFallbackFade}
/>
</div>}
</LinkWithStatus>

View File

@ -10,7 +10,7 @@ import {
import { PhotoSetCategory } from '../category';
import PhotoLink from './PhotoLink';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { AnimationConfig } from '@/components/AnimateItems';
import { clsx } from 'clsx/lite';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';

View File

@ -17,6 +17,7 @@ export default function PhotoSmall({
selected,
className,
prefetch = SHOULD_PREFETCH_ALL_LINKS,
forceFallbackFade,
onVisible,
...categories
}: {
@ -24,6 +25,7 @@ export default function PhotoSmall({
selected?: boolean
className?: string
prefetch?: boolean
forceFallbackFade?: boolean
onVisible?: () => void
} & PhotoSetCategory) {
const ref = useRef<HTMLAnchorElement>(null);
@ -50,6 +52,7 @@ export default function PhotoSmall({
blurDataURL={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
alt={altTextForPhoto(photo)}
forceFallbackFade={forceFallbackFade}
/>
</Link>
);

View File

@ -5,7 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/paths';
import ImageInput from '../components/ImageInput';
import { clsx } from 'clsx/lite';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { RefObject, useTransition, useRef, useEffect } from 'react';
import Spinner from '@/components/Spinner';
import ResponsiveText from '@/components/primitives/ResponsiveText';

View File

@ -14,7 +14,7 @@ import {
renamePhotoRecipeGlobally,
getPhotosNeedingRecipeTitleCount,
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import { PhotoQueryOptions, areOptionsSensitive } from './db';
import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData,
@ -546,7 +546,7 @@ export const getImageBlurAction = async (url: string) =>
// Public/Private actions
export const getPhotosAction = async (
options: GetPhotosOptions,
options: PhotoQueryOptions,
warmOnly?: boolean,
) => {
if (warmOnly) {
@ -559,7 +559,7 @@ export const getPhotosAction = async (
};
export const getPhotosCachedAction = async (
options: GetPhotosOptions,
options: PhotoQueryOptions,
warmOnly?: boolean,
) => {
if (warmOnly) {

View File

@ -18,7 +18,7 @@ import {
getUniqueRecipes,
getUniqueYears,
} from '@/photo/db/query';
import { GetPhotosOptions } from './db';
import { PhotoQueryOptions } from './db';
import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo';
import { createCameraKey } from '@/camera';
import {
@ -54,9 +54,9 @@ const KEY_YEARS = 'years';
const KEY_COUNT = 'count';
const KEY_DATE_RANGE = 'date-range';
const getPhotosCacheKeyForOption = (
options: GetPhotosOptions,
option: keyof GetPhotosOptions,
const getCacheKeyForPhotoQueryOptions = (
options: PhotoQueryOptions,
option: keyof PhotoQueryOptions,
): string | null => {
switch (option) {
// Complex keys
@ -81,13 +81,13 @@ const getPhotosCacheKeyForOption = (
}
};
const getPhotosCacheKeys = (options: GetPhotosOptions = {}) => {
const getPhotosCacheKeys = (options: PhotoQueryOptions = {}) => {
const tags: string[] = [];
Object.keys(options).forEach(key => {
const tag = getPhotosCacheKeyForOption(
const tag = getCacheKeyForPhotoQueryOptions(
options,
key as keyof GetPhotosOptions,
key as keyof PhotoQueryOptions,
);
if (tag) { tags.push(tag); }
});

View File

@ -19,7 +19,7 @@ const parameterizeForDb = (field: string) =>
`REPLACE(${acc}, '${from}', '${to}')`
, `LOWER(TRIM(${field}))`);
export type GetPhotosOptions = {
export type PhotoQueryOptions = {
sortBy?: SortBy
sortWithPriority?: boolean
limit?: number
@ -35,11 +35,11 @@ export type GetPhotosOptions = {
lens?: Partial<Lens>
};
export const areOptionsSensitive = (options: GetPhotosOptions) =>
export const areOptionsSensitive = (options: PhotoQueryOptions) =>
options.hidden === 'include' || options.hidden === 'only';
export const getWheresFromOptions = (
options: GetPhotosOptions,
options: PhotoQueryOptions,
initialValuesIndex = 1,
) => {
const {
@ -149,7 +149,7 @@ export const getWheresFromOptions = (
};
};
export const getOrderByFromOptions = (options: GetPhotosOptions) => {
export const getOrderByFromOptions = (options: PhotoQueryOptions) => {
const {
sortBy = APP_DEFAULT_SORT_BY,
sortWithPriority,
@ -176,7 +176,7 @@ export const getOrderByFromOptions = (options: GetPhotosOptions) => {
};
export const getLimitAndOffsetFromOptions = (
options: GetPhotosOptions,
options: PhotoQueryOptions,
initialValuesIndex = 1,
) => {
const {

View File

@ -21,7 +21,7 @@ import {
AI_TEXT_GENERATION_ENABLED,
} from '@/app/config';
import {
GetPhotosOptions,
PhotoQueryOptions,
getOrderByFromOptions,
getLimitAndOffsetFromOptions,
getWheresFromOptions,
@ -80,7 +80,7 @@ const createPhotosTable = () =>
const safelyQueryPhotos = async <T>(
callback: () => Promise<T>,
queryLabel: string,
queryOptions?: GetPhotosOptions,
queryOptions?: PhotoQueryOptions,
): Promise<T> => {
let result: T;
@ -489,7 +489,7 @@ export const getUniqueFocalLengths = async () =>
})))
, 'getUniqueFocalLengths');
export const getPhotos = async (options: GetPhotosOptions = {}) =>
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
safelyQueryPhotos(async () => {
const sql = ['SELECT * FROM photos'];
const values = [] as (string | number)[];
@ -526,7 +526,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) =>
export const getPhotosNearId = async (
photoId: string,
options: GetPhotosOptions,
options: PhotoQueryOptions,
) =>
safelyQueryPhotos(async () => {
const { limit } = options;
@ -565,7 +565,7 @@ export const getPhotosNearId = async (
});
}, `getPhotosNearId: ${photoId}`);
export const getPhotosMeta = (options: GetPhotosOptions = {}) =>
export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
safelyQueryPhotos(async () => {
// eslint-disable-next-line max-len
let sql = 'SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos';

View File

@ -33,7 +33,7 @@ import { AiContent } from '../ai/useAiImageQueries';
import AiButton from '../ai/AiButton';
import Spinner from '@/components/Spinner';
import usePreventNavigation from '@/utility/usePreventNavigation';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import UpdateBlurDataButton from '../UpdateBlurDataButton';
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
@ -327,6 +327,7 @@ export default function PhotoForm({
onSubmit={() => {
setFormActionErrorMessage('');
(document.activeElement as HTMLElement)?.blur?.();
invalidateSwr?.();
}}
>
{/* Fields */}
@ -473,7 +474,6 @@ export default function PhotoForm({
icon={type === 'create' && <IconAddUpload />}
disabled={!canFormBeSubmitted}
onFormStatusChange={onFormStatusChange}
onFormSubmit={invalidateSwr}
primary
>
{type === 'create' ? 'Add' : 'Update'}

View File

@ -1,16 +1,10 @@
import { PREFIX_RECENTS, pathForRecentsImage } from '@/app/paths';
import { PREFIX_RECENTS } from '@/app/paths';
import EntityLink, { EntityLinkExternalProps } from
'@/components/primitives/EntityLink';
'@/components/entity/EntityLink';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
import IconRecents from '@/components/icons/IconRecents';
export default function PhotoRecents({
countOnHover,
...props
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
export default function PhotoRecents(props: EntityLinkExternalProps) {
const appText = useAppText();
return (
@ -18,12 +12,9 @@ export default function PhotoRecents({
{...props}
label={appText.category.recentPlural}
path={PREFIX_RECENTS}
tooltipImagePath={pathForRecentsImage()}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ recent: true }}
icon={<IconRecents size={16} />}
iconBadge={<IconRecents size={10} solid />}
hoverEntity={countOnHover}
iconBadgeStart={<IconRecents size={10} solid />}
/>
);
}

View File

@ -24,7 +24,7 @@ export default function RecentsHeader({
return (
<PhotoHeader
recent={true}
entity={<PhotoRecents showTooltip={false} />}
entity={<PhotoRecents showHover={false} />}
entityDescription={descriptionForPhotoSet(
photos,
appText,

View File

@ -1,31 +1,25 @@
'use client';
import { pathForRecipe, pathForRecipeImage } from '@/app/paths';
import { pathForRecipe } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import { formatRecipe } from '.';
import clsx from 'clsx/lite';
import { ComponentProps } from 'react';
import IconRecipe from '@/components/icons/IconRecipe';
import PhotoRecipeOverlayButton from './PhotoRecipeOverlayButton';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoRecipe({
ref,
recipe,
countOnHover,
toggleRecipeOverlay,
isShowingRecipeOverlay,
...props
}: {
recipe: string
countOnHover?: number
} & Partial<ComponentProps<typeof PhotoRecipeOverlayButton>>
& EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
@ -33,13 +27,11 @@ export default function PhotoRecipe({
title="Recipe"
label={formatRecipe(recipe)}
path={pathForRecipe(recipe)}
tooltipImagePath={pathForRecipeImage(recipe)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ recipe }}
icon={<IconRecipe
size={16}
className={clsx(
props.badged && 'translate-x-[-1px] translate-y-[0.5px]',
props.badged && 'translate-x-[-1px] translate-y-[-1px]',
)}
/>}
action={toggleRecipeOverlay &&
@ -47,7 +39,6 @@ export default function PhotoRecipe({
toggleRecipeOverlay,
isShowingRecipeOverlay,
}} />}
hoverEntity={countOnHover}
/>
);
}

View File

@ -3,7 +3,7 @@
import { Photo, PhotoDateRange } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
@ -35,7 +35,7 @@ export default function RecipeHeader({
entity={<PhotoRecipe
recipe={recipe}
contrast="high"
showTooltip={false}
showHover={false}
isShowingRecipeOverlay={Boolean(recipeModalProps)}
toggleRecipeOverlay={recipeProps
? () => setRecipeModalProps?.(recipeProps)

View File

@ -1,7 +1,7 @@
'use client';
import Modal from '@/components/Modal';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import PhotoRecipeOverlay from './PhotoRecipeOverlay';
export default function ShareModals() {

View File

@ -3,7 +3,7 @@
import { TbPhotoShare } from 'react-icons/tb';
import { clsx } from 'clsx/lite';
import LoaderButton from '@/components/primitives/LoaderButton';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { getSharePathFromShareModalProps, ShareModalProps } from '.';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';

View File

@ -10,7 +10,7 @@ import { toastSuccess } from '@/toast';
import { PiXLogo } from 'react-icons/pi';
import { SHOW_SOCIAL } from '@/app/config';
import { generateXPostText } from '@/utility/social';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import useOnPathChange from '@/utility/useOnPathChange';
import { IoArrowUp } from 'react-icons/io5';
import MaskedScroll from '@/components/MaskedScroll';

View File

@ -5,7 +5,7 @@ import TagShareModal from '@/tag/TagShareModal';
import CameraShareModal from '@/camera/CameraShareModal';
import FilmShareModal from '@/film/FilmShareModal';
import FocalLengthShareModal from '@/focal/FocalLengthShareModal';
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import RecipeShareModal from '@/recipe/RecipeShareModal';
import LensShareModal from '@/lens/LensShareModal';
import YearShareModal from '@/years/YearShareModal';

21
src/swr/index.ts Normal file
View 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));

View File

@ -1,54 +1,29 @@
'use client';
import { TAG_FAVS } from '.';
import { pathForTag, pathForTagImage } from '@/app/paths';
import { pathForTag } from '@/app/paths';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import IconFavs from '@/components/icons/IconFavs';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function FavsTag({
type,
badged,
contrast,
prefetch,
countOnHover,
className,
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
export default function FavsTag(props: EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={TAG_FAVS}
labelComplex={badged &&
<span className="inline-flex gap-1 items-center">
{TAG_FAVS}
<IconFavs
size={10}
className="translate-y-[-0.5px]"
highlight
/>
</span>}
path={pathForTag(TAG_FAVS)}
tooltipImagePath={pathForTagImage(TAG_FAVS)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
icon={!badged &&
<IconFavs
size={13}
className="translate-x-[-0.5px] translate-y-[-0.5px]"
highlight
/>}
type={type}
className={className}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}
prefetch={prefetch}
hoverPhotoQueryOptions={{ tag: TAG_FAVS }}
icon={<IconFavs
size={13}
className="translate-x-[-0.5px] translate-y-[-0.5px]"
highlight
/>}
iconBadgeEnd={<IconFavs
size={10}
className="translate-y-[-0.5px]"
highlight
/>}
/>
);
}

View File

@ -3,37 +3,19 @@ import { pathForTag } from '@/app/paths';
import IconHidden from '@/components/icons/IconHidden';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
export default function HiddenTag({
type,
badged,
contrast,
prefetch,
countOnHover,
className,
}: {
countOnHover?: number
} & EntityLinkExternalProps) {
export default function HiddenTag(props: EntityLinkExternalProps) {
return (
<EntityLink
{...props}
label={TAG_HIDDEN}
labelComplex={badged &&
<span className="inline-flex items-center gap-1">
{TAG_HIDDEN}
<IconHidden
size={13}
className="translate-y-[-0.5px]"
/>
</span>}
path={pathForTag(TAG_HIDDEN)}
icon={!badged && <IconHidden size={16} />}
type={type}
className={className}
hoverEntity={countOnHover}
badged={badged}
contrast={contrast}
prefetch={prefetch}
icon={<IconHidden size={16} />}
iconBadgeEnd={<IconHidden
size={13}
className="translate-y-[-0.5px]"
/>}
/>
);
}

View File

@ -1,34 +1,25 @@
'use client';
import { pathForTag, pathForTagImage } from '@/app/paths';
import { pathForTag } from '@/app/paths';
import { formatTag } from '.';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
} from '@/components/entity/EntityLink';
import IconTag from '@/components/icons/IconTag';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoTag({
tag,
countOnHover,
...props
}: {
tag: string
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={formatTag(tag)}
path={pathForTag(tag)}
tooltipImagePath={pathForTagImage(tag)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ tag }}
icon={<IconTag size={14} className="translate-x-[0.5px]" />}
hoverEntity={countOnHover}
/>
);
}

View File

@ -1,7 +1,7 @@
import PhotoTag from '@/tag/PhotoTag';
import { isTagFavs } from '.';
import FavsTag from './FavsTag';
import { EntityLinkExternalProps } from '@/components/primitives/EntityLink';
import { EntityLinkExternalProps } from '@/components/entity/EntityLink';
import { Fragment } from 'react';
export default function PhotoTags({

View File

@ -28,12 +28,12 @@ export default async function TagHeader({
entity={isTagFavs(tag)
? <FavsTag
contrast="high"
showTooltip={false}
showHover={false}
/>
: <PhotoTag
tag={tag}
contrast="high"
showTooltip={false}
showHover={false}
/>}
entityVerb={appText.category.taggedPhotos}
entityDescription={descriptionForTaggedPhotos(

View File

@ -1,4 +1,4 @@
import { useAppState } from '@/state/AppState';
import { useAppState } from '@/app/AppState';
import { useCallback, useEffect } from 'react';
const LISTENER_KEYDOWN = 'keydown';

View File

@ -1,33 +1,24 @@
import { pathForYear, pathForYearImage } from '@/app/paths';
import { pathForYear } from '@/app/paths';
import EntityLink, { EntityLinkExternalProps } from
'@/components/primitives/EntityLink';
'@/components/entity/EntityLink';
import IconYear from '@/components/icons/IconYear';
import { useAppText } from '@/i18n/state/client';
import { photoQuantityText } from '@/photo';
export default function PhotoYear({
year,
countOnHover,
...props
}: {
year: string
countOnHover?: number
} & EntityLinkExternalProps) {
const appText = useAppText();
return (
<EntityLink
{...props}
label={year}
path={pathForYear(year)}
tooltipImagePath={pathForYearImage(year)}
tooltipCaption={countOnHover &&
photoQuantityText(countOnHover, appText, false)}
hoverPhotoQueryOptions={{ year }}
icon={<IconYear
size={14}
className="translate-x-[0.5px] translate-y-[-0.5px]"
/>}
hoverEntity={countOnHover}
/>
);
}

View File

@ -29,7 +29,7 @@ export default function YearHeader({
entity={<PhotoYear
year={year}
contrast="high"
showTooltip={false}
showHover={false}
/>}
entityDescription={descriptionForPhotoSet(
photos,

View File

@ -42,6 +42,7 @@ html {
--text-3xl--line-height: 1.5rem;
--animate-fade-in: fade-in 0.5s linear both running;
--animate-fade-in-fast: fade-in 0.2s linear both running;
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }