diff --git a/README.md b/README.md index 810bb866..73ca9c30 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider > There have been reports ([Issue 184](https://github.com/sambecker/exif-photo-blog/issues/184#issuecomment-2629474045) + [185](https://github.com/sambecker/exif-photo-blog/issues/185#issuecomment-2629478570)) that having large photos (over 30MB), or a CDN, e.g., Cloudflare in front of Vercel, may destabilize static optimization. #### Why don't my older photos look right? -> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the outdated photo page (`/admin/outdated`) to make batch updates. +> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or go to photo updates (`/admin/photos/updates`) to sync all photos that need updates. #### Why don't my OG images load when I share a link? > Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1`. Keep in mind that this will increase platform usage. @@ -314,6 +314,9 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider #### I've added my OpenAI key but can't seem to make it work. Why am I seeing connection errors? > You may need to pre-purchase credits before accessing the OpenAI API. See [Issue #110](https://github.com/sambecker/exif-photo-blog/issues/110) for discussion. +#### How do I generate AI text for preexisting photos? +> Once AI text generation is configured, photos missing text will show up in photo updates (`/admin/photos/updates`). + #### Will there be support for image storage providers beyond Vercel, AWS, and Cloudflare? > At this time, there are no plans to introduce support for new storage providers. While configuring a new, AWS-compatible provider (e.g., Cloudflare R2) should not be too difficult, there's nuance to consider surrounding details like IAM, CORS, and domain configuration, which can differ slightly from platform to platform. If you’d like to contribute an implementation for a new storage provider, please open a PR. diff --git a/app/admin/outdated/page.tsx b/app/admin/outdated/page.tsx deleted file mode 100644 index 4f012dca..00000000 --- a/app/admin/outdated/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import AdminOutdatedClient from '@/admin/AdminOutdatedClient'; -import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; -import { getOutdatedPhotos } from '@/photo/db/query'; - -export const maxDuration = 60; - -export default async function AdminOutdatedPage() { - const photos = await getOutdatedPhotos() - .catch(() => []); - - return ( - - ); -} diff --git a/app/admin/photos/page.tsx b/app/admin/photos/page.tsx index 5749df9b..9d5680bd 100644 --- a/app/admin/photos/page.tsx +++ b/app/admin/photos/page.tsx @@ -1,11 +1,10 @@ import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache'; -import { getPhotos } from '@/photo/db/query'; +import { getPhotos, getPhotosInNeedOfSyncCount } from '@/photo/db/query'; import { getPhotosMetaCached } from '@/photo/cache'; import AdminPhotosClient from '@/admin/AdminPhotosClient'; import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; -import { getOutdatedPhotosCount } from '@/photo/db/query'; import { AI_TEXT_GENERATION_ENABLED, PRESERVE_ORIGINAL_UPLOADS, @@ -24,7 +23,7 @@ export default async function AdminPhotosPage() { const [ photos, photosCount, - photosCountOutdated, + photosCountNeedsSync, blobPhotoUrls, ] = await Promise.all([ getPhotos({ @@ -35,7 +34,7 @@ export default async function AdminPhotosPage() { getPhotosMetaCached({ hidden: 'include'}) .then(({ count }) => count) .catch(() => 0), - getOutdatedPhotosCount() + getPhotosInNeedOfSyncCount() .catch(() => 0), DEBUG_PHOTO_BLOBS ? getStoragePhotoUrlsNoStore() @@ -46,7 +45,7 @@ export default async function AdminPhotosPage() { { diff --git a/app/admin/photos/updates/page.tsx b/app/admin/photos/updates/page.tsx new file mode 100644 index 00000000..f0851e94 --- /dev/null +++ b/app/admin/photos/updates/page.tsx @@ -0,0 +1,17 @@ +import AdminPhotosSyncClient from '@/admin/AdminPhotosSyncClient'; +import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; +import { getPhotosInNeedOfSync } from '@/photo/db/query'; + +export const maxDuration = 60; + +export default async function AdminUpdatesPage() { + const photos = await getPhotosInNeedOfSync() + .catch(() => []); + + return ( + + ); +} diff --git a/package.json b/package.json index 0113c130..5cdbddaa 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "@ai-sdk/openai": "^1.3.16", "@aws-sdk/client-s3": "3.787.0", "@aws-sdk/s3-request-presigner": "3.787.0", - "@radix-ui/react-dialog": "^1.1.7", - "@radix-ui/react-dropdown-menu": "^2.1.7", - "@radix-ui/react-tooltip": "^1.2.0", - "@radix-ui/react-visually-hidden": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.11", + "@radix-ui/react-tooltip": "^1.2.3", + "@radix-ui/react-visually-hidden": "^1.2.0", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.8", "@vercel/analytics": "^1.5.0", @@ -62,7 +62,7 @@ "@types/react-dom": "19.1.2", "@types/sanitize-html": "^2.15.0", "cross-fetch": "^4.1.0", - "eslint": "9.24.0", + "eslint": "9.25.0", "eslint-config-next": "15.3.1", "eslint-plugin-react-hooks": "^5.2.0", "jest": "^29.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42965596..dd57a200 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,17 +18,17 @@ importers: specifier: 3.787.0 version: 3.787.0 '@radix-ui/react-dialog': - specifier: ^1.1.7 - version: 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': - specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^2.1.11 + version: 2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tooltip': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-visually-hidden': - specifier: ^1.1.3 - version: 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@upstash/ratelimit': specifier: ^2.0.5 version: 2.0.5(@upstash/redis@1.34.8) @@ -163,14 +163,14 @@ importers: specifier: ^4.1.0 version: 4.1.0 eslint: - specifier: 9.24.0 - version: 9.24.0(jiti@2.4.2) + specifier: 9.25.0 + version: 9.25.0(jiti@2.4.2) eslint-config-next: specifier: 15.3.1 - version: 15.3.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + version: 15.3.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.24.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.25.0(jiti@2.4.2)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@types/node@22.14.1)(typescript@5.8.3)) @@ -598,28 +598,28 @@ packages: resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.2.0': - resolution: {integrity: sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==} + '@eslint/config-helpers@0.2.1': + resolution: {integrity: sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.12.0': - resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} + '@eslint/core@0.13.0': + resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.24.0': - resolution: {integrity: sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==} + '@eslint/js@9.25.0': + resolution: {integrity: sha512-iWhsUS8Wgxz9AXNfvfOPFSW4VfMXdVhp1hjkZVhXCrpgh/aLcc45rX6MPu+tIVUWDw0HfNwth7O28M1xDxNf9w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.2.7': - resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} + '@eslint/plugin-kit@0.2.8': + resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@fastify/busboy@2.1.1': @@ -952,8 +952,8 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} - '@radix-ui/react-arrow@1.1.3': - resolution: {integrity: sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==} + '@radix-ui/react-arrow@1.1.4': + resolution: {integrity: sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -965,8 +965,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.3': - resolution: {integrity: sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==} + '@radix-ui/react-collection@1.1.4': + resolution: {integrity: sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1005,8 +1005,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.7': - resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} + '@radix-ui/react-dialog@1.1.10': + resolution: {integrity: sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1027,8 +1027,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.6': - resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} + '@radix-ui/react-dismissable-layer@1.1.7': + resolution: {integrity: sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1040,8 +1040,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.7': - resolution: {integrity: sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==} + '@radix-ui/react-dropdown-menu@2.1.11': + resolution: {integrity: sha512-wbPE3cFBfLl+S+LCxChWQGX0k14zUxgvep1HEnLhJ9mNhjyO3ETzRviAeKZ3XomT/iVRRZAWFsnFZ3N0wI8OmA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1062,8 +1062,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.3': - resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} + '@radix-ui/react-focus-scope@1.1.4': + resolution: {integrity: sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1093,8 +1093,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-menu@2.1.7': - resolution: {integrity: sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==} + '@radix-ui/react-menu@2.1.11': + resolution: {integrity: sha512-sbFI4Qaw02J0ogmR9tOMsSqsdrGNpUanlPYAqTE2JJafow8ecHtykg4fSTjNHBdDl4deiKMK+RhTEwyVhP7UDA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1106,8 +1106,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.3': - resolution: {integrity: sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==} + '@radix-ui/react-popper@1.2.4': + resolution: {integrity: sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1119,8 +1119,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.5': - resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} + '@radix-ui/react-portal@1.1.6': + resolution: {integrity: sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1158,8 +1158,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.0.3': - resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + '@radix-ui/react-primitive@2.1.0': + resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1171,8 +1171,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.3': - resolution: {integrity: sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==} + '@radix-ui/react-roving-focus@1.1.7': + resolution: {integrity: sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1202,8 +1202,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-tooltip@1.2.0': - resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} + '@radix-ui/react-tooltip@1.2.3': + resolution: {integrity: sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1224,8 +1224,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.1.1': - resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1278,8 +1287,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.1.3': - resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} + '@radix-ui/react-visually-hidden@1.2.0': + resolution: {integrity: sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2489,8 +2498,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.24.0: - resolution: {integrity: sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==} + eslint@9.25.0: + resolution: {integrity: sha512-MsBdObhM4cEwkzCiraDv7A6txFXEqtNXOb877TsSp2FCkBNl8JfVQrmiuDqC1IkejT6JLPzYBXx/xAiYhyzgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -5077,9 +5086,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.24.0(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.4.1(eslint@9.25.0(jiti@2.4.2))': dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -5092,9 +5101,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.2.0': {} + '@eslint/config-helpers@0.2.1': {} - '@eslint/core@0.12.0': + '@eslint/core@0.13.0': dependencies: '@types/json-schema': 7.0.15 @@ -5112,13 +5121,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.24.0': {} + '@eslint/js@9.25.0': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.2.7': + '@eslint/plugin-kit@0.2.8': dependencies: - '@eslint/core': 0.12.0 + '@eslint/core': 0.13.0 levn: 0.4.1 '@fastify/busboy@2.1.1': {} @@ -5484,20 +5493,20 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-arrow@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-arrow@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-collection@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5523,20 +5532,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-dialog@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) aria-hidden: 1.2.4 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5551,11 +5560,11 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-dismissable-layer@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 @@ -5564,15 +5573,15 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-dropdown-menu@2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dropdown-menu@2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-menu': 2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5585,10 +5594,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-focus-scope@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5610,22 +5619,22 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-menu@2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menu@2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) aria-hidden: 1.2.4 @@ -5636,13 +5645,13 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-popper@1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popper@1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.2)(react@19.1.0) @@ -5654,9 +5663,9 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-portal@1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5683,7 +5692,7 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 @@ -5692,17 +5701,17 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-roving-focus@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5723,20 +5732,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-tooltip@1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tooltip@1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5749,9 +5758,17 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.1.2)(react@19.1.0)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.2)(react@19.1.0)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.2 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.2)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 optionalDependencies: '@types/react': 19.1.2 @@ -5789,9 +5806,9 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-visually-hidden@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -6357,15 +6374,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.24.1 - '@typescript-eslint/type-utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.24.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -6374,14 +6391,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.24.1 '@typescript-eslint/types': 8.24.1 '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.24.1 debug: 4.4.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -6391,12 +6408,12 @@ snapshots: '@typescript-eslint/types': 8.24.1 '@typescript-eslint/visitor-keys': 8.24.1 - '@typescript-eslint/type-utils@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) debug: 4.4.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) ts-api-utils: 2.0.1(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -6418,13 +6435,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/utils@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.24.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.25.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.24.1 '@typescript-eslint/types': 8.24.1 '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -6778,7 +6795,7 @@ snapshots: cmdk@1.1.1(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.0(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 @@ -7109,19 +7126,19 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.3.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3): + eslint-config-next@15.3.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3): dependencies: '@next/eslint-plugin-next': 15.3.1 '@rushstack/eslint-patch': 1.10.5 - '@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + '@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-react: 7.37.4(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-react: 7.37.4(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.25.0(jiti@2.4.2)) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -7137,33 +7154,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 enhanced-resolve: 5.18.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) get-tsconfig: 4.10.0 is-bun-module: 1.3.0 stable-hash: 0.0.4 tinyglobby: 0.2.11 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -7172,9 +7189,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7186,13 +7203,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.25.0(jiti@2.4.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.8 @@ -7202,7 +7219,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7211,11 +7228,11 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-react-hooks@5.2.0(eslint@9.25.0(jiti@2.4.2)): dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) - eslint-plugin-react@7.37.4(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-react@7.37.4(eslint@9.25.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -7223,7 +7240,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -7246,16 +7263,16 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.24.0(jiti@2.4.2): + eslint@9.25.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.24.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.25.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.20.0 - '@eslint/config-helpers': 0.2.0 - '@eslint/core': 0.12.0 + '@eslint/config-helpers': 0.2.1 + '@eslint/core': 0.13.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.24.0 - '@eslint/plugin-kit': 0.2.7 + '@eslint/js': 9.25.0 + '@eslint/plugin-kit': 0.2.8 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.2 diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index c5d33cdb..93501daa 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -30,6 +30,7 @@ import AdminLink from './AdminLink'; import ScoreCardContainer from '@/components/ScoreCardContainer'; import { capitalize, deparameterize } from '@/utility/string'; import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; +import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai'; export default function AdminAppConfigurationClient({ // Storage @@ -365,6 +366,28 @@ export default function AdminAppConfigurationClient({ and improve accessibility: {renderEnvVars(['OPENAI_SECRET_KEY'])} + + {hasAiTextAutoGeneratedFields && + AI_AUTO_GENERATED_FIELDS_ALL.map(field => + + {renderSubStatus( + aiTextAutoGeneratedFields.includes(field) + ? 'checked' + : 'optional', + capitalize(field), + )} + )} + Comma-separated fields to auto-generate when + uploading photos. Accepted values: title, caption, + tags, description, all, or none + {' '} + (default: {'"title,tags,semantic"'}): + {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} + - - Comma-separated fields to auto-generate when - uploading photos. Accepted values: title, caption, - tags, description, all, or none - {' '} - (default: {'"title,tags,semantic"'}): - {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} - , href: PATH_ADMIN_UPLOADS, }); } + if (photosCountNeedSync) { + items.push({ + label: 'Updates', + annotation: <> + + {photosCountNeedSync} + + + >, + icon: , + href: PATH_ADMIN_PHOTOS_UPDATES, + }); + } if (photosCountTotal) { items.push({ label: 'Manage Photos', @@ -121,7 +144,10 @@ export default function AdminAppMenu({ size={18} className="translate-x-[-1px] translate-y-[0.5px]" /> - : , + : , href: PATH_GRID_INFERRED, action: () => { if (isSelecting) { diff --git a/src/admin/AdminOutdatedClient.tsx b/src/admin/AdminOutdatedClient.tsx deleted file mode 100644 index 512c65f1..00000000 --- a/src/admin/AdminOutdatedClient.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import { Photo } from '@/photo'; -import AdminPhotosTable from '@/admin/AdminPhotosTable'; -import LoaderButton from '@/components/primitives/LoaderButton'; -import IconGrSync from '@/components/icons/IconGrSync'; -import Note from '@/components/Note'; -import AdminChildPage from '@/components/AdminChildPage'; -import { PATH_ADMIN_PHOTOS } from '@/app/paths'; -import { useState } from 'react'; -import { syncPhotosAction } from '@/photo/actions'; -import { useRouter } from 'next/navigation'; -import ResponsiveText from '@/components/primitives/ResponsiveText'; -import { LiaBroomSolid } from 'react-icons/lia'; - -const UPDATE_BATCH_SIZE_MAX = 4; - -export default function AdminOutdatedClient({ - photos, - hasAiTextGeneration, -}: { - photos: Photo[] - hasAiTextGeneration: boolean -}) { - const updateBatchSize = Math.min(UPDATE_BATCH_SIZE_MAX, photos.length); - - const [photoIdsSyncing, setPhotoIdsSyncing] = useState([]); - - const arePhotoIdsSyncing = photoIdsSyncing.length > 0; - - const router = useRouter(); - - return ( - - - Outdated ({photos.length}) - - - Outdated - - >} - accessory={} - hideTextOnMobile={false} - onClick={async () => { - if (window.confirm( - // eslint-disable-next-line max-len - `Are you sure you want to sync the oldest ${updateBatchSize} photos? This action cannot be undone.`, - )) { - const photosToSync = photos - .slice(0, updateBatchSize) - .map(photo => photo.id); - const isFinalBatch = photosToSync.length >= photos.length; - setPhotoIdsSyncing(photosToSync); - syncPhotosAction(photosToSync) - .finally(() => { - if (isFinalBatch) { - router.push(PATH_ADMIN_PHOTOS); - } else { - setPhotoIdsSyncing([]); - router.refresh(); - } - }); - } - }} - isLoading={arePhotoIdsSyncing} - disabled={!updateBatchSize} - > - {arePhotoIdsSyncing - ? 'Syncing' - : - Sync Next {updateBatchSize} Photos - } - } - > - - } - > - - - {photos.length} outdated - {' '} - {photos.length === 1 ? 'photo' : 'photos'} found - - Sync photos to import newer EXIF fields, improve blur data, - {' '} - and leverage AI-generated text where possible - - - - - - - - ); -} diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index 7ce6a06b..b5545924 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -21,10 +21,10 @@ import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import { MdOutlineFileDownload } from 'react-icons/md'; import MoreMenuItem from '@/components/more/MoreMenuItem'; import IconGrSync from '@/components/icons/IconGrSync'; -import { isPhotoOutdated } from '@/photo/outdated'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import IconFavs from '@/components/icons/IconFavs'; import IconEdit from '@/components/icons/IconEdit'; +import { photoNeedsToBeSynced } from '@/photo/sync'; export default function AdminPhotoMenu({ photo, @@ -79,10 +79,11 @@ export default function AdminPhotoMenu({ label: 'Sync', labelComplex: Sync - {isPhotoOutdated(photo) && + {photoNeedsToBeSynced(photo) && } , icon: , diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 100567a8..524ad0a6 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -5,19 +5,21 @@ import AppGrid from '@/components/AppGrid'; import AdminPhotosTable from '@/admin/AdminPhotosTable'; import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite'; import PathLoaderButton from '@/components/primitives/PathLoaderButton'; -import { PATH_ADMIN_OUTDATED } from '@/app/paths'; +import { PATH_ADMIN_PHOTOS_UPDATES } from '@/app/paths'; import { Photo } from '@/photo'; import { StorageListResponse } from '@/platforms/storage'; -import { LiaBroomSolid } from 'react-icons/lia'; import AdminUploadsTable from './AdminUploadsTable'; import { Timezone } from '@/utility/timezone'; import { useAppState } from '@/state/AppState'; import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; +import { pluralize } from '@/utility/string'; +import IconBroom from '@/components/icons/IconBroom'; +import ResponsiveText from '@/components/primitives/ResponsiveText'; export default function AdminPhotosClient({ photos, photosCount, - photosCountOutdated, + photosCountNeedsSync, blobPhotoUrls, shouldResize, hasAiTextGeneration, @@ -28,7 +30,7 @@ export default function AdminPhotosClient({ }: { photos: Photo[] photosCount: number - photosCountOutdated: number + photosCountNeedsSync: number blobPhotoUrls: StorageListResponse shouldResize: boolean hasAiTextGeneration: boolean @@ -51,14 +53,17 @@ export default function AdminPhotosClient({ onLastUpload={onLastUpload} /> - {photosCountOutdated > 0 && + {photosCountNeedsSync > 0 && } - title={`${photosCountOutdated} Outdated Photos`} + tooltip={( + pluralize(photosCountNeedsSync, 'photo') + + ' missing data or AI-generated text' + )} className={clsx( 'text-blue-600 dark:text-blue-400', 'border border-blue-200 dark:border-blue-800/60', @@ -70,7 +75,9 @@ export default function AdminPhotosClient({ spinnerClassName="text-blue-200 dark:text-blue-600/40" hideTextOnMobile={false} > - {photosCountOutdated} + + {pluralize(photosCountNeedsSync, 'Update')} + } {blobPhotoUrls.length > 0 && diff --git a/src/admin/AdminPhotosSyncClient.tsx b/src/admin/AdminPhotosSyncClient.tsx new file mode 100644 index 00000000..60466810 --- /dev/null +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { Photo } from '@/photo'; +import AdminPhotosTable from '@/admin/AdminPhotosTable'; +import IconGrSync from '@/components/icons/IconGrSync'; +import Note from '@/components/Note'; +import AdminChildPage from '@/components/AdminChildPage'; +import { PATH_ADMIN_PHOTOS } from '@/app/paths'; +import { useMemo, useRef, useState } from 'react'; +import { syncPhotosAction } from '@/photo/actions'; +import { useRouter } from 'next/navigation'; +import ResponsiveText from '@/components/primitives/ResponsiveText'; +import { LiaBroomSolid } from 'react-icons/lia'; +import ProgressButton from '@/components/primitives/ProgressButton'; +import ErrorNote from '@/components/ErrorNote'; +import { pluralize } from '@/utility/string'; +import { getPhotosSyncStatusText } from '@/photo/sync'; + +const SYNC_BATCH_SIZE_MAX = 3; + +export default function AdminPhotosSyncClient({ + photos, + hasAiTextGeneration, +}: { + photos: Photo[] + hasAiTextGeneration: boolean +}) { + // Use refs for non-reactive while loop state + const photoIdsToSync = useRef(photos.map(photo => photo.id)); + const errorRef = useRef(undefined); + + // Use state for updating progress button and error UI + const [photoIdsSyncing, setPhotoIdsSyncing] = useState([]); + const [error, setError] = useState(); + const [progress, setProgress] = useState(0); + + const arePhotoIdsSyncing = photoIdsSyncing.length > 0; + + const router = useRouter(); + + const statusText = useMemo(() => getPhotosSyncStatusText(photos), [photos]); + + return ( + + Updates ({photos.length}) + } + accessory={} + hideTextOnMobile={false} + progress={progress} + tooltip={`Sync data for all ${pluralize(photos.length, 'photo')}`} + onClick={async () => { + if (window.confirm([ + 'Are you sure you want to sync', + photos.length === 1 + ? '1 photo?' + : `all ${photos.length} photos?`, + 'Browser must remain open while syncing.', + 'This action cannot be undone.', + ].join(' '))) { + errorRef.current = undefined; + setError(undefined); + while (photoIdsToSync.current.length > 0) { + const photoIds = photoIdsToSync.current + .slice(0, SYNC_BATCH_SIZE_MAX); + setPhotoIdsSyncing(photoIds); + await syncPhotosAction(photoIds) + .then(() => { + photoIdsToSync.current = photoIdsToSync.current.filter( + id => !photoIds.includes(id), + ); + setProgress( + (photos.length - photoIdsToSync.current.length) / + photos.length, + ); + router.refresh(); + }) + .catch(e => { + errorRef.current = e; + setError(e); + }); + if (errorRef.current) { break; } + } + if (!errorRef.current) { + router.push(PATH_ADMIN_PHOTOS); + } else { + setProgress(0); + setPhotoIdsSyncing([]); + router.refresh(); + } + } + }} + isLoading={arePhotoIdsSyncing} + disabled={photoIdsSyncing.length > 0} + > + {arePhotoIdsSyncing + ? 'Syncing ...' + : 'Sync All'} + } + > + + {error && + + Issue syncing: + + {' '} + {error.message} + } + } + > + + + Photo updates: {statusText} + + Sync to capture new EXIF fields, improve blur data, + {' '} + use AI to generate missing text (if configured) + + + + + + + + ); +} diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 0c93e850..3fc11ace 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -15,6 +15,8 @@ import PhotoSyncButton from './PhotoSyncButton'; import DeletePhotoButton from './DeletePhotoButton'; import { Timezone } from '@/utility/timezone'; import IconHidden from '@/components/icons/IconHidden'; +import Tooltip from '@/components/Tooltip'; +import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync'; export default function AdminPhotosTable({ photos, @@ -22,20 +24,22 @@ export default function AdminPhotosTable({ revalidatePhoto, photoIdsSyncing = [], hasAiTextGeneration, - showUpdatedAt, + dateType = 'createdAt', canEdit = true, canDelete = true, timezone, + shouldScrollIntoViewOnExternalSync, }: { photos: Photo[], onLastPhotoVisible?: () => void revalidatePhoto?: RevalidatePhoto photoIdsSyncing?: string[] hasAiTextGeneration: boolean - showUpdatedAt?: boolean + dateType?: 'createdAt' | 'updatedAt' canEdit?: boolean canDelete?: boolean timezone?: Timezone + shouldScrollIntoViewOnExternalSync?: boolean }) { const { invalidateSwr } = useAppState(); @@ -68,7 +72,7 @@ export default function AdminPhotosTable({ - {titleForPhoto(photo)} + {titleForPhoto(photo, false)} {photo.hidden && {' '} - + {<> + + {photoNeedsToBeSynced(photo) && + } + >} {canDelete && ) { + const ref = useRef(null); + const [isSyncing, setIsSyncing] = useState(false); const confirmText = ['Overwrite']; @@ -33,10 +39,18 @@ export default function PhotoSyncButton({ 'AI text will be generated for undefined fields.'); } confirmText.push('This action cannot be undone.'); + useScrollIntoView({ + ref, + shouldScrollIntoView: + isSyncingExternal && + shouldScrollIntoViewOnExternalSync, + }); + return ( } diff --git a/src/admin/actions.ts b/src/admin/actions.ts index 283a69cb..c108be96 100644 --- a/src/admin/actions.ts +++ b/src/admin/actions.ts @@ -7,12 +7,16 @@ import { testDatabaseConnection } from '@/platforms/postgres'; import { testStorageConnection } from '@/platforms/storage'; import { APP_CONFIGURATION } from '@/app/config'; import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; -import { getInsightsIndicatorStatus } from '@/admin/insights/server'; import { getPhotosMeta, getUniqueTags, getUniqueRecipes, + getPhotosInNeedOfSyncCount, } from '@/photo/db/query'; +import { + getGitHubMetaForCurrentApp, + indicatorStatusForSignificantInsights, +} from './insights'; export type AdminData = Awaited>; @@ -21,10 +25,11 @@ export const getAdminDataAction = async () => const [ photosCount, photosCountHidden, + photosCountNeedSync, + codeMeta, uploadsCount, tagsCount, recipesCount, - insightsIndicatorStatus, ] = await Promise.all([ getPhotosMeta() .then(({ count }) => count) @@ -32,6 +37,8 @@ export const getAdminDataAction = async () => getPhotosMeta({ hidden: 'only' }) .then(({ count }) => count) .catch(() => 0), + getPhotosInNeedOfSyncCount(), + getGitHubMetaForCurrentApp(), getStorageUploadUrlsNoStore() .then(urls => urls.length) .catch(e => { @@ -44,9 +51,13 @@ export const getAdminDataAction = async () => getUniqueRecipes() .then(recipes => recipes.length) .catch(() => 0), - getInsightsIndicatorStatus(), ]); + const insightsIndicatorStatus = indicatorStatusForSignificantInsights({ + codeMeta, + photosCountNeedSync, + }); + const photosCountTotal = ( photosCount !== undefined && photosCountHidden !== undefined @@ -57,12 +68,13 @@ export const getAdminDataAction = async () => return { photosCount, photosCountHidden, + photosCountNeedSync, photosCountTotal, uploadsCount, tagsCount, recipesCount, insightsIndicatorStatus, - }; + } as const; }); const scanForError = ( diff --git a/src/admin/insights/AdminAppInsights.tsx b/src/admin/insights/AdminAppInsights.tsx index de07d2e9..d53a00dd 100644 --- a/src/admin/insights/AdminAppInsights.tsx +++ b/src/admin/insights/AdminAppInsights.tsx @@ -9,13 +9,13 @@ import { } from '@/photo/db/query'; import AdminAppInsightsClient from './AdminAppInsightsClient'; import { getAllInsights, getGitHubMetaForCurrentApp } from '.'; -import { getOutdatedPhotosCount } from '@/photo/db/query'; +import { getPhotosInNeedOfSyncCount } from '@/photo/db/query'; export default async function AdminAppInsights() { const [ { count: photosCount, dateRange }, { count: photosCountHidden }, - photosCountOutdated, + photosCountNeedSync, { count: photosCountPortrait }, codeMeta, cameras, @@ -27,7 +27,7 @@ export default async function AdminAppInsights() { ] = await Promise.all([ getPhotosMeta({ hidden: 'include' }), getPhotosMeta({ hidden: 'only' }), - getOutdatedPhotosCount(), + getPhotosInNeedOfSyncCount(), getPhotosMeta({ maximumAspectRatio: 0.9 }), getGitHubMetaForCurrentApp(), getUniqueCameras(), @@ -44,14 +44,14 @@ export default async function AdminAppInsights() { insights={getAllInsights({ codeMeta, photosCount, - photosCountOutdated, + photosCountNeedSync, photosCountPortrait, tagsCount: tags.length, })} photoStats={{ photosCount, photosCountHidden, - photosCountOutdated, + photosCountNeedSync, camerasCount: cameras.length, lensesCount: lenses.length, tagsCount: tags.length, diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index 9801b5ad..dc71d4df 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -28,7 +28,7 @@ import { import EnvVar from '@/components/EnvVar'; import { IoSyncCircle } from 'react-icons/io5'; import clsx from 'clsx/lite'; -import { PATH_ADMIN_OUTDATED } from '@/app/paths'; +import { PATH_ADMIN_PHOTOS_UPDATES } from '@/app/paths'; import { LiaBroomSolid } from 'react-icons/lia'; import { IoMdGrid } from 'react-icons/io'; import { RiSpeedMiniLine } from 'react-icons/ri'; @@ -46,11 +46,12 @@ import IconFocalLength from '@/components/icons/IconFocalLength'; import IconTag from '@/components/icons/IconTag'; import IconPhoto from '@/components/icons/IconPhoto'; import { HiOutlineDocumentText } from 'react-icons/hi'; +import { ReactNode } from 'react'; const DEBUG_COMMIT_SHA = '4cd29ed'; const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes'; const DEBUG_BEHIND_BY = 9; -const DEBUG_PHOTOS_COUNT_OUTDATED = 7; +const DEBUG_PHOTOS_NEED_SYNC_COUNT = 7; const TEXT_COLOR_WARNING = 'text-amber-600 dark:text-amber-500'; const TEXT_COLOR_BLUE = 'text-blue-600 dark:text-blue-500'; @@ -91,7 +92,7 @@ export default function AdminAppInsightsClient({ photoStats: { photosCount, photosCountHidden, - photosCountOutdated, + photosCountNeedSync, camerasCount, lensesCount, tagsCount, @@ -114,7 +115,7 @@ export default function AdminAppInsightsClient({ noAiRateLimiting, noConfiguredDomain, noConfiguredMeta, - outdatedPhotos, + photosNeedSync, photoMatting, camerasFirst, gridFirst, @@ -131,6 +132,13 @@ export default function AdminAppInsightsClient({ {codeMeta?.branch ?? TEMPLATE_REPO_BRANCH} ; + const renderTooltipContent = (content: ReactNode) => + ; + return ( {(codeMeta || debug) && <> @@ -143,11 +151,9 @@ export default function AdminAppInsightsClient({ />} content={<> Could not analyze source code - + {renderTooltipContent( + 'Could not connect to GitHub API. Try refreshing.', + )} >} />} {((!codeMeta?.didError && noFork) || debug) && @@ -417,7 +423,7 @@ export default function AdminAppInsightsClient({ } - {(outdatedPhotos || debug) && } - content={renderHighlightText( - pluralize( - photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, - 'outdated photo', - ), - 'blue', - )} - expandPath={PATH_ADMIN_OUTDATED} + content={<> + {renderHighlightText( + pluralize( + photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT, + 'photo', + ), + 'blue', + )} + {' '} + with updates + {renderTooltipContent(<> + Missing data or AI‑generated text + >)} + >} + expandPath={PATH_ADMIN_PHOTOS_UPDATES} />} export interface PhotoStats { photosCount: number photosCountHidden: number - photosCountOutdated: number + photosCountNeedSync: number camerasCount: number lensesCount: number tagsCount: number @@ -80,10 +80,10 @@ export const getGitHubMetaForCurrentApp = () => export const getSignificantInsights = ({ codeMeta, - photosCountOutdated, + photosCountNeedSync, }: { codeMeta: Awaited> - photosCountOutdated: number + photosCountNeedSync: number }) => { const { isAiTextGenerationEnabled, @@ -95,30 +95,38 @@ export const getSignificantInsights = ({ forkBehind: Boolean(codeMeta?.isBehind), noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage, noConfiguredDomain: !hasDomain, - outdatedPhotos: Boolean(photosCountOutdated), + photosNeedSync: Boolean(photosCountNeedSync), }; }; -export const indicatorStatusForSignificantInsights = ( - insights: Awaited>, -) => { +export const indicatorStatusForSignificantInsights = ({ + codeMeta, + photosCountNeedSync, +}: Parameters[0] & { + photosCountNeedSync: number +}) => { + const insights = getSignificantInsights({ + codeMeta, + photosCountNeedSync, + }); + const { forkBehind, noAiRateLimiting, noConfiguredDomain, - outdatedPhotos, + photosNeedSync, } = insights; if (noAiRateLimiting || noConfiguredDomain) { return 'yellow'; - } else if (forkBehind || outdatedPhotos) { + } else if (forkBehind || photosNeedSync) { return 'blue'; } }; export const getAllInsights = ({ codeMeta, - photosCountOutdated, + photosCountNeedSync, photosCount, photosCountPortrait, tagsCount, @@ -127,7 +135,7 @@ export const getAllInsights = ({ photosCountPortrait: number tagsCount: number }) => ({ - ...getSignificantInsights({ codeMeta, photosCountOutdated }), + ...getSignificantInsights({ codeMeta, photosCountNeedSync }), noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo, noAi: !AI_TEXT_GENERATION_ENABLED, noConfiguredMeta: diff --git a/src/admin/insights/server.ts b/src/admin/insights/server.ts deleted file mode 100644 index 3b453def..00000000 --- a/src/admin/insights/server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getOutdatedPhotosCount } from '@/photo/db/query'; -import { - getSignificantInsights, - indicatorStatusForSignificantInsights, -} from '.'; -import { getGitHubMetaForCurrentApp } from '.'; - -export const getInsightsIndicatorStatus = async () => { - const [ - codeMeta, - photosCountOutdated, - ] = await Promise.all([ - getGitHubMetaForCurrentApp(), - getOutdatedPhotosCount(), - ]); - - const significantInsights = getSignificantInsights({ - codeMeta, - photosCountOutdated, - }); - - return indicatorStatusForSignificantInsights(significantInsights); -}; diff --git a/src/app/paths.ts b/src/app/paths.ts index 43d9a119..1c3d0465 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -7,50 +7,54 @@ import { TAG_HIDDEN } from '@/tag'; import { Lens } from '@/lens'; // Core paths -export const PATH_ROOT = '/'; -export const PATH_GRID = '/grid'; -export const PATH_FEED = '/feed'; -export const PATH_ADMIN = '/admin'; -export const PATH_API = '/api'; -export const PATH_SIGN_IN = '/sign-in'; -export const PATH_OG = '/og'; -// eslint-disable-next-line max-len -export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID; -// eslint-disable-next-line max-len -export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_FEED : PATH_ROOT; +export const PATH_ROOT = '/'; +export const PATH_GRID = '/grid'; +export const PATH_FEED = '/feed'; +export const PATH_ADMIN = '/admin'; +export const PATH_API = '/api'; +export const PATH_SIGN_IN = '/sign-in'; +export const PATH_OG = '/og'; + +export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED + ? PATH_ROOT + : PATH_GRID; + +export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED + ? PATH_FEED + : PATH_ROOT; // Path prefixes -export const PREFIX_PHOTO = '/p'; -export const PREFIX_CAMERA = '/shot-on'; -export const PREFIX_LENS = '/lens'; -export const PREFIX_TAG = '/tag'; -export const PREFIX_RECIPE = '/recipe'; -export const PREFIX_FILM = '/film'; -export const PREFIX_FOCAL_LENGTH = '/focal'; +export const PREFIX_PHOTO = '/p'; +export const PREFIX_CAMERA = '/shot-on'; +export const PREFIX_LENS = '/lens'; +export const PREFIX_TAG = '/tag'; +export const PREFIX_RECIPE = '/recipe'; +export const PREFIX_FILM = '/film'; +export const PREFIX_FOCAL_LENGTH = '/focal'; // Dynamic paths -const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; -const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`; -const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`; -const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; -const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`; -const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`; -const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`; +const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; +const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`; +const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`; +const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; +const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`; +const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`; +const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`; // Admin paths -export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; -export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`; -export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; -export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; -export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`; -export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; -export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`; -export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`; -export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`; +export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; +export const PATH_ADMIN_PHOTOS_UPDATES = `${PATH_ADMIN_PHOTOS}/updates`; +export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; +export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; +export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`; +export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; +export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`; +export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`; +export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`; // Debug paths -export const PATH_OG_ALL = `${PATH_OG}/all`; -export const PATH_OG_SAMPLE = `${PATH_OG}/sample`; +export const PATH_OG_ALL = `${PATH_OG}/all`; +export const PATH_OG_SAMPLE = `${PATH_OG}/sample`; // API paths export const PATH_API_STORAGE = `${PATH_API}/storage`; @@ -66,6 +70,7 @@ export const MISSING_FIELD = '-'; export const PATHS_ADMIN = [ PATH_ADMIN, PATH_ADMIN_PHOTOS, + PATH_ADMIN_PHOTOS_UPDATES, PATH_ADMIN_UPLOADS, PATH_ADMIN_TAGS, PATH_ADMIN_RECIPES, @@ -94,9 +99,6 @@ type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & { showRecipe?: boolean }; -// Absolute paths -export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`; - export const pathForAdminUploadUrl = (url: string) => `${PATH_ADMIN_UPLOADS}/${encodeURIComponent(url)}`; @@ -164,6 +166,9 @@ export const pathForFocalLength = (focal: number) => export const pathForRecipe = (recipe: string) => `${PREFIX_RECIPE}/${recipe}`; +// Absolute paths +export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`; + export const absolutePathForPhoto = (params: PhotoPathParams) => `${BASE_URL}${pathForPhoto(params)}`; diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index e0743f16..5c6dd3ba 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -2,7 +2,6 @@ import { BiCopy } from 'react-icons/bi'; import LoaderButton from './primitives/LoaderButton'; import clsx from 'clsx/lite'; import { toastSuccess } from '@/toast'; -import Tooltip from './Tooltip'; import { ComponentProps } from 'react'; export default function CopyButton({ @@ -10,20 +9,18 @@ export default function CopyButton({ text, subtle, iconSize = 15, - tooltip, - tooltipColor, className, + ...props }: { label: string text?: string, subtle?: boolean iconSize?: number - tooltip?: string - tooltipColor?: ComponentProps['color'] className?: string -}) { - const button = +} & ComponentProps) { + return ( } className={clsx( subtle && 'text-gray-300 dark:text-gray-700', @@ -37,13 +34,6 @@ export default function CopyButton({ : undefined} styleAs="link" disabled={!text} - />; - - return ( - tooltip - ? - {button} - - : button + /> ); } diff --git a/src/components/icons/IconBroom.tsx b/src/components/icons/IconBroom.tsx new file mode 100644 index 00000000..fbb7c29e --- /dev/null +++ b/src/components/icons/IconBroom.tsx @@ -0,0 +1,6 @@ +import { IconBaseProps } from 'react-icons'; +import { LiaBroomSolid } from 'react-icons/lia'; + +export default function IconBroom(props: IconBaseProps) { + return ; +} diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index f021b70b..9a86cf44 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -21,7 +21,7 @@ export default function MoreMenuItem({ }: { label: string labelComplex?: ReactNode - annotation?: string + annotation?: ReactNode icon?: ReactNode href?: string hrefDownloadName?: string diff --git a/src/components/primitives/LoaderButton.tsx b/src/components/primitives/LoaderButton.tsx index fa105096..169d8809 100644 --- a/src/components/primitives/LoaderButton.tsx +++ b/src/components/primitives/LoaderButton.tsx @@ -2,9 +2,36 @@ import Spinner, { SpinnerColor } from '@/components/Spinner'; import { clsx } from 'clsx/lite'; -import { ButtonHTMLAttributes, ReactNode } from 'react'; +import { + ButtonHTMLAttributes, + ComponentProps, + ReactNode, + RefObject, +} from 'react'; +import Tooltip from '../Tooltip'; -export default function LoaderButton(props: { +export default function LoaderButton({ + ref, + children, + isLoading, + icon, + spinnerColor, + spinnerClassName, + styleAs = 'button', + hideTextOnMobile = true, + confirmText, + shouldPreventDefault, + primary, + hideFocusOutline, + type = 'button', + onClick, + disabled, + className, + tooltip, + tooltipColor, + ...rest +}: { + ref?: RefObject isLoading?: boolean icon?: ReactNode spinnerColor?: SpinnerColor @@ -15,29 +42,13 @@ export default function LoaderButton(props: { shouldPreventDefault?: boolean primary?: boolean hideFocusOutline?: boolean + tooltip?: string + tooltipColor?: ComponentProps['color'] } & ButtonHTMLAttributes) { - const { - children, - isLoading, - icon, - spinnerColor, - spinnerClassName, - styleAs = 'button', - hideTextOnMobile = true, - confirmText, - shouldPreventDefault, - primary, - hideFocusOutline, - type = 'button', - onClick, - disabled, - className, - ...rest - } = props; - - return ( + const button = { if (shouldPreventDefault) { e.preventDefault(); } @@ -86,6 +97,13 @@ export default function LoaderButton(props: { )}> {children} } - + ; + + return ( + tooltip + ? + {button} + + : button ); } diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 6513ba29..0768c850 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -374,7 +374,7 @@ export const getExifDataAction = async ( // - strip GPS data if necessary // - update blur data (or destroy if blur is disabled) // - generate AI text data, if enabled, and auto-generated fields are empty -export const syncPhotoAction = async (photoId: string) => +export const syncPhotoAction = async (photoId: string, isBatch?: boolean) => runAuthenticatedAdminServerAction(async () => { const photo = await getPhoto(photoId ?? '', true); @@ -414,7 +414,8 @@ export const syncPhotoAction = async (photoId: string) => semanticDescription: aiSemanticDescription, } = await generateAiImageQueries( imageResizedBase64, - AI_TEXT_AUTO_GENERATED_FIELDS, + photo.syncStatus.missingAiTextFields, + isBatch, ); const formDataFromPhoto = convertPhotoToFormData(photo); @@ -451,7 +452,7 @@ export const syncPhotoAction = async (photoId: string) => export const syncPhotosAction = async (photoIds: string[]) => runAuthenticatedAdminServerAction(async () => { for (const photoId of photoIds) { - await syncPhotoAction(photoId); + await syncPhotoAction(photoId, true); } revalidateAllKeysAndPaths(); }); diff --git a/src/photo/ai/server.ts b/src/photo/ai/server.ts index e1ac4130..d053a355 100644 --- a/src/photo/ai/server.ts +++ b/src/photo/ai/server.ts @@ -9,6 +9,7 @@ import { getUniqueTags } from '../db/query'; export const generateAiImageQueries = async ( imageBase64?: string, textFieldsToGenerate: AiAutoGeneratedField[] = [], + isBatch?: boolean, ): Promise<{ title?: string caption?: string @@ -31,6 +32,7 @@ export const generateAiImageQueries = async ( const titleAndCaption = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('title-and-caption'), + isBatch, ); if (titleAndCaption) { const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption); @@ -42,12 +44,14 @@ export const generateAiImageQueries = async ( title = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('title'), + isBatch, ); } if (textFieldsToGenerate.includes('caption')) { caption = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('caption'), + isBatch, ); } } @@ -57,6 +61,7 @@ export const generateAiImageQueries = async ( tags = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('tags', existingTags), + isBatch, ); } @@ -64,6 +69,7 @@ export const generateAiImageQueries = async ( semanticDescription = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('description-small'), + isBatch, ); } } diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index f15d954c..a73a9591 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -7,13 +7,17 @@ import { Lens } from '@/lens'; export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const PHOTO_DEFAULT_LIMIT = 100; -// Trim whitespace -// Make lowercase -// Remove commas, slashes -// Replace spaces with dashes +const DB_PARAMETERIZE_REPLACEMENTS = [ + [',', ''], + ['/', ''], + ['+', '-'], + [' ', '-'], +]; + const parameterizeForDb = (field: string) => - // eslint-disable-next-line max-len - `REPLACE(REPLACE(REPLACE(LOWER(TRIM(${field})), ',', ''), '/', ''), ' ', '-')`; + DB_PARAMETERIZE_REPLACEMENTS.reduce((acc, [from, to]) => + `REPLACE(${acc}, '${from}', '${to}')` + , `LOWER(TRIM(${field}))`); export type GetPhotosOptions = { sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority' diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 376a58d7..64f306d0 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -1,3 +1,4 @@ +/* eslint-disable quotes */ import { sql, query, @@ -14,7 +15,11 @@ import { import { Cameras, createCameraKey } from '@/camera'; import { Tags } from '@/tag'; import { Films } from '@/film'; -import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config'; +import { + ADMIN_SQL_DEBUG_ENABLED, + AI_TEXT_AUTO_GENERATED_FIELDS, + AI_TEXT_GENERATION_ENABLED, +} from '@/app/config'; import { GetPhotosOptions, getLimitAndOffsetFromOptions, @@ -24,7 +29,11 @@ import { getWheresFromOptions } from '.'; import { FocalLengths } from '@/focal'; import { Lenses, createLensKey } from '@/lens'; import { migrationForError } from './migration'; -import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated'; +import { + SYNC_QUERY_LIMIT, + UPDATED_BEFORE_01, + UPDATED_BEFORE_02, +} from '../sync'; import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { Recipes } from '@/recipe'; @@ -568,38 +577,55 @@ export const getPhoto = async ( .then(photos => photos.length > 0 ? photos[0] : undefined); }, 'getPhoto'); -// Outdated queries +// Sync queries -const outdatedWhereClause = - // eslint-disable-next-line quotes - `WHERE updated_at < $1 OR (updated_at < $2 AND make = $3)`; +const outdatedWhereClauses = [ + `updated_at < $1`, + `(updated_at < $2 AND make = $3)`, +]; -const outdatedValues = [ +const outdatedWhereValues = [ UPDATED_BEFORE_01.toISOString(), UPDATED_BEFORE_02.toISOString(), MAKE_FUJIFILM, ]; -export const getOutdatedPhotos = () => safelyQueryPhotos( +const needsAiTextWhereClauses = + AI_TEXT_GENERATION_ENABLED + ? AI_TEXT_AUTO_GENERATED_FIELDS + .map(field => { + switch (field) { + case 'title': return `(title <> '') IS NOT TRUE`; + case 'caption': return `(caption <> '') IS NOT TRUE`; + case 'tags': return `(tags IS NULL OR array_length(tags, 1) = 0)`; + case 'semantic': return `(semantic_description <> '') IS NOT TRUE`; + } + }) + : []; + +const needsSyncWhereStatement = + `WHERE ${outdatedWhereClauses.concat(needsAiTextWhereClauses).join(' OR ')}`; + +export const getPhotosInNeedOfSync = () => safelyQueryPhotos( () => query(` SELECT * FROM photos - ${outdatedWhereClause} + ${needsSyncWhereStatement} ORDER BY created_at DESC - LIMIT 1000 + LIMIT ${SYNC_QUERY_LIMIT} `, - outdatedValues, + outdatedWhereValues, ) .then(({ rows }) => rows.map(parsePhotoFromDb)), - 'getOutdatedPhotos', + 'getPhotosInNeedOfSync', ); -export const getOutdatedPhotosCount = () => safelyQueryPhotos( +export const getPhotosInNeedOfSyncCount = () => safelyQueryPhotos( () => query(` SELECT COUNT(*) FROM photos - ${outdatedWhereClause} + ${needsSyncWhereStatement} `, - outdatedValues, + outdatedWhereValues, ) .then(({ rows }) => parseInt(rows[0].count, 10)), - 'getOutdatedPhotosCount', + 'getPhotosInNeedOfSyncCount', ); diff --git a/src/photo/index.ts b/src/photo/index.ts index 94bb6162..a93c3675 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -23,6 +23,7 @@ import { isBefore } from 'date-fns'; import type { Metadata } from 'next'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; +import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync'; // INFINITE SCROLL: FEED export const INFINITE_SCROLL_FEED_INITIAL = @@ -96,7 +97,7 @@ export interface PhotoDb extends updatedAt: Date createdAt: Date takenAt: Date - tags: string[] + tags: string[] | null } // Parsed db response @@ -108,7 +109,9 @@ export interface Photo extends Omit { exposureTimeFormatted?: string exposureCompensationFormatted?: string takenAtNaiveFormatted: string + tags: string[] recipeData?: FujifilmRecipe + syncStatus: PhotoSyncStatus } export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { @@ -130,15 +133,16 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { formatExposureTime(photoDb.exposureTime), exposureCompensationFormatted: formatExposureCompensation(photoDb.exposureCompensation), + takenAtNaiveFormatted: + formatDateFromPostgresString(photoDb.takenAtNaive), recipeData: photoDb.recipeData // Legacy check on escaped, string-based JSON ? typeof photoDb.recipeData === 'string' ? JSON.parse(photoDb.recipeData) : photoDb.recipeData : undefined, - takenAtNaiveFormatted: - formatDateFromPostgresString(photoDb.takenAtNaive), - }; + syncStatus: generatePhotoSyncStatus(photoDb), + } as Photo; }; export const parseCachedPhotoDates = (photo: Photo) => ({ diff --git a/src/photo/outdated.ts b/src/photo/outdated.ts deleted file mode 100644 index 31d1fdfe..00000000 --- a/src/photo/outdated.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; -import { Photo } from '.'; - -export const UPDATED_BEFORE_01 = new Date('2024-06-16'); -// UTC 2025-02-24 05:30:00 -export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0)); - -export const isPhotoOutdated = (photo: Photo) => { - return photo.updatedAt < UPDATED_BEFORE_01 || ( - photo.updatedAt < UPDATED_BEFORE_02 && - photo.make === MAKE_FUJIFILM - ); -}; diff --git a/src/photo/sync.ts b/src/photo/sync.ts new file mode 100644 index 00000000..07aece56 --- /dev/null +++ b/src/photo/sync.ts @@ -0,0 +1,87 @@ +import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; +import { Photo, PhotoDb } from '.'; +import { + AI_TEXT_AUTO_GENERATED_FIELDS, + AI_TEXT_GENERATION_ENABLED, +} from '@/app/config'; +import { AiAutoGeneratedField } from './ai'; + +export interface PhotoSyncStatus { + isOutdated: boolean; + missingAiTextFields: AiAutoGeneratedField[]; +} + +export const SYNC_QUERY_LIMIT = 1000; + +export const UPDATED_BEFORE_01 = new Date('2024-06-16'); +// UTC 2025-02-24 05:30:00 +export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0)); + +const isPhotoOutdated = (photo: PhotoDb) => + photo.updatedAt < UPDATED_BEFORE_01 || ( + photo.updatedAt < UPDATED_BEFORE_02 && + photo.make === MAKE_FUJIFILM + ); + +const getMissingAiTextFields = ({ + title, + caption, + tags, + semanticDescription, +}: PhotoDb | Photo): AiAutoGeneratedField[] => + AI_TEXT_GENERATION_ENABLED + ? AI_TEXT_AUTO_GENERATED_FIELDS.reduce((fields, field) => { + switch (field) { + case 'title': + return !title ? [...fields, 'title'] : fields; + case 'caption': + return !caption ? [...fields, 'caption'] : fields; + case 'tags': + return (tags ?? []).length === 0 ? [...fields, 'tags'] : fields; + case 'semantic': + return !semanticDescription ? [...fields, 'semantic'] : fields; + } + }, [] as AiAutoGeneratedField[]) + : []; + +export const generatePhotoSyncStatus = (photo: PhotoDb): PhotoSyncStatus => ({ + isOutdated: isPhotoOutdated(photo), + missingAiTextFields: getMissingAiTextFields(photo), +}); + +export const photoNeedsToBeSynced = (photo: Photo) => + photo.syncStatus.isOutdated || + photo.syncStatus.missingAiTextFields.length > 0; + +export const getPhotoSyncStatusText = (photo: Photo) => { + const { isOutdated, missingAiTextFields } = photo.syncStatus; + const text: string[] = []; + if (isOutdated) { + text.push('Outdated Data'); + } else if (missingAiTextFields.length > 0) { + const missingFieldsText = missingAiTextFields + .map(field => field.toLocaleUpperCase()) + .join(', '); + text.push(`Missing AI Text (${missingFieldsText})`); + } + return text.join(' and '); +}; + +export const getPhotosSyncStatusText = (photos: Photo[]) => { + const statusText = [] as string[]; + + const photosCountOutdated = photos.filter( + photo => photo.syncStatus.isOutdated, + ).length; + const photosCountMissingAiText = photos.filter( + photo => photo.syncStatus.missingAiTextFields.length > 0, + ).length; + + if (photosCountOutdated > 0) { + statusText.push(`${photosCountOutdated} outdated`); + } + if (photosCountMissingAiText > 0) { + statusText.push(`${photosCountMissingAiText} missing AI text`); + } + return statusText.join(', '); +}; diff --git a/src/platforms/openai.ts b/src/platforms/openai.ts index 23fb55c5..64f1d7a0 100644 --- a/src/platforms/openai.ts +++ b/src/platforms/openai.ts @@ -13,7 +13,6 @@ import { cleanUpAiTextResponse } from '@/photo/ai'; const redis = HAS_REDIS_STORAGE ? Redis.fromEnv() : undefined; const RATE_LIMIT_IDENTIFIER = 'openai-image-query'; -const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100; const MODEL = 'gpt-4o'; const openai = AI_TEXT_GENERATION_ENABLED @@ -21,18 +20,24 @@ const openai = AI_TEXT_GENERATION_ENABLED : undefined; const ratelimit = redis - ? new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'), - }) + ? { + basic: new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(100, '1h'), + }), + batch: new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(1200, '1d'), + }), + } : undefined; -// Allows 100 requests per hour -const checkRateLimitAndThrow = async () => { +const checkRateLimitAndThrow = async (isBatch?: boolean) => { if (ratelimit) { let success = false; try { - success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success; + const limiter = isBatch ? ratelimit.batch : ratelimit.basic; + success = (await limiter.limit(RATE_LIMIT_IDENTIFIER)).success; } catch (e: any) { console.error('Failed to rate limit OpenAI', e); throw new Error('Failed to rate limit OpenAI'); @@ -92,8 +97,9 @@ export const streamOpenAiImageQuery = async ( export const generateOpenAiImageQuery = async ( imageBase64: string, query: string, + isBatch?: boolean, ) => { - await checkRateLimitAndThrow(); + await checkRateLimitAndThrow(isBatch); const args = getImageTextArgs(imageBase64, query); diff --git a/src/recipe/useRecipeOverlay.ts b/src/recipe/useRecipeOverlay.ts index 55217f51..2074bcb4 100644 --- a/src/recipe/useRecipeOverlay.ts +++ b/src/recipe/useRecipeOverlay.ts @@ -1,6 +1,6 @@ -import { RefObject, useCallback, useEffect, useState } from 'react'; -import { isElementEntirelyInViewport } from '@/utility/dom'; +import { RefObject, useCallback, useMemo, useState } from 'react'; import useClickInsideOutside from '@/utility/useClickInsideOutside'; +import useScrollIntoView from '@/utility/useScrollIntoView'; export default function useRecipeOverlay({ ref, @@ -19,16 +19,18 @@ export default function useRecipeOverlay({ setIsShowingRecipeOverlay(current => !current), []); + const htmlElements = useMemo(() => + [ref, ...refTriggers], [ref, refTriggers]); + useClickInsideOutside({ - htmlElements: [ref, ...refTriggers], + htmlElements, onClickOutside: hideRecipeOverlay, }); - useEffect(() => { - if (isShowingRecipeOverlay && !isElementEntirelyInViewport(ref?.current)) { - ref?.current?.scrollIntoView({ behavior: 'smooth' }); - } - }, [ref, isShowingRecipeOverlay]); + useScrollIntoView({ + ref, + shouldScrollIntoView: isShowingRecipeOverlay, + }); return { isShowingRecipeOverlay, diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index d5b3290a..832d1324 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -151,7 +151,7 @@ export default function AppStateProvider({ if (userEmail) { storeAuthEmailCookie(userEmail); } - }, [userEmail, adminData]); + }, [userEmail]); const registerAdminUpdate = useCallback(() => setAdminUpdateTimes(updates => [...updates, new Date()]) diff --git a/src/utility/string.ts b/src/utility/string.ts index b733b6c2..71b3b633 100644 --- a/src/utility/string.ts +++ b/src/utility/string.ts @@ -22,11 +22,11 @@ export const parameterize = ( ) => string .trim() - // Replaces spaces, underscores, slashes,and dashes with dashes - .replaceAll(/[\s_–—]/gi, '-') - // Removes punctuation - .replaceAll(/['"!@#$%^&*()_+=[\]{};:/?,<>\\/|`~]/gi, '') - // Removes all non-alphanumeric characters + // Replace spaces, underscores, slashes, pluses, dashes with dashes + .replaceAll(/[\s_–—+]/gi, '-') + // Remove punctuation + .replaceAll(/['"!@#$%^&*()=[\]{};:/?,<>\\/|`~]/gi, '') + // Removes non-alphanumeric characters, if configured .replaceAll( shouldRemoveNonAlphanumeric ? /([^a-z0-9-])/gi diff --git a/src/utility/useScrollIntoView.ts b/src/utility/useScrollIntoView.ts new file mode 100644 index 00000000..fbd5ff75 --- /dev/null +++ b/src/utility/useScrollIntoView.ts @@ -0,0 +1,20 @@ +import { RefObject, useEffect } from 'react'; +import { isElementEntirelyInViewport } from '@/utility/dom'; + +export default function useScrollIntoView({ + ref, + shouldScrollIntoView, +}: { + ref?: RefObject + shouldScrollIntoView?: boolean +}) { + useEffect(() => { + if ( + ref?.current && + !isElementEntirelyInViewport(ref.current) && + shouldScrollIntoView + ) { + ref.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [ref, shouldScrollIntoView]); +} diff --git a/src/utility/useVisualViewport.ts b/src/utility/useVisualViewport.ts index 1b1d42de..bed303a0 100644 --- a/src/utility/useVisualViewport.ts +++ b/src/utility/useVisualViewport.ts @@ -1,6 +1,4 @@ -import { useState } from 'react'; - -import { useEffect } from 'react'; +import { useState, useEffect } from 'react'; export default function useVisualViewportHeight() { const [viewportHeight, setViewportHeight] = useState();