diff --git a/README.md b/README.md index 5833afb7..22dcebf2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin/baseline/page.tsx b/app/admin/baseline/page.tsx index 099c4dd6..9b975fc0 100644 --- a/app/admin/baseline/page.tsx +++ b/app/admin/baseline/page.tsx @@ -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'; diff --git a/app/feed/[sortType]/[sortOrder]/page.tsx b/app/feed/[sortType]/[sortOrder]/page.tsx index a047a7ed..bb217755 100644 --- a/app/feed/[sortType]/[sortOrder]/page.tsx +++ b/app/feed/[sortType]/[sortOrder]/page.tsx @@ -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, })); diff --git a/app/feed/page.tsx b/app/feed/page.tsx index 3628396e..7034e0b5 100644 --- a/app/feed/page.tsx +++ b/app/feed/page.tsx @@ -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, })); diff --git a/app/grid/[sortType]/[sortOrder]/page.tsx b/app/grid/[sortType]/[sortOrder]/page.tsx index b9596aca..0a2abe19 100644 --- a/app/grid/[sortType]/[sortOrder]/page.tsx +++ b/app/grid/[sortType]/[sortOrder]/page.tsx @@ -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, })); diff --git a/app/grid/page.tsx b/app/grid/page.tsx index 219a2aa8..bce9cd24 100644 --- a/app/grid/page.tsx +++ b/app/grid/page.tsx @@ -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, })); diff --git a/app/layout.tsx b/app/layout.tsx index 028efaf4..6bf350a4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ - +
-
+
diff --git a/app/page.tsx b/app/page.tsx index 2e2ecafd..f6a6b435 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 diff --git a/package.json b/package.json index 5e9b2c06..55e986e1 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ }, "dependencies": { "@ai-sdk/openai": "^1.3.22", - "@aws-sdk/client-s3": "3.840.0", - "@aws-sdk/s3-request-presigner": "3.840.0", + "@aws-sdk/client-s3": "3.842.0", + "@aws-sdk/s3-request-presigner": "3.842.0", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-tooltip": "^1.2.7", @@ -28,9 +28,9 @@ "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "fast-deep-equal": "^3.1.3", - "framer-motion": "^12.22.0", + "framer-motion": "^12.23.0", "nanoid": "^5.1.5", - "next": "15.3.4", + "next": "15.3.5", "next-auth": "5.0.0-beta.28", "next-themes": "^0.4.6", "pg": "^8.16.3", @@ -39,16 +39,16 @@ "react-icons": "^5.5.0", "sanitize-html": "^2.17.0", "sharp": "^0.34.2", - "sonner": "^2.0.5", - "swr": "^2.3.3", + "sonner": "^2.0.6", + "swr": "^2.3.4", "ts-exif-parser": "^0.2.2", "use-debounce": "^10.0.5", "viewerjs": "^1.11.7" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", - "@next/bundle-analyzer": "15.3.4", - "@next/eslint-plugin-next": "^15.3.4", + "@next/bundle-analyzer": "15.3.5", + "@next/eslint-plugin-next": "^15.3.5", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/postcss": "^4.1.11", @@ -63,10 +63,10 @@ "@types/sanitize-html": "^2.16.0", "cross-fetch": "^4.1.0", "eslint": "9.30.1", - "eslint-config-next": "15.3.4", + "eslint-config-next": "15.3.5", "eslint-plugin-react-hooks": "^5.2.0", - "jest": "^30.0.3", - "jest-environment-jsdom": "^30.0.2", + "jest": "^30.0.4", + "jest-environment-jsdom": "^30.0.4", "postcss": "8.5.6", "tailwindcss": "4.1.11", "ts-node": "^10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b05ddbb..45fb6e87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^1.3.22 version: 1.3.22(zod@3.24.2) '@aws-sdk/client-s3': - specifier: 3.840.0 - version: 3.840.0 + specifier: 3.842.0 + version: 3.842.0 '@aws-sdk/s3-request-presigner': - specifier: 3.840.0 - version: 3.840.0 + specifier: 3.842.0 + version: 3.842.0 '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -37,13 +37,13 @@ importers: version: 1.35.1 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 1.5.0(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) '@vercel/blob': specifier: ^1.1.1 version: 1.1.1 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 1.2.0(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) ai: specifier: ^4.3.16 version: 4.3.16(react@19.1.0)(zod@3.24.2) @@ -66,17 +66,17 @@ importers: specifier: ^3.1.3 version: 3.1.3 framer-motion: - specifier: ^12.22.0 - version: 12.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^12.23.0 + version: 12.23.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nanoid: specifier: ^5.1.5 version: 5.1.5 next: - specifier: 15.3.4 - version: 15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 15.3.5 + version: 15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: specifier: 5.0.0-beta.28 - version: 5.0.0-beta.28(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + version: 5.0.0-beta.28(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -99,11 +99,11 @@ importers: specifier: ^0.34.2 version: 0.34.2 sonner: - specifier: ^2.0.5 - version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^2.0.6 + version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) swr: - specifier: ^2.3.3 - version: 2.3.3(react@19.1.0) + specifier: ^2.3.4 + version: 2.3.4(react@19.1.0) ts-exif-parser: specifier: ^0.2.2 version: 0.2.2 @@ -118,11 +118,11 @@ importers: specifier: ^3.3.1 version: 3.3.1 '@next/bundle-analyzer': - specifier: 15.3.4 - version: 15.3.4 + specifier: 15.3.5 + version: 15.3.5 '@next/eslint-plugin-next': - specifier: ^15.3.4 - version: 15.3.4 + specifier: ^15.3.5 + version: 15.3.5 '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@4.1.11) @@ -166,17 +166,17 @@ importers: specifier: 9.30.1 version: 9.30.1(jiti@2.4.2) eslint-config-next: - specifier: 15.3.4 - version: 15.3.4(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) + specifier: 15.3.5 + version: 15.3.5(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.30.1(jiti@2.4.2)) jest: - specifier: ^30.0.3 - version: 30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + specifier: ^30.0.4 + version: 30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) jest-environment-jsdom: - specifier: ^30.0.2 - version: 30.0.2 + specifier: ^30.0.4 + version: 30.0.4 postcss: specifier: 8.5.6 version: 8.5.6 @@ -275,8 +275,8 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.840.0': - resolution: {integrity: sha512-dRuo03EqGBbl9+PTogpwY9bYmGWIjn8nB82HN5Qj20otgjUvhLOdEkkip9mroYsrvqNoKbMedWdCudIcB/YY1w==} + '@aws-sdk/client-s3@3.842.0': + resolution: {integrity: sha512-T5Rh72Rcq1xIaM8KkTr1Wpr7/WPCYO++KrM+/Em0rq2jxpjMMhj77ITpgH7eEmNxWmwIndTwqpgfmbpNfk7Gbw==} engines: {node: '>=18.0.0'} '@aws-sdk/client-sso@3.840.0': @@ -363,8 +363,8 @@ packages: resolution: {integrity: sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==} engines: {node: '>=18.0.0'} - '@aws-sdk/s3-request-presigner@3.840.0': - resolution: {integrity: sha512-1jcrhVoSZjiAQJGNswI0RGR36/+OG6yTV42wQamHdNHk+/68dn9MGTUVr+58AEFOyEAPE/EvkiYRD6n5WkUjMg==} + '@aws-sdk/s3-request-presigner@3.842.0': + resolution: {integrity: sha512-daS69IJ20X+BzsiEtj3XuyyM765iFOdZ648lrptHncQHRWdpzahk67/nP/SKYhWvnNrQ4pw2vYlVxpOs9vl1yg==} engines: {node: '>=18.0.0'} '@aws-sdk/signature-v4-multi-region@3.840.0': @@ -838,12 +838,12 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} - '@jest/console@30.0.2': - resolution: {integrity: sha512-krGElPU0FipAqpVZ/BRZOy0MZh/ARdJ0Nj+PiH1ykFY1+VpBlYNLjdjVA5CFKxnKR6PFqFutO4Z7cdK9BlGiDA==} + '@jest/console@30.0.4': + resolution: {integrity: sha512-tMLCDvBJBwPqMm4OAiuKm2uF5y5Qe26KgcMn+nrDSWpEW+eeFmqA0iO4zJfL16GP7gE3bUUQ3hIuUJ22AqVRnw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/core@30.0.3': - resolution: {integrity: sha512-Mgs1N+NSHD3Fusl7bOq1jyxv1JDAUwjy+0DhVR93Q6xcBP9/bAQ+oZhXb5TTnP5sQzAHgb7ROCKQ2SnovtxYtg==} + '@jest/core@30.0.4': + resolution: {integrity: sha512-MWScSO9GuU5/HoWjpXAOBs6F/iobvK1XlioelgOM9St7S0Z5WTI9kjCQLPeo4eQRRYusyLW25/J7J5lbFkrYXw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -859,8 +859,8 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/environment-jsdom-abstract@30.0.2': - resolution: {integrity: sha512-8aMoEzGdUuJeQl71BUACkys1ZEX437AF376VBqdYXsGFd4l3F1SdTjFHmNq8vF0Rp+CYhUyxa0kRAzXbBaVzfQ==} + '@jest/environment-jsdom-abstract@30.0.4': + resolution: {integrity: sha512-pUKfqgr5Nki9kZ/3iV+ubDsvtPq0a0oNL6zqkKLM1tPQI8FBJeuWskvW1kzc5pOvqlgpzumYZveJ4bxhANY0hg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -869,24 +869,24 @@ packages: canvas: optional: true - '@jest/environment@30.0.2': - resolution: {integrity: sha512-hRLhZRJNxBiOhxIKSq2UkrlhMt3/zVFQOAi5lvS8T9I03+kxsbflwHJEF+eXEYXCrRGRhHwECT7CDk6DyngsRA==} + '@jest/environment@30.0.4': + resolution: {integrity: sha512-5NT+sr7ZOb8wW7C4r7wOKnRQ8zmRWQT2gW4j73IXAKp5/PX1Z8MCStBLQDYfIG3n1Sw0NRfYGdp0iIPVooBAFQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/expect-utils@30.0.0': resolution: {integrity: sha512-UiWfsqNi/+d7xepfOv8KDcbbzcYtkWBe3a3kVDtg6M1kuN6CJ7b4HzIp5e1YHrSaQaVS8sdCoyCMCZClTLNKFQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@30.0.3': - resolution: {integrity: sha512-SMtBvf2sfX2agcT0dA9pXwcUrKvOSDqBY4e4iRfT+Hya33XzV35YVg+98YQFErVGA/VR1Gto5Y2+A6G9LSQ3Yg==} + '@jest/expect-utils@30.0.4': + resolution: {integrity: sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect@30.0.3': - resolution: {integrity: sha512-73BVLqfCeWjYWPEQoYjiRZ4xuQRhQZU0WdgvbyXGRHItKQqg5e6mt2y1kVhzLSuZpmUnccZHbGynoaL7IcLU3A==} + '@jest/expect@30.0.4': + resolution: {integrity: sha512-Z/DL7t67LBHSX4UzDyeYKqOxE/n7lbrrgEwWM3dGiH5Dgn35nk+YtgzKudmfIrBI8DRRrKYY5BCo3317HZV1Fw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/fake-timers@30.0.2': - resolution: {integrity: sha512-jfx0Xg7l0gmphTY9UKm5RtH12BlLYj/2Plj6wXjVW5Era4FZKfXeIvwC67WX+4q8UCFxYS20IgnMcFBcEU0DtA==} + '@jest/fake-timers@30.0.4': + resolution: {integrity: sha512-qZ7nxOcL5+gwBO6LErvwVy5k06VsX/deqo2XnVUSTV0TNC9lrg8FC3dARbi+5lmrr5VyX5drragK+xLcOjvjYw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/get-type@30.0.0': @@ -897,8 +897,8 @@ packages: resolution: {integrity: sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/globals@30.0.3': - resolution: {integrity: sha512-fIduqNyYpMeeSr5iEAiMn15KxCzvrmxl7X7VwLDRGj7t5CoHtbF+7K3EvKk32mOUIJ4kIvFRlaixClMH2h/Vaw==} + '@jest/globals@30.0.4': + resolution: {integrity: sha512-avyZuxEHF2EUhFF6NEWVdxkRRV6iXXcIES66DLhuLlU7lXhtFG/ySq/a8SRZmEJSsLkNAFX6z6mm8KWyXe9OEA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/pattern@30.0.0': @@ -909,8 +909,8 @@ packages: resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/reporters@30.0.2': - resolution: {integrity: sha512-l4QzS/oKf57F8WtPZK+vvF4Io6ukplc6XgNFu4Hd/QxaLEO9f+8dSFzUua62Oe0HKlCUjKHpltKErAgDiMJKsA==} + '@jest/reporters@30.0.4': + resolution: {integrity: sha512-6ycNmP0JSJEEys1FbIzHtjl9BP0tOZ/KN6iMeAKrdvGmUsa1qfRdlQRUDKJ4P84hJ3xHw1yTqJt4fvPNHhyE+g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 @@ -926,24 +926,24 @@ packages: resolution: {integrity: sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/snapshot-utils@30.0.1': - resolution: {integrity: sha512-6Dpv7vdtoRiISEFwYF8/c7LIvqXD7xDXtLPNzC2xqAfBznKip0MQM+rkseKwUPUpv2PJ7KW/YsnwWXrIL2xF+A==} + '@jest/snapshot-utils@30.0.4': + resolution: {integrity: sha512-BEpX8M/Y5lG7MI3fmiO+xCnacOrVsnbqVrcDZIT8aSGkKV1w2WwvRQxSWw5SIS8ozg7+h8tSj5EO1Riqqxcdag==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/source-map@30.0.1': resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-result@30.0.2': - resolution: {integrity: sha512-KKMuBKkkZYP/GfHMhI+cH2/P3+taMZS3qnqqiPC1UXZTJskkCS+YU/ILCtw5anw1+YsTulDHFpDo70mmCedW8w==} + '@jest/test-result@30.0.4': + resolution: {integrity: sha512-Mfpv8kjyKTHqsuu9YugB6z1gcdB3TSSOaKlehtVaiNlClMkEHY+5ZqCY2CrEE3ntpBMlstX/ShDAf84HKWsyIw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/test-sequencer@30.0.2': - resolution: {integrity: sha512-fbyU5HPka0rkalZ3MXVvq0hwZY8dx3Y6SCqR64zRmh+xXlDeFl0IdL4l9e7vp4gxEXTYHbwLFA1D+WW5CucaSw==} + '@jest/test-sequencer@30.0.4': + resolution: {integrity: sha512-bj6ePmqi4uxAE8EHE0Slmk5uBYd9Vd/PcVt06CsBxzH4bbA8nGsI1YbXl/NH+eii4XRtyrRx+Cikub0x8H4vDg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/transform@30.0.2': - resolution: {integrity: sha512-kJIuhLMTxRF7sc0gPzPtCDib/V9KwW3I2U25b+lYCYMVqHHSrcZopS8J8H+znx9yixuFv+Iozl8raLt/4MoxrA==} + '@jest/transform@30.0.4': + resolution: {integrity: sha512-atvy4hRph/UxdCIBp+UB2jhEA/jJiUeGZ7QPgBi9jUUKNgi3WEoMXGNG7zbbELG2+88PMabUNCDchmqgJy3ELg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} '@jest/types@30.0.0': @@ -978,59 +978,59 @@ packages: '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} - '@next/bundle-analyzer@15.3.4': - resolution: {integrity: sha512-AN9H9S+4WaIIahyJBGe6arLj5kopvVZPLffAJsDhkbQPGqirYqaHhwO6vheytXtdq3xNjwJLpbmYNa5ZQnitSw==} + '@next/bundle-analyzer@15.3.5': + resolution: {integrity: sha512-r1tlg7N4IUWpdqdy8/6bf7pvo2yeN9Oc6OHEiMsMfIooJ5k37Odi9HC1qBS4soULNE7FiQ18JP/TdmQKiaIkoA==} - '@next/env@15.3.4': - resolution: {integrity: sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==} + '@next/env@15.3.5': + resolution: {integrity: sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g==} - '@next/eslint-plugin-next@15.3.4': - resolution: {integrity: sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==} + '@next/eslint-plugin-next@15.3.5': + resolution: {integrity: sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w==} - '@next/swc-darwin-arm64@15.3.4': - resolution: {integrity: sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==} + '@next/swc-darwin-arm64@15.3.5': + resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.3.4': - resolution: {integrity: sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==} + '@next/swc-darwin-x64@15.3.5': + resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.3.4': - resolution: {integrity: sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==} + '@next/swc-linux-arm64-gnu@15.3.5': + resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.3.4': - resolution: {integrity: sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==} + '@next/swc-linux-arm64-musl@15.3.5': + resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.3.4': - resolution: {integrity: sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==} + '@next/swc-linux-x64-gnu@15.3.5': + resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.3.4': - resolution: {integrity: sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==} + '@next/swc-linux-x64-musl@15.3.5': + resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.3.4': - resolution: {integrity: sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==} + '@next/swc-win32-arm64-msvc@15.3.5': + resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.3.4': - resolution: {integrity: sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==} + '@next/swc-win32-x64-msvc@15.3.5': + resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2214,8 +2214,8 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - babel-jest@30.0.2: - resolution: {integrity: sha512-A5kqR1/EUTidM2YC2YMEUDP2+19ppgOwK0IAd9Swc3q2KqFb5f9PtRUXVeZcngu0z5mDMyZ9zH2huJZSOMLiTQ==} + babel-jest@30.0.4: + resolution: {integrity: sha512-UjG2j7sAOqsp2Xua1mS/e+ekddkSu3wpf4nZUSvXNHuVWdaOUXQ77+uyjJLDE9i0atm5x4kds8K9yb5lRsRtcA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@babel/core': ^7.11.0 @@ -2596,8 +2596,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@15.3.4: - resolution: {integrity: sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==} + eslint-config-next@15.3.5: + resolution: {integrity: sha512-oQdvnIgP68wh2RlR3MdQpvaJ94R6qEFl+lnu8ZKxPj5fsAHrSF/HlAOZcsimLw3DT6bnEQIUdbZC2Ab6sWyptg==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -2737,8 +2737,8 @@ packages: resolution: {integrity: sha512-xCdPp6gwiR9q9lsPCHANarIkFTN/IMZso6Kkq03sOm9IIGtzK/UJqml0dkhHibGh8HKOj8BIDIpZ0BZuU7QK6w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - expect@30.0.3: - resolution: {integrity: sha512-HXg6NvK35/cSYZCUKAtmlgCFyqKM4frEPbzrav5hRqb0GMz0E0lS5hfzYjSaiaE5ysnp/qI2aeZkeyeIAOeXzQ==} + expect@30.0.4: + resolution: {integrity: sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} fast-deep-equal@3.1.3: @@ -2807,8 +2807,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - framer-motion@12.22.0: - resolution: {integrity: sha512-qG07rR8/mboCNU34nORbrIbBXbJzP4aDqBdr67TAIVlMryDEOwh7LXjylWovlnPCMg78ExoY0Gn2F1fV+3DNIw==} + framer-motion@12.23.0: + resolution: {integrity: sha512-xf6NxTGAyf7zR4r2KlnhFmsRfKIbjqeBupEDBAaEtVIBJX96sAon00kMlsKButSIRwPSHjbRrAPnYdJJ9kyhbA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3169,12 +3169,12 @@ packages: resolution: {integrity: sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-circus@30.0.3: - resolution: {integrity: sha512-rD9qq2V28OASJHJWDRVdhoBdRs6k3u3EmBzDYcyuMby8XCO3Ll1uq9kyqM41ZcC4fMiPulMVh3qMw0cBvDbnyg==} + jest-circus@30.0.4: + resolution: {integrity: sha512-o6UNVfbXbmzjYgmVPtSQrr5xFZCtkDZGdTlptYvGFSN80RuOOlTe73djvMrs+QAuSERZWcHBNIOMH+OEqvjWuw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-cli@30.0.3: - resolution: {integrity: sha512-UWDSj0ayhumEAxpYRlqQLrssEi29kdQ+kddP94AuHhZknrE+mT0cR0J+zMHKFe9XPfX3dKQOc2TfWki3WhFTsA==} + jest-cli@30.0.4: + resolution: {integrity: sha512-3dOrP3zqCWBkjoVG1zjYJpD9143N9GUCbwaF2pFF5brnIgRLHmKcCIw+83BvF1LxggfMWBA0gxkn6RuQVuRhIQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -3183,8 +3183,8 @@ packages: node-notifier: optional: true - jest-config@30.0.3: - resolution: {integrity: sha512-j0L4oRCtJwNyZktXIqwzEiDVQXBbQ4dqXuLD/TZdn++hXIcIfZmjHgrViEy5s/+j4HvITmAXbexVZpQ/jnr0bg==} + jest-config@30.0.4: + resolution: {integrity: sha512-3dzbO6sh34thAGEjJIW0fgT0GA0EVlkski6ZzMcbW6dzhenylXAE/Mj2MI4HonroWbkKc6wU6bLVQ8dvBSZ9lA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: '@types/node': '*' @@ -3202,8 +3202,8 @@ packages: resolution: {integrity: sha512-TgT1+KipV8JTLXXeFX0qSvIJR/UXiNNojjxb/awh3vYlBZyChU/NEmyKmq+wijKjWEztyrGJFL790nqMqNjTHA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-diff@30.0.3: - resolution: {integrity: sha512-Q1TAV0cUcBTic57SVnk/mug0/ASyAqtSIOkr7RAlxx97llRYsM74+E8N5WdGJUlwCKwgxPAkVjKh653h1+HA9A==} + jest-diff@30.0.4: + resolution: {integrity: sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-docblock@30.0.1: @@ -3214,8 +3214,8 @@ packages: resolution: {integrity: sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-environment-jsdom@30.0.2: - resolution: {integrity: sha512-lwMpe7hZ81e2PpHj+4nowAzSkC0p8ftRfzC+qEjav9p5ElCs6LAce3y46iLwMS27oL9+/KQe55gUvUDwrlDeJQ==} + jest-environment-jsdom@30.0.4: + resolution: {integrity: sha512-9WmS3oyCLFgs6DUJSoMpVb+AbH62Y2Xecw3XClbRgj6/Z+VjNeSLjrhBgVvTZ40njZTWeDHv8unp+6M/z8ADDg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -3223,8 +3223,8 @@ packages: canvas: optional: true - jest-environment-node@30.0.2: - resolution: {integrity: sha512-XsGtZ0H+a70RsxAQkKuIh0D3ZlASXdZdhpOSBq9WRPq6lhe0IoQHGW0w9ZUaPiZQ/CpkIdprvlfV1QcXcvIQLQ==} + jest-environment-node@30.0.4: + resolution: {integrity: sha512-p+rLEzC2eThXqiNh9GHHTC0OW5Ca4ZfcURp7scPjYBcmgpR9HG6750716GuUipYf2AcThU3k20B31USuiaaIEg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-haste-map@30.0.2: @@ -3239,8 +3239,8 @@ packages: resolution: {integrity: sha512-m5mrunqopkrqwG1mMdJxe1J4uGmS9AHHKYUmoxeQOxBcLjEvirIrIDwuKmUYrecPHVB/PUBpXs2gPoeA2FSSLQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@30.0.3: - resolution: {integrity: sha512-hMpVFGFOhYmIIRGJ0HgM9htC5qUiJ00famcc9sRFchJJiLZbbVKrAztcgE6VnXLRxA3XZ0bvNA7hQWh3oHXo/A==} + jest-matcher-utils@30.0.4: + resolution: {integrity: sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-message-util@30.0.0: @@ -3276,24 +3276,24 @@ packages: resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-resolve-dependencies@30.0.3: - resolution: {integrity: sha512-FlL6u7LiHbF0Oe27k7DHYMq2T2aNpPhxnNo75F7lEtu4A6sSw+TKkNNUGNcVckdFoL0RCWREJsC1HsKDwKRZzQ==} + jest-resolve-dependencies@30.0.4: + resolution: {integrity: sha512-EQBYow19B/hKr4gUTn+l8Z+YLlP2X0IoPyp0UydOtrcPbIOYzJ8LKdFd+yrbwztPQvmlBFUwGPPEzHH1bAvFAw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-resolve@30.0.2: resolution: {integrity: sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runner@30.0.3: - resolution: {integrity: sha512-CxYBzu9WStOBBXAKkLXGoUtNOWsiS1RRmUQb6SsdUdTcqVncOau7m8AJ4cW3Mz+YL1O9pOGPSYLyvl8HBdFmkQ==} + jest-runner@30.0.4: + resolution: {integrity: sha512-mxY0vTAEsowJwvFJo5pVivbCpuu6dgdXRmt3v3MXjBxFly7/lTk3Td0PaMyGOeNQUFmSuGEsGYqhbn7PA9OekQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-runtime@30.0.3: - resolution: {integrity: sha512-Xjosq0C48G9XEQOtmgrjXJwPaUPaq3sPJwHDRaiC+5wi4ZWxO6Lx6jNkizK/0JmTulVNuxP8iYwt77LGnfg3/w==} + jest-runtime@30.0.4: + resolution: {integrity: sha512-tUQrZ8+IzoZYIHoPDQEB4jZoPyzBjLjq7sk0KVyd5UPRjRDOsN7o6UlvaGF8ddpGsjznl9PW+KRgWqCNO+Hn7w==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-snapshot@30.0.3: - resolution: {integrity: sha512-F05JCohd3OA1N9+5aEPXA6I0qOfZDGIx0zTq5Z4yMBg2i1p5ELfBusjYAWwTkC12c7dHcbyth4QAfQbS7cRjow==} + jest-snapshot@30.0.4: + resolution: {integrity: sha512-S/8hmSkeUib8WRUq9pWEb5zMfsOjiYWDWzFzKnjX7eDyKKgimsu9hcmsUEg8a7dPAw8s/FacxsXquq71pDgPjQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-util@30.0.0: @@ -3308,16 +3308,16 @@ packages: resolution: {integrity: sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-watcher@30.0.2: - resolution: {integrity: sha512-vYO5+E7jJuF+XmONr6CrbXdlYrgvZqtkn6pdkgjt/dU64UAdc0v1cAVaAeWtAfUUMScxNmnUjKPUMdCpNVASwg==} + jest-watcher@30.0.4: + resolution: {integrity: sha512-YESbdHDs7aQOCSSKffG8jXqOKFqw4q4YqR+wHYpR5GWEQioGvL0BfbcjvKIvPEM0XGfsfJrka7jJz3Cc3gI4VQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} jest-worker@30.0.2: resolution: {integrity: sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest@30.0.3: - resolution: {integrity: sha512-Uy8xfeE/WpT2ZLGDXQmaYNzw2v8NUKuYeKGtkS6sDxwsdQihdgYCXaKIYnph1h95DN5H35ubFDm0dfmsQnjn4Q==} + jest@30.0.4: + resolution: {integrity: sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: @@ -3629,8 +3629,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.3.4: - resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==} + next@15.3.5: + resolution: {integrity: sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -4119,8 +4119,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - sonner@2.0.5: - resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==} + sonner@2.0.6: + resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==} peerDependencies: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc @@ -4245,8 +4245,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.3.3: - resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} + swr@2.3.4: + resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4616,7 +4616,7 @@ snapshots: '@ai-sdk/provider-utils': 2.2.8(zod@3.24.2) '@ai-sdk/ui-utils': 1.2.11(zod@3.24.2) react: 19.1.0 - swr: 2.3.3(react@19.1.0) + swr: 2.3.4(react@19.1.0) throttleit: 2.1.0 optionalDependencies: zod: 3.24.2 @@ -4698,7 +4698,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.840.0': + '@aws-sdk/client-s3@3.842.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -5055,7 +5055,7 @@ snapshots: '@smithy/util-middleware': 4.0.4 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.840.0': + '@aws-sdk/s3-request-presigner@3.842.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.840.0 '@aws-sdk/types': 3.840.0 @@ -5555,7 +5555,7 @@ snapshots: '@istanbuljs/schema@0.1.3': {} - '@jest/console@30.0.2': + '@jest/console@30.0.4': dependencies: '@jest/types': 30.0.1 '@types/node': 24.0.10 @@ -5564,13 +5564,13 @@ snapshots: jest-util: 30.0.2 slash: 3.0.0 - '@jest/core@30.0.3(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3))': + '@jest/core@30.0.4(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3))': dependencies: - '@jest/console': 30.0.2 + '@jest/console': 30.0.4 '@jest/pattern': 30.0.1 - '@jest/reporters': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 + '@jest/reporters': 30.0.4 + '@jest/test-result': 30.0.4 + '@jest/transform': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 ansi-escapes: 4.3.2 @@ -5579,18 +5579,18 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.0.2 - jest-config: 30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + jest-config: 30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) jest-haste-map: 30.0.2 jest-message-util: 30.0.2 jest-regex-util: 30.0.1 jest-resolve: 30.0.2 - jest-resolve-dependencies: 30.0.3 - jest-runner: 30.0.3 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 + jest-resolve-dependencies: 30.0.4 + jest-runner: 30.0.4 + jest-runtime: 30.0.4 + jest-snapshot: 30.0.4 jest-util: 30.0.2 jest-validate: 30.0.2 - jest-watcher: 30.0.2 + jest-watcher: 30.0.4 micromatch: 4.0.8 pretty-format: 30.0.2 slash: 3.0.0 @@ -5604,10 +5604,10 @@ snapshots: '@jest/diff-sequences@30.0.1': {} - '@jest/environment-jsdom-abstract@30.0.2(jsdom@26.1.0)': + '@jest/environment-jsdom-abstract@30.0.4(jsdom@26.1.0)': dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 + '@jest/environment': 30.0.4 + '@jest/fake-timers': 30.0.4 '@jest/types': 30.0.1 '@types/jsdom': 21.1.7 '@types/node': 24.0.10 @@ -5615,9 +5615,9 @@ snapshots: jest-util: 30.0.2 jsdom: 26.1.0 - '@jest/environment@30.0.2': + '@jest/environment@30.0.4': dependencies: - '@jest/fake-timers': 30.0.2 + '@jest/fake-timers': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 jest-mock: 30.0.2 @@ -5626,18 +5626,18 @@ snapshots: dependencies: '@jest/get-type': 30.0.0 - '@jest/expect-utils@30.0.3': + '@jest/expect-utils@30.0.4': dependencies: '@jest/get-type': 30.0.1 - '@jest/expect@30.0.3': + '@jest/expect@30.0.4': dependencies: - expect: 30.0.3 - jest-snapshot: 30.0.3 + expect: 30.0.4 + jest-snapshot: 30.0.4 transitivePeerDependencies: - supports-color - '@jest/fake-timers@30.0.2': + '@jest/fake-timers@30.0.4': dependencies: '@jest/types': 30.0.1 '@sinonjs/fake-timers': 13.0.5 @@ -5650,10 +5650,10 @@ snapshots: '@jest/get-type@30.0.1': {} - '@jest/globals@30.0.3': + '@jest/globals@30.0.4': dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 + '@jest/environment': 30.0.4 + '@jest/expect': 30.0.4 '@jest/types': 30.0.1 jest-mock: 30.0.2 transitivePeerDependencies: @@ -5669,12 +5669,12 @@ snapshots: '@types/node': 24.0.10 jest-regex-util: 30.0.1 - '@jest/reporters@30.0.2': + '@jest/reporters@30.0.4': dependencies: '@bcoe/v8-coverage': 0.2.3 - '@jest/console': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 + '@jest/console': 30.0.4 + '@jest/test-result': 30.0.4 + '@jest/transform': 30.0.4 '@jest/types': 30.0.1 '@jridgewell/trace-mapping': 0.3.25 '@types/node': 24.0.10 @@ -5705,7 +5705,7 @@ snapshots: dependencies: '@sinclair/typebox': 0.34.33 - '@jest/snapshot-utils@30.0.1': + '@jest/snapshot-utils@30.0.4': dependencies: '@jest/types': 30.0.1 chalk: 4.1.2 @@ -5718,21 +5718,21 @@ snapshots: callsites: 3.1.0 graceful-fs: 4.2.11 - '@jest/test-result@30.0.2': + '@jest/test-result@30.0.4': dependencies: - '@jest/console': 30.0.2 + '@jest/console': 30.0.4 '@jest/types': 30.0.1 '@types/istanbul-lib-coverage': 2.0.6 collect-v8-coverage: 1.0.2 - '@jest/test-sequencer@30.0.2': + '@jest/test-sequencer@30.0.4': dependencies: - '@jest/test-result': 30.0.2 + '@jest/test-result': 30.0.4 graceful-fs: 4.2.11 jest-haste-map: 30.0.2 slash: 3.0.0 - '@jest/transform@30.0.2': + '@jest/transform@30.0.4': dependencies: '@babel/core': 7.27.4 '@jest/types': 30.0.1 @@ -5801,41 +5801,41 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true - '@next/bundle-analyzer@15.3.4': + '@next/bundle-analyzer@15.3.5': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@next/env@15.3.4': {} + '@next/env@15.3.5': {} - '@next/eslint-plugin-next@15.3.4': + '@next/eslint-plugin-next@15.3.5': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.3.4': + '@next/swc-darwin-arm64@15.3.5': optional: true - '@next/swc-darwin-x64@15.3.4': + '@next/swc-darwin-x64@15.3.5': optional: true - '@next/swc-linux-arm64-gnu@15.3.4': + '@next/swc-linux-arm64-gnu@15.3.5': optional: true - '@next/swc-linux-arm64-musl@15.3.4': + '@next/swc-linux-arm64-musl@15.3.5': optional: true - '@next/swc-linux-x64-gnu@15.3.4': + '@next/swc-linux-x64-gnu@15.3.5': optional: true - '@next/swc-linux-x64-musl@15.3.4': + '@next/swc-linux-x64-musl@15.3.5': optional: true - '@next/swc-win32-arm64-msvc@15.3.4': + '@next/swc-win32-arm64-msvc@15.3.5': optional: true - '@next/swc-win32-x64-msvc@15.3.4': + '@next/swc-win32-x64-msvc@15.3.5': optional: true '@nodelib/fs.scandir@2.1.5': @@ -6904,9 +6904,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@1.5.0(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@vercel/analytics@1.5.0(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': optionalDependencies: - next: 15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 '@vercel/blob@1.1.1': @@ -6917,9 +6917,9 @@ snapshots: throttleit: 2.1.0 undici: 5.28.5 - '@vercel/speed-insights@1.2.0(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + '@vercel/speed-insights@1.2.0(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': optionalDependencies: - next: 15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 acorn-jsx@5.3.2(acorn@8.14.0): @@ -7078,10 +7078,10 @@ snapshots: axobject-query@4.1.0: {} - babel-jest@30.0.2(@babel/core@7.27.4): + babel-jest@30.0.4(@babel/core@7.27.4): dependencies: '@babel/core': 7.27.4 - '@jest/transform': 30.0.2 + '@jest/transform': 30.0.4 '@types/babel__core': 7.20.5 babel-plugin-istanbul: 7.0.0 babel-preset-jest: 30.0.1(@babel/core@7.27.4) @@ -7523,9 +7523,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@15.3.4(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3): + eslint-config-next@15.3.5(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3): dependencies: - '@next/eslint-plugin-next': 15.3.4 + '@next/eslint-plugin-next': 15.3.5 '@rushstack/eslint-patch': 1.10.5 '@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': 8.24.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) @@ -7753,11 +7753,11 @@ snapshots: jest-mock: 30.0.0 jest-util: 30.0.0 - expect@30.0.3: + expect@30.0.4: dependencies: - '@jest/expect-utils': 30.0.3 + '@jest/expect-utils': 30.0.4 '@jest/get-type': 30.0.1 - jest-matcher-utils: 30.0.3 + jest-matcher-utils: 30.0.4 jest-message-util: 30.0.2 jest-mock: 30.0.2 jest-util: 30.0.2 @@ -7834,7 +7834,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - framer-motion@12.22.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + framer-motion@12.23.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: motion-dom: 12.22.0 motion-utils: 12.19.0 @@ -8211,11 +8211,11 @@ snapshots: jest-util: 30.0.2 p-limit: 3.1.0 - jest-circus@30.0.3: + jest-circus@30.0.4: dependencies: - '@jest/environment': 30.0.2 - '@jest/expect': 30.0.3 - '@jest/test-result': 30.0.2 + '@jest/environment': 30.0.4 + '@jest/expect': 30.0.4 + '@jest/test-result': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 chalk: 4.1.2 @@ -8223,10 +8223,10 @@ snapshots: dedent: 1.6.0 is-generator-fn: 2.1.0 jest-each: 30.0.2 - jest-matcher-utils: 30.0.3 + jest-matcher-utils: 30.0.4 jest-message-util: 30.0.2 - jest-runtime: 30.0.3 - jest-snapshot: 30.0.3 + jest-runtime: 30.0.4 + jest-snapshot: 30.0.4 jest-util: 30.0.2 p-limit: 3.1.0 pretty-format: 30.0.2 @@ -8237,15 +8237,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): + jest-cli@30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): dependencies: - '@jest/core': 30.0.3(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) - '@jest/test-result': 30.0.2 + '@jest/core': 30.0.4(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + '@jest/test-result': 30.0.4 '@jest/types': 30.0.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + jest-config: 30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) jest-util: 30.0.2 jest-validate: 30.0.2 yargs: 17.7.2 @@ -8256,25 +8256,25 @@ snapshots: - supports-color - ts-node - jest-config@30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): + jest-config@30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): dependencies: '@babel/core': 7.27.4 '@jest/get-type': 30.0.1 '@jest/pattern': 30.0.1 - '@jest/test-sequencer': 30.0.2 + '@jest/test-sequencer': 30.0.4 '@jest/types': 30.0.1 - babel-jest: 30.0.2(@babel/core@7.27.4) + babel-jest: 30.0.4(@babel/core@7.27.4) chalk: 4.1.2 ci-info: 4.2.0 deepmerge: 4.3.1 glob: 10.4.5 graceful-fs: 4.2.11 - jest-circus: 30.0.3 + jest-circus: 30.0.4 jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 + jest-environment-node: 30.0.4 jest-regex-util: 30.0.1 jest-resolve: 30.0.2 - jest-runner: 30.0.3 + jest-runner: 30.0.4 jest-util: 30.0.2 jest-validate: 30.0.2 micromatch: 4.0.8 @@ -8296,7 +8296,7 @@ snapshots: chalk: 4.1.2 pretty-format: 30.0.0 - jest-diff@30.0.3: + jest-diff@30.0.4: dependencies: '@jest/diff-sequences': 30.0.1 '@jest/get-type': 30.0.1 @@ -8315,10 +8315,10 @@ snapshots: jest-util: 30.0.2 pretty-format: 30.0.2 - jest-environment-jsdom@30.0.2: + jest-environment-jsdom@30.0.4: dependencies: - '@jest/environment': 30.0.2 - '@jest/environment-jsdom-abstract': 30.0.2(jsdom@26.1.0) + '@jest/environment': 30.0.4 + '@jest/environment-jsdom-abstract': 30.0.4(jsdom@26.1.0) '@types/jsdom': 21.1.7 '@types/node': 24.0.10 jsdom: 26.1.0 @@ -8327,10 +8327,10 @@ snapshots: - supports-color - utf-8-validate - jest-environment-node@30.0.2: + jest-environment-node@30.0.4: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 + '@jest/environment': 30.0.4 + '@jest/fake-timers': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 jest-mock: 30.0.2 @@ -8364,11 +8364,11 @@ snapshots: jest-diff: 30.0.0 pretty-format: 30.0.0 - jest-matcher-utils@30.0.3: + jest-matcher-utils@30.0.4: dependencies: '@jest/get-type': 30.0.1 chalk: 4.1.2 - jest-diff: 30.0.3 + jest-diff: 30.0.4 pretty-format: 30.0.2 jest-message-util@30.0.0: @@ -8415,10 +8415,10 @@ snapshots: jest-regex-util@30.0.1: {} - jest-resolve-dependencies@30.0.3: + jest-resolve-dependencies@30.0.4: dependencies: jest-regex-util: 30.0.1 - jest-snapshot: 30.0.3 + jest-snapshot: 30.0.4 transitivePeerDependencies: - supports-color @@ -8433,12 +8433,12 @@ snapshots: slash: 3.0.0 unrs-resolver: 1.9.0 - jest-runner@30.0.3: + jest-runner@30.0.4: dependencies: - '@jest/console': 30.0.2 - '@jest/environment': 30.0.2 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 + '@jest/console': 30.0.4 + '@jest/environment': 30.0.4 + '@jest/test-result': 30.0.4 + '@jest/transform': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 chalk: 4.1.2 @@ -8446,28 +8446,28 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-docblock: 30.0.1 - jest-environment-node: 30.0.2 + jest-environment-node: 30.0.4 jest-haste-map: 30.0.2 jest-leak-detector: 30.0.2 jest-message-util: 30.0.2 jest-resolve: 30.0.2 - jest-runtime: 30.0.3 + jest-runtime: 30.0.4 jest-util: 30.0.2 - jest-watcher: 30.0.2 + jest-watcher: 30.0.4 jest-worker: 30.0.2 p-limit: 3.1.0 source-map-support: 0.5.13 transitivePeerDependencies: - supports-color - jest-runtime@30.0.3: + jest-runtime@30.0.4: dependencies: - '@jest/environment': 30.0.2 - '@jest/fake-timers': 30.0.2 - '@jest/globals': 30.0.3 + '@jest/environment': 30.0.4 + '@jest/fake-timers': 30.0.4 + '@jest/globals': 30.0.4 '@jest/source-map': 30.0.1 - '@jest/test-result': 30.0.2 - '@jest/transform': 30.0.2 + '@jest/test-result': 30.0.4 + '@jest/transform': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 chalk: 4.1.2 @@ -8480,31 +8480,31 @@ snapshots: jest-mock: 30.0.2 jest-regex-util: 30.0.1 jest-resolve: 30.0.2 - jest-snapshot: 30.0.3 + jest-snapshot: 30.0.4 jest-util: 30.0.2 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.0.3: + jest-snapshot@30.0.4: dependencies: '@babel/core': 7.27.4 '@babel/generator': 7.27.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.27.4) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.27.4) '@babel/types': 7.27.6 - '@jest/expect-utils': 30.0.3 + '@jest/expect-utils': 30.0.4 '@jest/get-type': 30.0.1 - '@jest/snapshot-utils': 30.0.1 - '@jest/transform': 30.0.2 + '@jest/snapshot-utils': 30.0.4 + '@jest/transform': 30.0.4 '@jest/types': 30.0.1 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.27.4) chalk: 4.1.2 - expect: 30.0.3 + expect: 30.0.4 graceful-fs: 4.2.11 - jest-diff: 30.0.3 - jest-matcher-utils: 30.0.3 + jest-diff: 30.0.4 + jest-matcher-utils: 30.0.4 jest-message-util: 30.0.2 jest-util: 30.0.2 pretty-format: 30.0.2 @@ -8540,9 +8540,9 @@ snapshots: leven: 3.1.0 pretty-format: 30.0.2 - jest-watcher@30.0.2: + jest-watcher@30.0.4: dependencies: - '@jest/test-result': 30.0.2 + '@jest/test-result': 30.0.4 '@jest/types': 30.0.1 '@types/node': 24.0.10 ansi-escapes: 4.3.2 @@ -8559,12 +8559,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): + jest@30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)): dependencies: - '@jest/core': 30.0.3(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + '@jest/core': 30.0.4(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) '@jest/types': 30.0.1 import-local: 3.2.0 - jest-cli: 30.0.3(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) + jest-cli: 30.0.4(@types/node@24.0.10)(ts-node@10.9.2(@types/node@24.0.10)(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8804,10 +8804,10 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.28(next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): + next-auth@5.0.0-beta.28(next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): dependencies: '@auth/core': 0.39.1 - next: 15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -8815,9 +8815,9 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.3.4(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@15.3.5(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 15.3.4 + '@next/env': 15.3.5 '@swc/counter': 0.1.3 '@swc/helpers': 0.5.15 busboy: 1.6.0 @@ -8827,14 +8827,14 @@ snapshots: react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.27.4)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.3.4 - '@next/swc-darwin-x64': 15.3.4 - '@next/swc-linux-arm64-gnu': 15.3.4 - '@next/swc-linux-arm64-musl': 15.3.4 - '@next/swc-linux-x64-gnu': 15.3.4 - '@next/swc-linux-x64-musl': 15.3.4 - '@next/swc-win32-arm64-msvc': 15.3.4 - '@next/swc-win32-x64-msvc': 15.3.4 + '@next/swc-darwin-arm64': 15.3.5 + '@next/swc-darwin-x64': 15.3.5 + '@next/swc-linux-arm64-gnu': 15.3.5 + '@next/swc-linux-arm64-musl': 15.3.5 + '@next/swc-linux-x64-gnu': 15.3.5 + '@next/swc-linux-x64-musl': 15.3.5 + '@next/swc-win32-arm64-msvc': 15.3.5 + '@next/swc-win32-x64-msvc': 15.3.5 '@opentelemetry/api': 1.9.0 sharp: 0.34.2 transitivePeerDependencies: @@ -9337,7 +9337,7 @@ snapshots: slash@3.0.0: {} - sonner@2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -9469,7 +9469,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.3.3(react@19.1.0): + swr@2.3.4(react@19.1.0): dependencies: dequal: 2.0.3 react: 19.1.0 diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index 5f060b8a..c3b94208 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -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'])} + +
+
+ Set environment variable to {'"1"'} to prevent images + displaying when hovering over category links: + {renderEnvVars(['NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS'])} +
+
+
- -
-
- Set environment variable to {'"1"'} to show images when hovering - over category links like cameras and lenses: - {renderEnvVars(['NEXT_PUBLIC_CATEGORY_IMAGE_HOVERS'])} -
-
- Static optimization strongly recommended - for responsive hover interactions: - {/* eslint-disable-next-line max-len */} - {renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES'])} -
-
-
void nextPhotoAnimation?: AnimationConfig setNextPhotoAnimation?: (animationConfig?: AnimationConfig) => void diff --git a/src/state/AppStateProvider.tsx b/src/app/AppStateProvider.tsx similarity index 92% rename from src/state/AppStateProvider.tsx rename to src/app/AppStateProvider.tsx index 50d81d42..3f2ccd54 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/app/AppStateProvider.tsx @@ -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(); 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, diff --git a/src/app/AppViewSwitcher.tsx b/src/app/AppViewSwitcher.tsx index bded5d84..bd81c132 100644 --- a/src/app/AppViewSwitcher.tsx +++ b/src/app/AppViewSwitcher.tsx @@ -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, diff --git a/src/app/Footer.tsx b/src/app/Footer.tsx index 62b4894f..41b4161f 100644 --- a/src/app/Footer.tsx +++ b/src/app/Footer.tsx @@ -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'; diff --git a/src/app/Nav.tsx b/src/app/Nav.tsx index e5ea17fb..d230f66b 100644 --- a/src/app/Nav.tsx +++ b/src/app/Nav.tsx @@ -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]' diff --git a/src/app/config.ts b/src/app/config.ts index d135ae1e..c3d7a6ba 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -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, diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 4ebf8fc2..5526a9f3 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -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'; diff --git a/src/camera/CameraHeader.tsx b/src/camera/CameraHeader.tsx index ec85149c..7b4d6c8c 100644 --- a/src/camera/CameraHeader.tsx +++ b/src/camera/CameraHeader.tsx @@ -30,7 +30,7 @@ export default async function CameraHeader({ entity={} entityDescription={ descriptionForCameraPhotos( diff --git a/src/camera/PhotoCamera.tsx b/src/camera/PhotoCamera.tsx index 75eafb70..60bc82d0 100644 --- a/src/camera/PhotoCamera.tsx +++ b/src/camera/PhotoCamera.tsx @@ -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 ? } - hoverEntity={countOnHover} /> ); } diff --git a/src/category/useCategoryCounts.ts b/src/category/useCategoryCounts.ts index fd8d0349..8089dca7 100644 --- a/src/category/useCategoryCounts.ts +++ b/src/category/useCategoryCounts.ts @@ -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(); diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 6adc5809..7bec66fb 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -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'; diff --git a/src/components/AnimateItems.tsx b/src/components/AnimateItems.tsx index 248a86f4..abd0e139 100644 --- a/src/components/AnimateItems.tsx +++ b/src/components/AnimateItems.tsx @@ -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; diff --git a/src/components/DivDebugBaselineGrid.tsx b/src/components/DivDebugBaselineGrid.tsx index 0e07d159..ee983245 100644 --- a/src/components/DivDebugBaselineGrid.tsx +++ b/src/components/DivDebugBaselineGrid.tsx @@ -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'; diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 158a1438..b1a820f2 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -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({ diff --git a/src/components/SelectTileOverlay.tsx b/src/components/SelectTileOverlay.tsx index a3362853..11a2b199 100644 --- a/src/components/SelectTileOverlay.tsx +++ b/src/components/SelectTileOverlay.tsx @@ -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({ diff --git a/src/components/entity/EntityHover.tsx b/src/components/entity/EntityHover.tsx new file mode 100644 index 00000000..cbd36705 --- /dev/null +++ b/src/components/entity/EntityHover.tsx @@ -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 + photosCount: number + color?: ComponentProps['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(() => +
+ {/* Photo grid */} +
+ {Array.from({ length: photosToShow }).map((_, index) => + photos?.[index] && + )} +
+ {/* Placeholder grid */} +
+ {Array.from({ length: photosToShow }).map((_, index) => +
)} +
+ {/* Text guard */} +
+ {/* Text */} +
+
+ {/* Header */} +
+ + {header} + +
+ {/* Caption */} +
+ {photoQuantityText(photosCount, appText, false)} + {isLoading && + } +
+
+
+
+ , [ + gridClass, + photosToShow, + photos, + header, + photosCount, + appText, + isLoading, + ]); + + return + {children} + ; +} diff --git a/src/components/primitives/EntityLink.tsx b/src/components/entity/EntityLink.tsx similarity index 67% rename from src/components/primitives/EntityLink.tsx rename to src/components/entity/EntityLink.tsx index ac9e013e..8365bb81 100644 --- a/src/components/primitives/EntityLink.tsx +++ b/src/components/entity/EntityLink.tsx @@ -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 type?: LabeledIconType badged?: boolean contrast?: ComponentProps['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 = - {labelComplex || label} + {label} ; - const renderLink = + const renderLink = (useForHover?: boolean) => - {badged + {badged && !useForHover ? - {iconBadge} + {iconBadgeStart} {renderLabel} + {iconBadgeEnd} : - {showTooltip && tooltipImagePath - ? + getPhotosCachedAction({ + ...hoverPhotoQueryOptions, + limit: MAX_PHOTOS_TO_SHOW_PER_CATEGORY, + })} color={contrast === 'frosted' ? 'frosted' : undefined} > - {renderLink} - - : renderLink} + {renderLink()} + + : renderLink()} {action && {action} } {showHoverEntity && - {hoverEntity} + {countOnHover} } {isLoading && !suppressSpinner && setIsLoading(false), []); const onError = useCallback(() => setDidError(true), []); diff --git a/src/components/image/useImageZoomControls.ts b/src/components/image/useImageZoomControls.ts index 17645798..584b3db4 100644 --- a/src/components/image/useImageZoomControls.ts +++ b/src/components/image/useImageZoomControls.ts @@ -1,5 +1,5 @@ import useMetaThemeColor from '@/utility/useMetaThemeColor'; -import { useAppState } from '@/state/AppState'; +import { useAppState } from '@/app/AppState'; import { ComponentProps, RefObject, diff --git a/src/components/og/OGTooltip.tsx b/src/components/og/OGTooltip.tsx deleted file mode 100644 index 481e8118..00000000 --- a/src/components/og/OGTooltip.tsx +++ /dev/null @@ -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) { - const ref = useRef(null); - - const { showTooltip, dismissTooltip } = useOGTooltipState(); - - const supportsHover = useSupportsHover(); - - useEffect(() => { - const trigger = ref.current; - return () => dismissTooltip?.(trigger); - }, [dismissTooltip]); - - const content = -
- - {caption &&
- {caption} -
} -
; - - return ( -
supportsHover && - showTooltip?.( - ref.current, - { content, width, height, offsetAbove, offsetBelow, color }, - )} - onMouseLeave={() => supportsHover && - dismissTooltip?.(ref.current)} - > - {children} -
- ); -} diff --git a/src/components/og/OGTooltipProvider.tsx b/src/components/og/OGTooltipProvider.tsx deleted file mode 100644 index ad295a33..00000000 --- a/src/components/og/OGTooltipProvider.tsx +++ /dev/null @@ -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(); - const [tooltipStyle, setTooltipStyle] = useState(); - - const currentTriggerRef = useRef(null); - - const timeoutInitialHoverRef = useRef(undefined); - const timeoutDismissRef = useRef(undefined); - - const clearTimeouts = useCallback(() => { - clearTimeout(timeoutInitialHoverRef.current); - timeoutInitialHoverRef.current = undefined; - clearTimeout(timeoutDismissRef.current); - timeoutDismissRef.current = undefined; - }, []); - - const clearState = useCallback((delay = 0) => { - clearTimeouts(); - if (delay) { - timeoutDismissRef.current = setTimeout(() => { - setCurrentTooltip(undefined); - currentTriggerRef.current = null; - }, delay); - } else { - setCurrentTooltip(undefined); - currentTriggerRef.current = null; - } - }, [clearTimeouts]); - - const showTooltip = useCallback(( - _trigger: HTMLElement | null, - tooltip: Tooltip, - ) => { - if (_trigger) { - currentTriggerRef.current = _trigger; - const displayTooltip = () => { - // Update current trigger ref on display - currentTriggerRef.current = _trigger; - setCurrentTooltip(tooltip); - const trigger = _trigger.getBoundingClientRect(); - const top = - trigger.top - (tooltip.height + TOOLTIP_MARGIN) < VIEWPORT_SAFE_AREA - // Position below trigger - ? trigger.bottom + TOOLTIP_MARGIN + tooltip.offsetBelow - // Position above trigger - : trigger.top - (tooltip.height + TOOLTIP_MARGIN) - + tooltip.offsetAbove; - const horizontalOffset = - // eslint-disable-next-line max-len - window.innerWidth - (trigger.left + tooltip.width) < VIEWPORT_SAFE_AREA - ? { right: VIEWPORT_SAFE_AREA } - : { left: trigger.left }; - setTooltipStyle({ top, ...horizontalOffset }); - clearTimeouts(); - }; - if (currentTooltip) { - // Don't apply delay if tooltip's already visible - displayTooltip(); - } else { - timeoutInitialHoverRef.current = - setTimeout(displayTooltip, DELAY_INITIAL_HOVER); - } - } - }, [currentTooltip, clearTimeouts]); - - const dismissTooltip = useCallback((trigger: HTMLElement | null) => { - if (trigger === currentTriggerRef.current) { - clearState(DELAY_DISMISS); - } - }, [clearState]); - - useEffect(() => { - const onWindowChange = () => clearState(0); - window.addEventListener('mouseup', onWindowChange); - window.addEventListener('mousewheel', onWindowChange); - window.addEventListener('resize', onWindowChange); - return () => { - window.removeEventListener('mouseup', onWindowChange); - window.removeEventListener('mousewheel', onWindowChange); - window.removeEventListener('resize', onWindowChange); - }; - }, [clearState]); - - return ( - -
- - {currentTooltip && - - - {currentTooltip.content} - - } - -
- {children} -
- ); -} diff --git a/src/components/og/state.ts b/src/components/og/state.ts deleted file mode 100644 index c45b7a08..00000000 --- a/src/components/og/state.ts +++ /dev/null @@ -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['color'] -} - -export type OGTooltipState = { - showTooltip?: (trigger: HTMLElement | null, tooltip: Tooltip) => void - dismissTooltip?: (trigger: HTMLElement | null) => void -} - -export const OGTooltipContext = createContext({}); - -export const useOGTooltipState = () => use(OGTooltipContext); diff --git a/src/components/shared-hover/SharedHover.tsx b/src/components/shared-hover/SharedHover.tsx new file mode 100644 index 00000000..7e13fda7 --- /dev/null +++ b/src/components/shared-hover/SharedHover.tsx @@ -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(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 ( +
supportsHover && + showHover?.(ref.current, { + key, + width, + height, + offsetAbove, + offsetBelow, + color, + })} + onMouseLeave={() => supportsHover && + dismissHover?.(ref.current)} + > + {children} +
+ ); +} diff --git a/src/components/shared-hover/SharedHoverProvider.tsx b/src/components/shared-hover/SharedHoverProvider.tsx new file mode 100644 index 00000000..b57f0e5d --- /dev/null +++ b/src/components/shared-hover/SharedHoverProvider.tsx @@ -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(); + const [hoverContent, setHoverContent] = useState(); + const [hoverStyle, setHoverStyle] = useState(); + + const currentTriggerRef = useRef(null); + + const timeoutInitialHoverRef = useRef(undefined); + const timeoutDismissRef = useRef(undefined); + + const clearTimeouts = useCallback(() => { + clearTimeout(timeoutInitialHoverRef.current); + timeoutInitialHoverRef.current = undefined; + clearTimeout(timeoutDismissRef.current); + timeoutDismissRef.current = undefined; + }, []); + + const clearState = useCallback((delay = 0) => { + clearTimeouts(); + if (delay) { + timeoutDismissRef.current = setTimeout(() => { + 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 ( + +
+ + {hoverProps && + + +
+ {/* Content */} + {hoverContent} + {/* Border */} +
+
+ + } + +
+ {children} + + ); +} diff --git a/src/components/shared-hover/state.ts b/src/components/shared-hover/state.ts new file mode 100644 index 00000000..50fa08cb --- /dev/null +++ b/src/components/shared-hover/state.ts @@ -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['color'] +} + +export type SharedHoverState = { + showHover?: (trigger: HTMLElement | null, hover: SharedHoverProps) => void + renderHover?: Dispatch> + dismissHover?: (trigger: HTMLElement | null) => void + isHoverBeingShown?: (key: string) => boolean +} + +export const SharedHoverContext = createContext({}); + +export const useSharedHoverState = () => use(SharedHoverContext); diff --git a/src/film/FilmHeader.tsx b/src/film/FilmHeader.tsx index fb31d0a4..a0c022ec 100644 --- a/src/film/FilmHeader.tsx +++ b/src/film/FilmHeader.tsx @@ -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, diff --git a/src/film/PhotoFilm.tsx b/src/film/PhotoFilm.tsx index 0441da79..0c58fc32 100644 --- a/src/film/PhotoFilm.tsx +++ b/src/film/PhotoFilm.tsx @@ -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> & 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={} - hoverEntity={countOnHover} iconWide={isStringFujifilmSimulation(film)} /> ); diff --git a/src/focal/FocalLengthHeader.tsx b/src/focal/FocalLengthHeader.tsx index edb2d08c..83736b33 100644 --- a/src/focal/FocalLengthHeader.tsx +++ b/src/focal/FocalLengthHeader.tsx @@ -27,7 +27,7 @@ export default async function FocalLengthHeader({ entity={} entityDescription={descriptionForFocalLengthPhotos( photos, diff --git a/src/focal/PhotoFocalLength.tsx b/src/focal/PhotoFocalLength.tsx index e40ae19b..f4776800 100644 --- a/src/focal/PhotoFocalLength.tsx +++ b/src/focal/PhotoFocalLength.tsx @@ -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 ( } - hoverEntity={countOnHover} /> ); } diff --git a/src/lens/LensHeader.tsx b/src/lens/LensHeader.tsx index 8ac8600e..6bf99b36 100644 --- a/src/lens/LensHeader.tsx +++ b/src/lens/LensHeader.tsx @@ -30,7 +30,7 @@ export default async function LensHeader({ entity={} entityDescription={ descriptionForLensPhotos( diff --git a/src/lens/PhotoLens.tsx b/src/lens/PhotoLens.tsx index 4f85b1cb..579032f7 100644 --- a/src/lens/PhotoLens.tsx +++ b/src/lens/PhotoLens.tsx @@ -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 ( } - hoverEntity={countOnHover} /> ); } diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 5d80a1e3..c5bcb762 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -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]); diff --git a/src/photo/PhotoGrid.tsx b/src/photo/PhotoGrid.tsx index b2190f9a..03b7acda 100644 --- a/src/photo/PhotoGrid.tsx +++ b/src/photo/PhotoGrid.tsx @@ -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, diff --git a/src/photo/PhotoGridPageClient.tsx b/src/photo/PhotoGridPageClient.tsx index 40963659..c4ac4db4 100644 --- a/src/photo/PhotoGridPageClient.tsx +++ b/src/photo/PhotoGridPageClient.tsx @@ -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={ void showAdminKeyCommands?: boolean }) { @@ -232,6 +234,7 @@ export default function PhotoLarge({ blurDataURL={photo.blurData} blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} priority={priority} + forceFallbackFade={forceFallbackFade} />
void } & PhotoSetCategory) { const ref = useRef(null); @@ -66,6 +68,7 @@ export default function PhotoMedium({ classNameImage="object-cover w-full h-full" alt={altTextForPhoto(photo)} priority={priority} + forceFallbackFade={forceFallbackFade} />
} diff --git a/src/photo/PhotoPrevNextActions.tsx b/src/photo/PhotoPrevNextActions.tsx index da1c942d..c288aa77 100644 --- a/src/photo/PhotoPrevNextActions.tsx +++ b/src/photo/PhotoPrevNextActions.tsx @@ -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'; diff --git a/src/photo/PhotoSmall.tsx b/src/photo/PhotoSmall.tsx index 19a18eed..e6884b83 100644 --- a/src/photo/PhotoSmall.tsx +++ b/src/photo/PhotoSmall.tsx @@ -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(null); @@ -50,6 +52,7 @@ export default function PhotoSmall({ blurDataURL={photo.blurData} blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} alt={altTextForPhoto(photo)} + forceFallbackFade={forceFallbackFade} /> ); diff --git a/src/photo/PhotoUploadWithStatus.tsx b/src/photo/PhotoUploadWithStatus.tsx index e031c75b..bac358fd 100644 --- a/src/photo/PhotoUploadWithStatus.tsx +++ b/src/photo/PhotoUploadWithStatus.tsx @@ -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'; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index a3934d56..6bc0232d 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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) { diff --git a/src/photo/cache.ts b/src/photo/cache.ts index 5e8a50d1..981cbdeb 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -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); } }); diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index 2f3203dd..2a555393 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -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 }; -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 { diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index d52b9044..3deab5de 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -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 ( callback: () => Promise, queryLabel: string, - queryOptions?: GetPhotosOptions, + queryOptions?: PhotoQueryOptions, ): Promise => { 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'; diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index f283316b..46fafe7f 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -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' && } disabled={!canFormBeSubmitted} onFormStatusChange={onFormStatusChange} - onFormSubmit={invalidateSwr} primary > {type === 'create' ? 'Add' : 'Update'} diff --git a/src/recents/PhotoRecents.tsx b/src/recents/PhotoRecents.tsx index 47599e64..9adc05a2 100644 --- a/src/recents/PhotoRecents.tsx +++ b/src/recents/PhotoRecents.tsx @@ -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={} - iconBadge={} - hoverEntity={countOnHover} + iconBadgeStart={} /> ); -} \ No newline at end of file +} diff --git a/src/recents/RecentsHeader.tsx b/src/recents/RecentsHeader.tsx index 4d507f60..dcaf78de 100644 --- a/src/recents/RecentsHeader.tsx +++ b/src/recents/RecentsHeader.tsx @@ -24,7 +24,7 @@ export default function RecentsHeader({ return ( } + entity={} entityDescription={descriptionForPhotoSet( photos, appText, diff --git a/src/recipe/PhotoRecipe.tsx b/src/recipe/PhotoRecipe.tsx index adf1c886..fdd7861c 100644 --- a/src/recipe/PhotoRecipe.tsx +++ b/src/recipe/PhotoRecipe.tsx @@ -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> & EntityLinkExternalProps) { - const appText = useAppText(); - return ( } action={toggleRecipeOverlay && @@ -47,7 +39,6 @@ export default function PhotoRecipe({ toggleRecipeOverlay, isShowingRecipeOverlay, }} />} - hoverEntity={countOnHover} /> ); } diff --git a/src/recipe/RecipeHeader.tsx b/src/recipe/RecipeHeader.tsx index 96042a17..438c0c8d 100644 --- a/src/recipe/RecipeHeader.tsx +++ b/src/recipe/RecipeHeader.tsx @@ -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={ setRecipeModalProps?.(recipeProps) diff --git a/src/recipe/RecipeModal.tsx b/src/recipe/RecipeModal.tsx index a1588213..4eef7517 100644 --- a/src/recipe/RecipeModal.tsx +++ b/src/recipe/RecipeModal.tsx @@ -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() { diff --git a/src/share/ShareButton.tsx b/src/share/ShareButton.tsx index 6b1a09cc..1dbea562 100644 --- a/src/share/ShareButton.tsx +++ b/src/share/ShareButton.tsx @@ -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'; diff --git a/src/share/ShareModal.tsx b/src/share/ShareModal.tsx index 6516d79d..7c433ac6 100644 --- a/src/share/ShareModal.tsx +++ b/src/share/ShareModal.tsx @@ -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'; diff --git a/src/share/ShareModals.tsx b/src/share/ShareModals.tsx index 4323ac32..5542a74f 100644 --- a/src/share/ShareModals.tsx +++ b/src/share/ShareModals.tsx @@ -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'; diff --git a/src/state/SwrConfigClient.tsx b/src/swr/SwrConfigClient.tsx similarity index 100% rename from src/state/SwrConfigClient.tsx rename to src/swr/SwrConfigClient.tsx diff --git a/src/swr/index.ts b/src/swr/index.ts new file mode 100644 index 00000000..bda00829 --- /dev/null +++ b/src/swr/index.ts @@ -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)); diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index cc65dbcd..163fc40e 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -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 ( - {TAG_FAVS} - - } path={pathForTag(TAG_FAVS)} - tooltipImagePath={pathForTagImage(TAG_FAVS)} - tooltipCaption={countOnHover && - photoQuantityText(countOnHover, appText, false)} - icon={!badged && - } - type={type} - className={className} - hoverEntity={countOnHover} - badged={badged} - contrast={contrast} - prefetch={prefetch} + hoverPhotoQueryOptions={{ tag: TAG_FAVS }} + icon={} + iconBadgeEnd={} /> ); } diff --git a/src/tag/HiddenTag.tsx b/src/tag/HiddenTag.tsx index 80290f8d..9dc29a9e 100644 --- a/src/tag/HiddenTag.tsx +++ b/src/tag/HiddenTag.tsx @@ -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 ( - {TAG_HIDDEN} - - } path={pathForTag(TAG_HIDDEN)} - icon={!badged && } - type={type} - className={className} - hoverEntity={countOnHover} - badged={badged} - contrast={contrast} - prefetch={prefetch} + icon={} + iconBadgeEnd={} /> ); } diff --git a/src/tag/PhotoTag.tsx b/src/tag/PhotoTag.tsx index eb2444c3..f821c9c6 100644 --- a/src/tag/PhotoTag.tsx +++ b/src/tag/PhotoTag.tsx @@ -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 ( } - hoverEntity={countOnHover} /> ); } diff --git a/src/tag/PhotoTags.tsx b/src/tag/PhotoTags.tsx index b0050146..047ee271 100644 --- a/src/tag/PhotoTags.tsx +++ b/src/tag/PhotoTags.tsx @@ -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({ diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index b8d460e6..6991248e 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -28,12 +28,12 @@ export default async function TagHeader({ entity={isTagFavs(tag) ? : } entityVerb={appText.category.taggedPhotos} entityDescription={descriptionForTaggedPhotos( diff --git a/src/utility/useKeydownHandler.ts b/src/utility/useKeydownHandler.ts index 68a66b99..2ce6faed 100644 --- a/src/utility/useKeydownHandler.ts +++ b/src/utility/useKeydownHandler.ts @@ -1,4 +1,4 @@ -import { useAppState } from '@/state/AppState'; +import { useAppState } from '@/app/AppState'; import { useCallback, useEffect } from 'react'; const LISTENER_KEYDOWN = 'keydown'; diff --git a/src/years/PhotoYear.tsx b/src/years/PhotoYear.tsx index 6aa4ef20..26288205 100644 --- a/src/years/PhotoYear.tsx +++ b/src/years/PhotoYear.tsx @@ -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 ( } - hoverEntity={countOnHover} /> ); -} \ No newline at end of file +} diff --git a/src/years/YearHeader.tsx b/src/years/YearHeader.tsx index 62eeb66d..e2b8d52c 100644 --- a/src/years/YearHeader.tsx +++ b/src/years/YearHeader.tsx @@ -29,7 +29,7 @@ export default function YearHeader({ entity={} entityDescription={descriptionForPhotoSet( photos, diff --git a/tailwind.css b/tailwind.css index 29731815..903b967b 100644 --- a/tailwind.css +++ b/tailwind.css @@ -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; }