Merge pull request #242 from sambecker/ai-autofill

Batched generation of missing AI fields
This commit is contained in:
Sam Becker 2025-04-20 23:03:14 -05:00 committed by GitHub
commit db14acabdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 823 additions and 532 deletions

View File

@ -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. > 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? #### 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? #### 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. > 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? #### 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. > 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? #### 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 youd like to contribute an implementation for a new storage provider, please open a PR. > 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 youd like to contribute an implementation for a new storage provider, please open a PR.

View File

@ -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 (
<AdminOutdatedClient {...{
photos,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
}} />
);
}

View File

@ -1,11 +1,10 @@
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache'; 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 { getPhotosMetaCached } from '@/photo/cache';
import AdminPhotosClient from '@/admin/AdminPhotosClient'; import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { import {
AI_TEXT_GENERATION_ENABLED, AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS, PRESERVE_ORIGINAL_UPLOADS,
@ -24,7 +23,7 @@ export default async function AdminPhotosPage() {
const [ const [
photos, photos,
photosCount, photosCount,
photosCountOutdated, photosCountNeedsSync,
blobPhotoUrls, blobPhotoUrls,
] = await Promise.all([ ] = await Promise.all([
getPhotos({ getPhotos({
@ -35,7 +34,7 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'}) getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
getOutdatedPhotosCount() getPhotosInNeedOfSyncCount()
.catch(() => 0), .catch(() => 0),
DEBUG_PHOTO_BLOBS DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore() ? getStoragePhotoUrlsNoStore()
@ -46,7 +45,7 @@ export default async function AdminPhotosPage() {
<AdminPhotosClient {...{ <AdminPhotosClient {...{
photos, photos,
photosCount, photosCount,
photosCountOutdated, photosCountNeedsSync,
shouldResize: !PRESERVE_ORIGINAL_UPLOADS, shouldResize: !PRESERVE_ORIGINAL_UPLOADS,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED, hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
onLastUpload: async () => { onLastUpload: async () => {

View File

@ -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 (
<AdminPhotosSyncClient {...{
photos,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
}} />
);
}

View File

@ -12,10 +12,10 @@
"@ai-sdk/openai": "^1.3.16", "@ai-sdk/openai": "^1.3.16",
"@aws-sdk/client-s3": "3.787.0", "@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0", "@aws-sdk/s3-request-presigner": "3.787.0",
"@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.3",
"@radix-ui/react-visually-hidden": "^1.1.3", "@radix-ui/react-visually-hidden": "^1.2.0",
"@upstash/ratelimit": "^2.0.5", "@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.34.8", "@upstash/redis": "^1.34.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
@ -62,7 +62,7 @@
"@types/react-dom": "19.1.2", "@types/react-dom": "19.1.2",
"@types/sanitize-html": "^2.15.0", "@types/sanitize-html": "^2.15.0",
"cross-fetch": "^4.1.0", "cross-fetch": "^4.1.0",
"eslint": "9.24.0", "eslint": "9.25.0",
"eslint-config-next": "15.3.1", "eslint-config-next": "15.3.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jest": "^29.7.0", "jest": "^29.7.0",

315
pnpm-lock.yaml generated
View File

@ -18,17 +18,17 @@ importers:
specifier: 3.787.0 specifier: 3.787.0
version: 3.787.0 version: 3.787.0
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.7 specifier: ^1.1.10
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) 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': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.7 specifier: ^2.1.11
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) 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': '@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 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) 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': '@upstash/ratelimit':
specifier: ^2.0.5 specifier: ^2.0.5
version: 2.0.5(@upstash/redis@1.34.8) version: 2.0.5(@upstash/redis@1.34.8)
@ -163,14 +163,14 @@ importers:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
eslint: eslint:
specifier: 9.24.0 specifier: 9.25.0
version: 9.24.0(jiti@2.4.2) version: 9.25.0(jiti@2.4.2)
eslint-config-next: eslint-config-next:
specifier: 15.3.1 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: eslint-plugin-react-hooks:
specifier: ^5.2.0 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: jest:
specifier: ^29.7.0 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)) 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==} resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.2.0': '@eslint/config-helpers@0.2.1':
resolution: {integrity: sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==} resolution: {integrity: sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@0.12.0': '@eslint/core@0.13.0':
resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.1': '@eslint/eslintrc@3.3.1':
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.24.0': '@eslint/js@9.25.0':
resolution: {integrity: sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==} resolution: {integrity: sha512-iWhsUS8Wgxz9AXNfvfOPFSW4VfMXdVhp1hjkZVhXCrpgh/aLcc45rX6MPu+tIVUWDw0HfNwth7O28M1xDxNf9w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6': '@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.2.7': '@eslint/plugin-kit@0.2.8':
resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fastify/busboy@2.1.1': '@fastify/busboy@2.1.1':
@ -952,8 +952,8 @@ packages:
'@radix-ui/primitive@1.1.2': '@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/react-arrow@1.1.3': '@radix-ui/react-arrow@1.1.4':
resolution: {integrity: sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==} resolution: {integrity: sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -965,8 +965,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-collection@1.1.3': '@radix-ui/react-collection@1.1.4':
resolution: {integrity: sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==} resolution: {integrity: sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1005,8 +1005,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-dialog@1.1.7': '@radix-ui/react-dialog@1.1.10':
resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} resolution: {integrity: sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1027,8 +1027,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-dismissable-layer@1.1.6': '@radix-ui/react-dismissable-layer@1.1.7':
resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} resolution: {integrity: sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1040,8 +1040,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-dropdown-menu@2.1.7': '@radix-ui/react-dropdown-menu@2.1.11':
resolution: {integrity: sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==} resolution: {integrity: sha512-wbPE3cFBfLl+S+LCxChWQGX0k14zUxgvep1HEnLhJ9mNhjyO3ETzRviAeKZ3XomT/iVRRZAWFsnFZ3N0wI8OmA==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1062,8 +1062,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-focus-scope@1.1.3': '@radix-ui/react-focus-scope@1.1.4':
resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} resolution: {integrity: sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1093,8 +1093,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-menu@2.1.7': '@radix-ui/react-menu@2.1.11':
resolution: {integrity: sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==} resolution: {integrity: sha512-sbFI4Qaw02J0ogmR9tOMsSqsdrGNpUanlPYAqTE2JJafow8ecHtykg4fSTjNHBdDl4deiKMK+RhTEwyVhP7UDA==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1106,8 +1106,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-popper@1.2.3': '@radix-ui/react-popper@1.2.4':
resolution: {integrity: sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==} resolution: {integrity: sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1119,8 +1119,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-portal@1.1.5': '@radix-ui/react-portal@1.1.6':
resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} resolution: {integrity: sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1158,8 +1158,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-primitive@2.0.3': '@radix-ui/react-primitive@2.1.0':
resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1171,8 +1171,8 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-roving-focus@1.1.3': '@radix-ui/react-roving-focus@1.1.7':
resolution: {integrity: sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==} resolution: {integrity: sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1202,8 +1202,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-tooltip@1.2.0': '@radix-ui/react-tooltip@1.2.3':
resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} resolution: {integrity: sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -1224,8 +1224,17 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-controllable-state@1.1.1': '@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} 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: peerDependencies:
'@types/react': '*' '@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
@ -1278,8 +1287,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-visually-hidden@1.1.3': '@radix-ui/react-visually-hidden@1.2.0':
resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} resolution: {integrity: sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==}
peerDependencies: peerDependencies:
'@types/react': '*' '@types/react': '*'
'@types/react-dom': '*' '@types/react-dom': '*'
@ -2489,8 +2498,8 @@ packages:
resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@9.24.0: eslint@9.25.0:
resolution: {integrity: sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==} resolution: {integrity: sha512-MsBdObhM4cEwkzCiraDv7A6txFXEqtNXOb877TsSp2FCkBNl8JfVQrmiuDqC1IkejT6JLPzYBXx/xAiYhyzgGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -5077,9 +5086,9 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true 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: dependencies:
eslint: 9.24.0(jiti@2.4.2) eslint: 9.25.0(jiti@2.4.2)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {} '@eslint-community/regexpp@4.12.1': {}
@ -5092,9 +5101,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
@ -5112,13 +5121,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/js@9.24.0': {} '@eslint/js@9.25.0': {}
'@eslint/object-schema@2.1.6': {} '@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.2.7': '@eslint/plugin-kit@0.2.8':
dependencies: dependencies:
'@eslint/core': 0.12.0 '@eslint/core': 0.13.0
levn: 0.4.1 levn: 0.4.1
'@fastify/busboy@2.1.1': {} '@fastify/busboy@2.1.1': {}
@ -5484,20 +5493,20 @@ snapshots:
'@radix-ui/primitive@1.1.2': {} '@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: 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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@types/react': 19.1.2
'@types/react-dom': 19.1.2(@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(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-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) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
@ -5523,20 +5532,20 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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-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 aria-hidden: 1.2.4
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
@ -5551,11 +5560,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.2)(react@19.1.0)
react: 19.1.0 react: 19.1.0
@ -5564,15 +5573,15 @@ snapshots:
'@types/react': 19.1.2 '@types/react': 19.1.2
'@types/react-dom': 19.1.2(@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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.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-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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
@ -5585,10 +5594,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@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-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
@ -5610,22 +5619,22 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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-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.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-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-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)
'@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(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) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0)
aria-hidden: 1.2.4 aria-hidden: 1.2.4
@ -5636,13 +5645,13 @@ snapshots:
'@types/react': 19.1.2 '@types/react': 19.1.2
'@types/react-dom': 19.1.2(@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: dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@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-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-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-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-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) '@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': 19.1.2
'@types/react-dom': 19.1.2(@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: 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) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0)
react: 19.1.0 react: 19.1.0
react-dom: 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': 19.1.2
'@types/react-dom': 19.1.2(@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: dependencies:
'@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(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: 19.1.0
@ -5692,17 +5701,17 @@ snapshots:
'@types/react': 19.1.2 '@types/react': 19.1.2
'@types/react-dom': 19.1.2(@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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-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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
@ -5723,20 +5732,20 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: dependencies:
'@radix-ui/primitive': 1.1.2 '@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-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-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-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-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.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-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-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)
'@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)
react: 19.1.0 react: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
@ -5749,9 +5758,17 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: 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 react: 19.1.0
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@types/react': 19.1.2
@ -5789,9 +5806,9 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.1.2 '@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: 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: 19.1.0
react-dom: 19.1.0(react@19.1.0) react-dom: 19.1.0(react@19.1.0)
optionalDependencies: optionalDependencies:
@ -6357,15 +6374,15 @@ snapshots:
dependencies: dependencies:
'@types/yargs-parser': 21.0.3 '@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: dependencies:
'@eslint-community/regexpp': 4.12.1 '@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/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/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.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)
'@typescript-eslint/visitor-keys': 8.24.1 '@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 graphemer: 1.4.0
ignore: 5.3.2 ignore: 5.3.2
natural-compare: 1.4.0 natural-compare: 1.4.0
@ -6374,14 +6391,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@typescript-eslint/scope-manager': 8.24.1 '@typescript-eslint/scope-manager': 8.24.1
'@typescript-eslint/types': 8.24.1 '@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.24.1 '@typescript-eslint/visitor-keys': 8.24.1
debug: 4.4.0 debug: 4.4.0
eslint: 9.24.0(jiti@2.4.2) eslint: 9.25.0(jiti@2.4.2)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -6391,12 +6408,12 @@ snapshots:
'@typescript-eslint/types': 8.24.1 '@typescript-eslint/types': 8.24.1
'@typescript-eslint/visitor-keys': 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: dependencies:
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) '@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 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) ts-api-utils: 2.0.1(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
@ -6418,13 +6435,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: 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/scope-manager': 8.24.1
'@typescript-eslint/types': 8.24.1 '@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) '@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 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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): 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: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@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-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) '@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 react: 19.1.0
@ -7109,19 +7126,19 @@ snapshots:
optionalDependencies: optionalDependencies:
source-map: 0.6.1 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: dependencies:
'@next/eslint-plugin-next': 15.3.1 '@next/eslint-plugin-next': 15.3.1
'@rushstack/eslint-patch': 1.10.5 '@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/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.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)
eslint: 9.24.0(jiti@2.4.2) eslint: 9.25.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9 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))
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))
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))
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))
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))
optionalDependencies: optionalDependencies:
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
@ -7137,33 +7154,33 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.0 debug: 4.4.0
enhanced-resolve: 5.18.1 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 get-tsconfig: 4.10.0
is-bun-module: 1.3.0 is-bun-module: 1.3.0
stable-hash: 0.0.4 stable-hash: 0.0.4
tinyglobby: 0.2.11 tinyglobby: 0.2.11
optionalDependencies: 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: 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)
eslint: 9.24.0(jiti@2.4.2) eslint: 9.25.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9 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: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.8 array-includes: 3.1.8
@ -7172,9 +7189,9 @@ snapshots:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
debug: 3.2.7 debug: 3.2.7
doctrine: 2.1.0 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-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 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@ -7186,13 +7203,13 @@ snapshots:
string.prototype.trimend: 1.0.9 string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: 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: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - 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: dependencies:
aria-query: 5.3.2 aria-query: 5.3.2
array-includes: 3.1.8 array-includes: 3.1.8
@ -7202,7 +7219,7 @@ snapshots:
axobject-query: 4.1.0 axobject-query: 4.1.0
damerau-levenshtein: 1.0.8 damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2 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 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
language-tags: 1.0.9 language-tags: 1.0.9
@ -7211,11 +7228,11 @@ snapshots:
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1 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: 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: dependencies:
array-includes: 3.1.8 array-includes: 3.1.8
array.prototype.findlast: 1.2.5 array.prototype.findlast: 1.2.5
@ -7223,7 +7240,7 @@ snapshots:
array.prototype.tosorted: 1.1.4 array.prototype.tosorted: 1.1.4
doctrine: 2.1.0 doctrine: 2.1.0
es-iterator-helpers: 1.2.1 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 estraverse: 5.3.0
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
@ -7246,16 +7263,16 @@ snapshots:
eslint-visitor-keys@4.2.0: {} eslint-visitor-keys@4.2.0: {}
eslint@9.24.0(jiti@2.4.2): eslint@9.25.0(jiti@2.4.2):
dependencies: 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-community/regexpp': 4.12.1
'@eslint/config-array': 0.20.0 '@eslint/config-array': 0.20.0
'@eslint/config-helpers': 0.2.0 '@eslint/config-helpers': 0.2.1
'@eslint/core': 0.12.0 '@eslint/core': 0.13.0
'@eslint/eslintrc': 3.3.1 '@eslint/eslintrc': 3.3.1
'@eslint/js': 9.24.0 '@eslint/js': 9.25.0
'@eslint/plugin-kit': 0.2.7 '@eslint/plugin-kit': 0.2.8
'@humanfs/node': 0.16.6 '@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.2 '@humanwhocodes/retry': 0.4.2

View File

@ -30,6 +30,7 @@ import AdminLink from './AdminLink';
import ScoreCardContainer from '@/components/ScoreCardContainer'; import ScoreCardContainer from '@/components/ScoreCardContainer';
import { capitalize, deparameterize } from '@/utility/string'; import { capitalize, deparameterize } from '@/utility/string';
import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category';
import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai';
export default function AdminAppConfigurationClient({ export default function AdminAppConfigurationClient({
// Storage // Storage
@ -365,6 +366,28 @@ export default function AdminAppConfigurationClient({
and improve accessibility: and improve accessibility:
{renderEnvVars(['OPENAI_SECRET_KEY'])} {renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow
title={'Auto-generated fields'}
status={hasAiTextAutoGeneratedFields}
optional
>
{hasAiTextAutoGeneratedFields &&
AI_AUTO_GENERATED_FIELDS_ALL.map(field =>
<Fragment key={field}>
{renderSubStatus(
aiTextAutoGeneratedFields.includes(field)
? 'checked'
: 'optional',
capitalize(field),
)}
</Fragment>)}
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'])}
</ChecklistRow>
<ChecklistRow <ChecklistRow
title={hasRedisStorage && isAnalyzingConfiguration title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection' ? 'Testing Redis connection'
@ -380,19 +403,6 @@ export default function AdminAppConfigurationClient({
on Vercel dashboard and connect to this project on Vercel dashboard and connect to this project
to enable rate limiting to enable rate limiting
</ChecklistRow> </ChecklistRow>
<ChecklistRow
// eslint-disable-next-line max-len
title={`Auto-generated fields: ${aiTextAutoGeneratedFields.join(',')}`}
status={hasAiTextAutoGeneratedFields}
optional
>
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'])}
</ChecklistRow>
</ChecklistGroup> </ChecklistGroup>
<ChecklistGroup <ChecklistGroup
title="Performance" title="Performance"

View File

@ -5,6 +5,7 @@ import {
PATH_ADMIN_CONFIGURATION, PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS, PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_PHOTOS_UPDATES,
PATH_ADMIN_RECIPES, PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
@ -26,6 +27,8 @@ import IconSignOut from '@/components/icons/IconSignOut';
import IconLock from '@/components/icons/IconLock'; import IconLock from '@/components/icons/IconLock';
import { IoMdCheckboxOutline } from 'react-icons/io'; import { IoMdCheckboxOutline } from 'react-icons/io';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import IconBroom from '@/components/icons/IconBroom';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
export default function AdminAppMenu({ export default function AdminAppMenu({
active, active,
@ -38,6 +41,7 @@ export default function AdminAppMenu({
}) { }) {
const { const {
photosCountTotal = 0, photosCountTotal = 0,
photosCountNeedSync = 0,
uploadsCount = 0, uploadsCount = 0,
tagsCount = 0, tagsCount = 0,
recipesCount = 0, recipesCount = 0,
@ -71,11 +75,30 @@ export default function AdminAppMenu({
annotation: `${uploadsCount}`, annotation: `${uploadsCount}`,
icon: <IconFolder icon: <IconFolder
size={16} size={16}
className="translate-y-[0.5px]" className="translate-x-[1px] translate-y-[0.5px]"
/>, />,
href: PATH_ADMIN_UPLOADS, href: PATH_ADMIN_UPLOADS,
}); });
} }
if (photosCountNeedSync) {
items.push({
label: 'Updates',
annotation: <>
<span className="mr-3">
{photosCountNeedSync}
</span>
<InsightsIndicatorDot
className="inline-block translate-y-[-0.5px]"
size="small"
/>
</>,
icon: <IconBroom
size={17}
className="translate-y-[0.5px]"
/>,
href: PATH_ADMIN_PHOTOS_UPDATES,
});
}
if (photosCountTotal) { if (photosCountTotal) {
items.push({ items.push({
label: 'Manage Photos', label: 'Manage Photos',
@ -121,7 +144,10 @@ export default function AdminAppMenu({
size={18} size={18}
className="translate-x-[-1px] translate-y-[0.5px]" className="translate-x-[-1px] translate-y-[0.5px]"
/> />
: <IoMdCheckboxOutline size={17} className="translate-x-[-0.5px]" />, : <IoMdCheckboxOutline
size={16}
className="translate-x-[-0.5px]"
/>,
href: PATH_GRID_INFERRED, href: PATH_GRID_INFERRED,
action: () => { action: () => {
if (isSelecting) { if (isSelecting) {

View File

@ -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<string[]>([]);
const arePhotoIdsSyncing = photoIdsSyncing.length > 0;
const router = useRouter();
return (
<AdminChildPage
backLabel="Photos"
backPath={PATH_ADMIN_PHOTOS}
breadcrumb={<>
<span className="hidden sm:inline-block">
Outdated ({photos.length})
</span>
<span className="sm:hidden">
Outdated
</span>
</>}
accessory={<LoaderButton
primary
icon={<IconGrSync className="translate-y-[1px]" />}
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'
: <ResponsiveText shortText={`Sync Next ${updateBatchSize}`}>
Sync Next {updateBatchSize} Photos
</ResponsiveText>}
</LoaderButton>}
>
<div className="space-y-6">
<Note
color="blue"
icon={<LiaBroomSolid size={18}/>}
>
<div className="space-y-1.5">
<div className="font-bold">
{photos.length} outdated
{' '}
{photos.length === 1 ? 'photo' : 'photos'} found
</div>
Sync photos to import newer EXIF fields, improve blur data,
{' '}
and leverage AI-generated text where possible
</div>
</Note>
<div className="space-y-4">
<AdminPhotosTable
photos={photos}
photoIdsSyncing={photoIdsSyncing}
hasAiTextGeneration={hasAiTextGeneration}
canEdit={false}
canDelete={false}
showUpdatedAt
/>
</div>
</div>
</AdminChildPage>
);
}

View File

@ -21,10 +21,10 @@ import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md'; import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem'; import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/components/icons/IconGrSync'; import IconGrSync from '@/components/icons/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit'; import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync';
export default function AdminPhotoMenu({ export default function AdminPhotoMenu({
photo, photo,
@ -79,10 +79,11 @@ export default function AdminPhotoMenu({
label: 'Sync', label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2"> labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span> <span>Sync</span>
{isPhotoOutdated(photo) && {photoNeedsToBeSynced(photo) &&
<InsightsIndicatorDot <InsightsIndicatorDot
colorOverride="blue" colorOverride="blue"
className="translate-y-[1.5px]" className="ml-1 translate-y-[1.5px]"
size="small"
/>} />}
</span>, </span>,
icon: <IconGrSync className="translate-x-[-1px]" />, icon: <IconGrSync className="translate-x-[-1px]" />,

View File

@ -5,19 +5,21 @@ import AppGrid from '@/components/AppGrid';
import AdminPhotosTable from '@/admin/AdminPhotosTable'; import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite'; import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
import PathLoaderButton from '@/components/primitives/PathLoaderButton'; 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 { Photo } from '@/photo';
import { StorageListResponse } from '@/platforms/storage'; import { StorageListResponse } from '@/platforms/storage';
import { LiaBroomSolid } from 'react-icons/lia';
import AdminUploadsTable from './AdminUploadsTable'; import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone'; import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; 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({ export default function AdminPhotosClient({
photos, photos,
photosCount, photosCount,
photosCountOutdated, photosCountNeedsSync,
blobPhotoUrls, blobPhotoUrls,
shouldResize, shouldResize,
hasAiTextGeneration, hasAiTextGeneration,
@ -28,7 +30,7 @@ export default function AdminPhotosClient({
}: { }: {
photos: Photo[] photos: Photo[]
photosCount: number photosCount: number
photosCountOutdated: number photosCountNeedsSync: number
blobPhotoUrls: StorageListResponse blobPhotoUrls: StorageListResponse
shouldResize: boolean shouldResize: boolean
hasAiTextGeneration: boolean hasAiTextGeneration: boolean
@ -51,14 +53,17 @@ export default function AdminPhotosClient({
onLastUpload={onLastUpload} onLastUpload={onLastUpload}
/> />
</div> </div>
{photosCountOutdated > 0 && {photosCountNeedsSync > 0 &&
<PathLoaderButton <PathLoaderButton
path={PATH_ADMIN_OUTDATED} path={PATH_ADMIN_PHOTOS_UPDATES}
icon={<LiaBroomSolid icon={<IconBroom
size={18} size={18}
className="translate-y-[-1px]" className="translate-x-[-1px]"
/>} />}
title={`${photosCountOutdated} Outdated Photos`} tooltip={(
pluralize(photosCountNeedsSync, 'photo') +
' missing data or AI-generated text'
)}
className={clsx( className={clsx(
'text-blue-600 dark:text-blue-400', 'text-blue-600 dark:text-blue-400',
'border border-blue-200 dark:border-blue-800/60', '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" spinnerClassName="text-blue-200 dark:text-blue-600/40"
hideTextOnMobile={false} hideTextOnMobile={false}
> >
{photosCountOutdated} <ResponsiveText shortText={photosCountNeedsSync}>
{pluralize(photosCountNeedsSync, 'Update')}
</ResponsiveText>
</PathLoaderButton>} </PathLoaderButton>}
</div> </div>
{blobPhotoUrls.length > 0 && {blobPhotoUrls.length > 0 &&

View File

@ -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<Error>(undefined);
// Use state for updating progress button and error UI
const [photoIdsSyncing, setPhotoIdsSyncing] = useState<string[]>([]);
const [error, setError] = useState<Error>();
const [progress, setProgress] = useState(0);
const arePhotoIdsSyncing = photoIdsSyncing.length > 0;
const router = useRouter();
const statusText = useMemo(() => getPhotosSyncStatusText(photos), [photos]);
return (
<AdminChildPage
backLabel="Photos"
backPath={PATH_ADMIN_PHOTOS}
breadcrumb={<ResponsiveText shortText="Updates">
Updates ({photos.length})
</ResponsiveText>}
accessory={<ProgressButton
primary
icon={<IconGrSync className="translate-y-[1px]" />}
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'}
</ProgressButton>}
>
<div className="space-y-6">
{error && <ErrorNote>
<span className="font-bold">
Issue syncing:
</span>
{' '}
{error.message}
</ErrorNote>}
<Note
color="blue"
icon={<LiaBroomSolid size={18}/>}
>
<div className="space-y-1.5">
<div className="font-bold">
Photo updates: {statusText}
</div>
Sync to capture new EXIF fields, improve blur data,
{' '}
use AI to generate missing text (if configured)
</div>
</Note>
<div className="space-y-4">
<AdminPhotosTable
photos={photos}
photoIdsSyncing={photoIdsSyncing}
hasAiTextGeneration={hasAiTextGeneration}
canEdit={false}
canDelete={false}
dateType="updatedAt"
shouldScrollIntoViewOnExternalSync
/>
</div>
</div>
</AdminChildPage>
);
}

View File

@ -15,6 +15,8 @@ import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton'; import DeletePhotoButton from './DeletePhotoButton';
import { Timezone } from '@/utility/timezone'; import { Timezone } from '@/utility/timezone';
import IconHidden from '@/components/icons/IconHidden'; import IconHidden from '@/components/icons/IconHidden';
import Tooltip from '@/components/Tooltip';
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
export default function AdminPhotosTable({ export default function AdminPhotosTable({
photos, photos,
@ -22,20 +24,22 @@ export default function AdminPhotosTable({
revalidatePhoto, revalidatePhoto,
photoIdsSyncing = [], photoIdsSyncing = [],
hasAiTextGeneration, hasAiTextGeneration,
showUpdatedAt, dateType = 'createdAt',
canEdit = true, canEdit = true,
canDelete = true, canDelete = true,
timezone, timezone,
shouldScrollIntoViewOnExternalSync,
}: { }: {
photos: Photo[], photos: Photo[],
onLastPhotoVisible?: () => void onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto revalidatePhoto?: RevalidatePhoto
photoIdsSyncing?: string[] photoIdsSyncing?: string[]
hasAiTextGeneration: boolean hasAiTextGeneration: boolean
showUpdatedAt?: boolean dateType?: 'createdAt' | 'updatedAt'
canEdit?: boolean canEdit?: boolean
canDelete?: boolean canDelete?: boolean
timezone?: Timezone timezone?: Timezone
shouldScrollIntoViewOnExternalSync?: boolean
}) { }) {
const { invalidateSwr } = useAppState(); const { invalidateSwr } = useAppState();
@ -68,7 +72,7 @@ export default function AdminPhotosTable({
<span className={clsx( <span className={clsx(
photo.hidden && 'text-dim', photo.hidden && 'text-dim',
)}> )}>
{titleForPhoto(photo)} {titleForPhoto(photo, false)}
{photo.hidden && <span className="whitespace-nowrap"> {photo.hidden && <span className="whitespace-nowrap">
{' '} {' '}
<IconHidden <IconHidden
@ -90,11 +94,18 @@ export default function AdminPhotosTable({
'lg:w-[50%] uppercase', 'lg:w-[50%] uppercase',
'text-dim', 'text-dim',
)}> )}>
<PhotoDate {...{ {<>
photo, <PhotoDate {...{ photo, dateType, timezone }} />
dateType: showUpdatedAt ? 'updatedAt' : 'createdAt', {photoNeedsToBeSynced(photo) &&
timezone, <Tooltip
}} /> content={getPhotoSyncStatusText(photo)}
classNameTrigger={clsx(
'translate-y-1 ml-1.5',
'text-blue-600 dark:text-blue-400',
)}
supportMobile
/>}
</>}
</div> </div>
</div> </div>
<div className={clsx( <div className={clsx(
@ -113,6 +124,8 @@ export default function AdminPhotosTable({
className={opacityForPhotoId(photo.id)} className={opacityForPhotoId(photo.id)}
shouldConfirm shouldConfirm
shouldToast shouldToast
shouldScrollIntoViewOnExternalSync={
shouldScrollIntoViewOnExternalSync}
/> />
{canDelete && {canDelete &&
<DeletePhotoButton <DeletePhotoButton

View File

@ -2,8 +2,10 @@ import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions'; import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/components/icons/IconGrSync'; import IconGrSync from '@/components/icons/IconGrSync';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import { ComponentProps, useState } from 'react'; import { ComponentProps, useRef, useState } from 'react';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import clsx from 'clsx/lite';
import useScrollIntoView from '@/utility/useScrollIntoView';
export default function PhotoSyncButton({ export default function PhotoSyncButton({
photoId, photoId,
@ -15,6 +17,7 @@ export default function PhotoSyncButton({
disabled, disabled,
shouldConfirm, shouldConfirm,
shouldToast, shouldToast,
shouldScrollIntoViewOnExternalSync,
}: { }: {
photoId: string photoId: string
photoTitle?: string photoTitle?: string
@ -23,7 +26,10 @@ export default function PhotoSyncButton({
hasAiTextGeneration?: boolean hasAiTextGeneration?: boolean
shouldConfirm?: boolean shouldConfirm?: boolean
shouldToast?: boolean shouldToast?: boolean
shouldScrollIntoViewOnExternalSync?: boolean
} & ComponentProps<typeof LoaderButton>) { } & ComponentProps<typeof LoaderButton>) {
const ref = useRef<HTMLButtonElement>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const confirmText = ['Overwrite']; const confirmText = ['Overwrite'];
@ -33,10 +39,18 @@ export default function PhotoSyncButton({
'AI text will be generated for undefined fields.'); } 'AI text will be generated for undefined fields.'); }
confirmText.push('This action cannot be undone.'); confirmText.push('This action cannot be undone.');
useScrollIntoView({
ref,
shouldScrollIntoView:
isSyncingExternal &&
shouldScrollIntoViewOnExternalSync,
});
return ( return (
<Tooltip content="Regenerate photo data"> <Tooltip content="Regenerate photo data">
<LoaderButton <LoaderButton
className={className} ref={ref}
className={clsx('scroll-mt-8', className)}
icon={<IconGrSync icon={<IconGrSync
className="translate-y-[0.5px] translate-x-[0.5px]" className="translate-y-[0.5px] translate-x-[0.5px]"
/>} />}

View File

@ -7,12 +7,16 @@ import { testDatabaseConnection } from '@/platforms/postgres';
import { testStorageConnection } from '@/platforms/storage'; import { testStorageConnection } from '@/platforms/storage';
import { APP_CONFIGURATION } from '@/app/config'; import { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { getInsightsIndicatorStatus } from '@/admin/insights/server';
import { import {
getPhotosMeta, getPhotosMeta,
getUniqueTags, getUniqueTags,
getUniqueRecipes, getUniqueRecipes,
getPhotosInNeedOfSyncCount,
} from '@/photo/db/query'; } from '@/photo/db/query';
import {
getGitHubMetaForCurrentApp,
indicatorStatusForSignificantInsights,
} from './insights';
export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>; export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>;
@ -21,10 +25,11 @@ export const getAdminDataAction = async () =>
const [ const [
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountNeedSync,
codeMeta,
uploadsCount, uploadsCount,
tagsCount, tagsCount,
recipesCount, recipesCount,
insightsIndicatorStatus,
] = await Promise.all([ ] = await Promise.all([
getPhotosMeta() getPhotosMeta()
.then(({ count }) => count) .then(({ count }) => count)
@ -32,6 +37,8 @@ export const getAdminDataAction = async () =>
getPhotosMeta({ hidden: 'only' }) getPhotosMeta({ hidden: 'only' })
.then(({ count }) => count) .then(({ count }) => count)
.catch(() => 0), .catch(() => 0),
getPhotosInNeedOfSyncCount(),
getGitHubMetaForCurrentApp(),
getStorageUploadUrlsNoStore() getStorageUploadUrlsNoStore()
.then(urls => urls.length) .then(urls => urls.length)
.catch(e => { .catch(e => {
@ -44,9 +51,13 @@ export const getAdminDataAction = async () =>
getUniqueRecipes() getUniqueRecipes()
.then(recipes => recipes.length) .then(recipes => recipes.length)
.catch(() => 0), .catch(() => 0),
getInsightsIndicatorStatus(),
]); ]);
const insightsIndicatorStatus = indicatorStatusForSignificantInsights({
codeMeta,
photosCountNeedSync,
});
const photosCountTotal = ( const photosCountTotal = (
photosCount !== undefined && photosCount !== undefined &&
photosCountHidden !== undefined photosCountHidden !== undefined
@ -57,12 +68,13 @@ export const getAdminDataAction = async () =>
return { return {
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountNeedSync,
photosCountTotal, photosCountTotal,
uploadsCount, uploadsCount,
tagsCount, tagsCount,
recipesCount, recipesCount,
insightsIndicatorStatus, insightsIndicatorStatus,
}; } as const;
}); });
const scanForError = ( const scanForError = (

View File

@ -9,13 +9,13 @@ import {
} from '@/photo/db/query'; } from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient'; import AdminAppInsightsClient from './AdminAppInsightsClient';
import { getAllInsights, getGitHubMetaForCurrentApp } from '.'; import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query'; import { getPhotosInNeedOfSyncCount } from '@/photo/db/query';
export default async function AdminAppInsights() { export default async function AdminAppInsights() {
const [ const [
{ count: photosCount, dateRange }, { count: photosCount, dateRange },
{ count: photosCountHidden }, { count: photosCountHidden },
photosCountOutdated, photosCountNeedSync,
{ count: photosCountPortrait }, { count: photosCountPortrait },
codeMeta, codeMeta,
cameras, cameras,
@ -27,7 +27,7 @@ export default async function AdminAppInsights() {
] = await Promise.all([ ] = await Promise.all([
getPhotosMeta({ hidden: 'include' }), getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }), getPhotosMeta({ hidden: 'only' }),
getOutdatedPhotosCount(), getPhotosInNeedOfSyncCount(),
getPhotosMeta({ maximumAspectRatio: 0.9 }), getPhotosMeta({ maximumAspectRatio: 0.9 }),
getGitHubMetaForCurrentApp(), getGitHubMetaForCurrentApp(),
getUniqueCameras(), getUniqueCameras(),
@ -44,14 +44,14 @@ export default async function AdminAppInsights() {
insights={getAllInsights({ insights={getAllInsights({
codeMeta, codeMeta,
photosCount, photosCount,
photosCountOutdated, photosCountNeedSync,
photosCountPortrait, photosCountPortrait,
tagsCount: tags.length, tagsCount: tags.length,
})} })}
photoStats={{ photoStats={{
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountOutdated, photosCountNeedSync,
camerasCount: cameras.length, camerasCount: cameras.length,
lensesCount: lenses.length, lensesCount: lenses.length,
tagsCount: tags.length, tagsCount: tags.length,

View File

@ -28,7 +28,7 @@ import {
import EnvVar from '@/components/EnvVar'; import EnvVar from '@/components/EnvVar';
import { IoSyncCircle } from 'react-icons/io5'; import { IoSyncCircle } from 'react-icons/io5';
import clsx from 'clsx/lite'; 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 { LiaBroomSolid } from 'react-icons/lia';
import { IoMdGrid } from 'react-icons/io'; import { IoMdGrid } from 'react-icons/io';
import { RiSpeedMiniLine } from 'react-icons/ri'; import { RiSpeedMiniLine } from 'react-icons/ri';
@ -46,11 +46,12 @@ import IconFocalLength from '@/components/icons/IconFocalLength';
import IconTag from '@/components/icons/IconTag'; import IconTag from '@/components/icons/IconTag';
import IconPhoto from '@/components/icons/IconPhoto'; import IconPhoto from '@/components/icons/IconPhoto';
import { HiOutlineDocumentText } from 'react-icons/hi'; import { HiOutlineDocumentText } from 'react-icons/hi';
import { ReactNode } from 'react';
const DEBUG_COMMIT_SHA = '4cd29ed'; const DEBUG_COMMIT_SHA = '4cd29ed';
const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes'; const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes';
const DEBUG_BEHIND_BY = 9; 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_WARNING = 'text-amber-600 dark:text-amber-500';
const TEXT_COLOR_BLUE = 'text-blue-600 dark:text-blue-500'; const TEXT_COLOR_BLUE = 'text-blue-600 dark:text-blue-500';
@ -91,7 +92,7 @@ export default function AdminAppInsightsClient({
photoStats: { photoStats: {
photosCount, photosCount,
photosCountHidden, photosCountHidden,
photosCountOutdated, photosCountNeedSync,
camerasCount, camerasCount,
lensesCount, lensesCount,
tagsCount, tagsCount,
@ -114,7 +115,7 @@ export default function AdminAppInsightsClient({
noAiRateLimiting, noAiRateLimiting,
noConfiguredDomain, noConfiguredDomain,
noConfiguredMeta, noConfiguredMeta,
outdatedPhotos, photosNeedSync,
photoMatting, photoMatting,
camerasFirst, camerasFirst,
gridFirst, gridFirst,
@ -131,6 +132,13 @@ export default function AdminAppInsightsClient({
{codeMeta?.branch ?? TEMPLATE_REPO_BRANCH} {codeMeta?.branch ?? TEMPLATE_REPO_BRANCH}
</a>; </a>;
const renderTooltipContent = (content: ReactNode) =>
<Tooltip
content={content}
classNameTrigger="translate-y-[-1.5px] ml-2 h-3"
supportMobile
/>;
return ( return (
<ScoreCardContainer> <ScoreCardContainer>
{(codeMeta || debug) && <> {(codeMeta || debug) && <>
@ -143,11 +151,9 @@ export default function AdminAppInsightsClient({
/>} />}
content={<> content={<>
<span>Could not analyze source code</span> <span>Could not analyze source code</span>
<Tooltip {renderTooltipContent(
content="Could not connect to GitHub API. Try refreshing." 'Could not connect to GitHub API. Try refreshing.',
classNameTrigger="translate-y-[-1.5px] ml-2 h-3" )}
supportMobile
/>
</>} </>}
/>} />}
{((!codeMeta?.didError && noFork) || debug) && {((!codeMeta?.didError && noFork) || debug) &&
@ -417,7 +423,7 @@ export default function AdminAppInsightsClient({
</AdminEmptyState>} </AdminEmptyState>}
</ScoreCard> </ScoreCard>
<ScoreCard title="Library Stats"> <ScoreCard title="Library Stats">
{(outdatedPhotos || debug) && <ScoreCardRow {(photosNeedSync || debug) && <ScoreCardRow
icon={<LiaBroomSolid icon={<LiaBroomSolid
size={19} size={19}
className={clsx( className={clsx(
@ -425,14 +431,21 @@ export default function AdminAppInsightsClient({
TEXT_COLOR_BLUE, TEXT_COLOR_BLUE,
)} )}
/>} />}
content={renderHighlightText( content={<>
pluralize( {renderHighlightText(
photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, pluralize(
'outdated photo', photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT,
), 'photo',
'blue', ),
)} 'blue',
expandPath={PATH_ADMIN_OUTDATED} )}
{' '}
with updates
{renderTooltipContent(<>
Missing data or AI&#8209;generated text
</>)}
</>}
expandPath={PATH_ADMIN_PHOTOS_UPDATES}
/>} />}
<ScoreCardRow <ScoreCardRow
icon={<IconPhoto icon={<IconPhoto

View File

@ -39,7 +39,7 @@ const _INSIGHTS_TEMPLATE = [
type AdminAppInsightRecommendation = typeof _INSIGHTS_TEMPLATE[number]; type AdminAppInsightRecommendation = typeof _INSIGHTS_TEMPLATE[number];
const _INSIGHTS_LIBRARY = [ const _INSIGHTS_LIBRARY = [
'outdatedPhotos', 'photosNeedSync',
] as const; ] as const;
type AdminAppInsightLibrary = typeof _INSIGHTS_LIBRARY[number]; type AdminAppInsightLibrary = typeof _INSIGHTS_LIBRARY[number];
@ -58,7 +58,7 @@ export const hasTemplateRecommendations = (insights: AdminAppInsights) =>
export interface PhotoStats { export interface PhotoStats {
photosCount: number photosCount: number
photosCountHidden: number photosCountHidden: number
photosCountOutdated: number photosCountNeedSync: number
camerasCount: number camerasCount: number
lensesCount: number lensesCount: number
tagsCount: number tagsCount: number
@ -80,10 +80,10 @@ export const getGitHubMetaForCurrentApp = () =>
export const getSignificantInsights = ({ export const getSignificantInsights = ({
codeMeta, codeMeta,
photosCountOutdated, photosCountNeedSync,
}: { }: {
codeMeta: Awaited<ReturnType<typeof getGitHubMetaForCurrentApp>> codeMeta: Awaited<ReturnType<typeof getGitHubMetaForCurrentApp>>
photosCountOutdated: number photosCountNeedSync: number
}) => { }) => {
const { const {
isAiTextGenerationEnabled, isAiTextGenerationEnabled,
@ -95,30 +95,38 @@ export const getSignificantInsights = ({
forkBehind: Boolean(codeMeta?.isBehind), forkBehind: Boolean(codeMeta?.isBehind),
noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage, noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage,
noConfiguredDomain: !hasDomain, noConfiguredDomain: !hasDomain,
outdatedPhotos: Boolean(photosCountOutdated), photosNeedSync: Boolean(photosCountNeedSync),
}; };
}; };
export const indicatorStatusForSignificantInsights = ( export const indicatorStatusForSignificantInsights = ({
insights: Awaited<ReturnType<typeof getSignificantInsights>>, codeMeta,
) => { photosCountNeedSync,
}: Parameters<typeof getSignificantInsights>[0] & {
photosCountNeedSync: number
}) => {
const insights = getSignificantInsights({
codeMeta,
photosCountNeedSync,
});
const { const {
forkBehind, forkBehind,
noAiRateLimiting, noAiRateLimiting,
noConfiguredDomain, noConfiguredDomain,
outdatedPhotos, photosNeedSync,
} = insights; } = insights;
if (noAiRateLimiting || noConfiguredDomain) { if (noAiRateLimiting || noConfiguredDomain) {
return 'yellow'; return 'yellow';
} else if (forkBehind || outdatedPhotos) { } else if (forkBehind || photosNeedSync) {
return 'blue'; return 'blue';
} }
}; };
export const getAllInsights = ({ export const getAllInsights = ({
codeMeta, codeMeta,
photosCountOutdated, photosCountNeedSync,
photosCount, photosCount,
photosCountPortrait, photosCountPortrait,
tagsCount, tagsCount,
@ -127,7 +135,7 @@ export const getAllInsights = ({
photosCountPortrait: number photosCountPortrait: number
tagsCount: number tagsCount: number
}) => ({ }) => ({
...getSignificantInsights({ codeMeta, photosCountOutdated }), ...getSignificantInsights({ codeMeta, photosCountNeedSync }),
noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo, noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo,
noAi: !AI_TEXT_GENERATION_ENABLED, noAi: !AI_TEXT_GENERATION_ENABLED,
noConfiguredMeta: noConfiguredMeta:

View File

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

View File

@ -7,50 +7,54 @@ import { TAG_HIDDEN } from '@/tag';
import { Lens } from '@/lens'; import { Lens } from '@/lens';
// Core paths // Core paths
export const PATH_ROOT = '/'; export const PATH_ROOT = '/';
export const PATH_GRID = '/grid'; export const PATH_GRID = '/grid';
export const PATH_FEED = '/feed'; export const PATH_FEED = '/feed';
export const PATH_ADMIN = '/admin'; export const PATH_ADMIN = '/admin';
export const PATH_API = '/api'; export const PATH_API = '/api';
export const PATH_SIGN_IN = '/sign-in'; export const PATH_SIGN_IN = '/sign-in';
export const PATH_OG = '/og'; export const PATH_OG = '/og';
// eslint-disable-next-line max-len
export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID; export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED
// eslint-disable-next-line max-len ? PATH_ROOT
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_FEED : PATH_ROOT; : PATH_GRID;
export const PATH_FEED_INFERRED = GRID_HOMEPAGE_ENABLED
? PATH_FEED
: PATH_ROOT;
// Path prefixes // Path prefixes
export const PREFIX_PHOTO = '/p'; export const PREFIX_PHOTO = '/p';
export const PREFIX_CAMERA = '/shot-on'; export const PREFIX_CAMERA = '/shot-on';
export const PREFIX_LENS = '/lens'; export const PREFIX_LENS = '/lens';
export const PREFIX_TAG = '/tag'; export const PREFIX_TAG = '/tag';
export const PREFIX_RECIPE = '/recipe'; export const PREFIX_RECIPE = '/recipe';
export const PREFIX_FILM = '/film'; export const PREFIX_FILM = '/film';
export const PREFIX_FOCAL_LENGTH = '/focal'; export const PREFIX_FOCAL_LENGTH = '/focal';
// Dynamic paths // Dynamic paths
const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`; const PATH_PHOTO_DYNAMIC = `${PREFIX_PHOTO}/[photoId]`;
const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`; const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`; const PATH_LENS_DYNAMIC = `${PREFIX_LENS}/[make]/[model]`;
const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`; const PATH_TAG_DYNAMIC = `${PREFIX_TAG}/[tag]`;
const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`; const PATH_FILM_DYNAMIC = `${PREFIX_FILM}/[film]`;
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`; const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`; const PATH_RECIPE_DYNAMIC = `${PREFIX_RECIPE}/[recipe]`;
// Admin paths // Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`; export const PATH_ADMIN_PHOTOS_UPDATES = `${PATH_ADMIN_PHOTOS}/updates`;
export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`;
export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`;
export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`; export const PATH_ADMIN_RECIPES = `${PATH_ADMIN}/recipes`;
export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`;
export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`; export const PATH_ADMIN_INSIGHTS = `${PATH_ADMIN}/insights`;
export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`; export const PATH_ADMIN_BASELINE = `${PATH_ADMIN}/baseline`;
export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`; export const PATH_ADMIN_COMPONENTS = `${PATH_ADMIN}/components`;
// Debug paths // Debug paths
export const PATH_OG_ALL = `${PATH_OG}/all`; export const PATH_OG_ALL = `${PATH_OG}/all`;
export const PATH_OG_SAMPLE = `${PATH_OG}/sample`; export const PATH_OG_SAMPLE = `${PATH_OG}/sample`;
// API paths // API paths
export const PATH_API_STORAGE = `${PATH_API}/storage`; export const PATH_API_STORAGE = `${PATH_API}/storage`;
@ -66,6 +70,7 @@ export const MISSING_FIELD = '-';
export const PATHS_ADMIN = [ export const PATHS_ADMIN = [
PATH_ADMIN, PATH_ADMIN,
PATH_ADMIN_PHOTOS, PATH_ADMIN_PHOTOS,
PATH_ADMIN_PHOTOS_UPDATES,
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
PATH_ADMIN_TAGS, PATH_ADMIN_TAGS,
PATH_ADMIN_RECIPES, PATH_ADMIN_RECIPES,
@ -94,9 +99,6 @@ type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & {
showRecipe?: boolean showRecipe?: boolean
}; };
// Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
export const pathForAdminUploadUrl = (url: string) => export const pathForAdminUploadUrl = (url: string) =>
`${PATH_ADMIN_UPLOADS}/${encodeURIComponent(url)}`; `${PATH_ADMIN_UPLOADS}/${encodeURIComponent(url)}`;
@ -164,6 +166,9 @@ export const pathForFocalLength = (focal: number) =>
export const pathForRecipe = (recipe: string) => export const pathForRecipe = (recipe: string) =>
`${PREFIX_RECIPE}/${recipe}`; `${PREFIX_RECIPE}/${recipe}`;
// Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
export const absolutePathForPhoto = (params: PhotoPathParams) => export const absolutePathForPhoto = (params: PhotoPathParams) =>
`${BASE_URL}${pathForPhoto(params)}`; `${BASE_URL}${pathForPhoto(params)}`;

View File

@ -2,7 +2,6 @@ import { BiCopy } from 'react-icons/bi';
import LoaderButton from './primitives/LoaderButton'; import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import Tooltip from './Tooltip';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
export default function CopyButton({ export default function CopyButton({
@ -10,20 +9,18 @@ export default function CopyButton({
text, text,
subtle, subtle,
iconSize = 15, iconSize = 15,
tooltip,
tooltipColor,
className, className,
...props
}: { }: {
label: string label: string
text?: string, text?: string,
subtle?: boolean subtle?: boolean
iconSize?: number iconSize?: number
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
className?: string className?: string
}) { } & ComponentProps<typeof LoaderButton>) {
const button = return (
<LoaderButton <LoaderButton
{...props}
icon={<BiCopy size={iconSize} />} icon={<BiCopy size={iconSize} />}
className={clsx( className={clsx(
subtle && 'text-gray-300 dark:text-gray-700', subtle && 'text-gray-300 dark:text-gray-700',
@ -37,13 +34,6 @@ export default function CopyButton({
: undefined} : undefined}
styleAs="link" styleAs="link"
disabled={!text} disabled={!text}
/>; />
return (
tooltip
? <Tooltip content={tooltip} color={tooltipColor}>
{button}
</Tooltip>
: button
); );
} }

View File

@ -0,0 +1,6 @@
import { IconBaseProps } from 'react-icons';
import { LiaBroomSolid } from 'react-icons/lia';
export default function IconBroom(props: IconBaseProps) {
return <LiaBroomSolid {...props} />;
}

View File

@ -21,7 +21,7 @@ export default function MoreMenuItem({
}: { }: {
label: string label: string
labelComplex?: ReactNode labelComplex?: ReactNode
annotation?: string annotation?: ReactNode
icon?: ReactNode icon?: ReactNode
href?: string href?: string
hrefDownloadName?: string hrefDownloadName?: string

View File

@ -2,9 +2,36 @@
import Spinner, { SpinnerColor } from '@/components/Spinner'; import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite'; 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<HTMLButtonElement | null>
isLoading?: boolean isLoading?: boolean
icon?: ReactNode icon?: ReactNode
spinnerColor?: SpinnerColor spinnerColor?: SpinnerColor
@ -15,29 +42,13 @@ export default function LoaderButton(props: {
shouldPreventDefault?: boolean shouldPreventDefault?: boolean
primary?: boolean primary?: boolean
hideFocusOutline?: boolean hideFocusOutline?: boolean
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
} & ButtonHTMLAttributes<HTMLButtonElement>) { } & ButtonHTMLAttributes<HTMLButtonElement>) {
const { const button =
children,
isLoading,
icon,
spinnerColor,
spinnerClassName,
styleAs = 'button',
hideTextOnMobile = true,
confirmText,
shouldPreventDefault,
primary,
hideFocusOutline,
type = 'button',
onClick,
disabled,
className,
...rest
} = props;
return (
<button <button
{...rest} {...rest}
ref={ref}
type={type} type={type}
onClick={e => { onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); } if (shouldPreventDefault) { e.preventDefault(); }
@ -86,6 +97,13 @@ export default function LoaderButton(props: {
)}> )}>
{children} {children}
</span>} </span>}
</button> </button>;
return (
tooltip
? <Tooltip content={tooltip} color={tooltipColor}>
{button}
</Tooltip>
: button
); );
} }

View File

@ -374,7 +374,7 @@ export const getExifDataAction = async (
// - strip GPS data if necessary // - strip GPS data if necessary
// - update blur data (or destroy if blur is disabled) // - update blur data (or destroy if blur is disabled)
// - generate AI text data, if enabled, and auto-generated fields are empty // - 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 () => { runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId ?? '', true); const photo = await getPhoto(photoId ?? '', true);
@ -414,7 +414,8 @@ export const syncPhotoAction = async (photoId: string) =>
semanticDescription: aiSemanticDescription, semanticDescription: aiSemanticDescription,
} = await generateAiImageQueries( } = await generateAiImageQueries(
imageResizedBase64, imageResizedBase64,
AI_TEXT_AUTO_GENERATED_FIELDS, photo.syncStatus.missingAiTextFields,
isBatch,
); );
const formDataFromPhoto = convertPhotoToFormData(photo); const formDataFromPhoto = convertPhotoToFormData(photo);
@ -451,7 +452,7 @@ export const syncPhotoAction = async (photoId: string) =>
export const syncPhotosAction = async (photoIds: string[]) => export const syncPhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) { for (const photoId of photoIds) {
await syncPhotoAction(photoId); await syncPhotoAction(photoId, true);
} }
revalidateAllKeysAndPaths(); revalidateAllKeysAndPaths();
}); });

View File

@ -9,6 +9,7 @@ import { getUniqueTags } from '../db/query';
export const generateAiImageQueries = async ( export const generateAiImageQueries = async (
imageBase64?: string, imageBase64?: string,
textFieldsToGenerate: AiAutoGeneratedField[] = [], textFieldsToGenerate: AiAutoGeneratedField[] = [],
isBatch?: boolean,
): Promise<{ ): Promise<{
title?: string title?: string
caption?: string caption?: string
@ -31,6 +32,7 @@ export const generateAiImageQueries = async (
const titleAndCaption = await generateOpenAiImageQuery( const titleAndCaption = await generateOpenAiImageQuery(
imageBase64, imageBase64,
getAiImageQuery('title-and-caption'), getAiImageQuery('title-and-caption'),
isBatch,
); );
if (titleAndCaption) { if (titleAndCaption) {
const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption); const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption);
@ -42,12 +44,14 @@ export const generateAiImageQueries = async (
title = await generateOpenAiImageQuery( title = await generateOpenAiImageQuery(
imageBase64, imageBase64,
getAiImageQuery('title'), getAiImageQuery('title'),
isBatch,
); );
} }
if (textFieldsToGenerate.includes('caption')) { if (textFieldsToGenerate.includes('caption')) {
caption = await generateOpenAiImageQuery( caption = await generateOpenAiImageQuery(
imageBase64, imageBase64,
getAiImageQuery('caption'), getAiImageQuery('caption'),
isBatch,
); );
} }
} }
@ -57,6 +61,7 @@ export const generateAiImageQueries = async (
tags = await generateOpenAiImageQuery( tags = await generateOpenAiImageQuery(
imageBase64, imageBase64,
getAiImageQuery('tags', existingTags), getAiImageQuery('tags', existingTags),
isBatch,
); );
} }
@ -64,6 +69,7 @@ export const generateAiImageQueries = async (
semanticDescription = await generateOpenAiImageQuery( semanticDescription = await generateOpenAiImageQuery(
imageBase64, imageBase64,
getAiImageQuery('description-small'), getAiImageQuery('description-small'),
isBatch,
); );
} }
} }

View File

@ -7,13 +7,17 @@ import { Lens } from '@/lens';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100; export const PHOTO_DEFAULT_LIMIT = 100;
// Trim whitespace const DB_PARAMETERIZE_REPLACEMENTS = [
// Make lowercase [',', ''],
// Remove commas, slashes ['/', ''],
// Replace spaces with dashes ['+', '-'],
[' ', '-'],
];
const parameterizeForDb = (field: string) => const parameterizeForDb = (field: string) =>
// eslint-disable-next-line max-len DB_PARAMETERIZE_REPLACEMENTS.reduce((acc, [from, to]) =>
`REPLACE(REPLACE(REPLACE(LOWER(TRIM(${field})), ',', ''), '/', ''), ' ', '-')`; `REPLACE(${acc}, '${from}', '${to}')`
, `LOWER(TRIM(${field}))`);
export type GetPhotosOptions = { export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority' sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority'

View File

@ -1,3 +1,4 @@
/* eslint-disable quotes */
import { import {
sql, sql,
query, query,
@ -14,7 +15,11 @@ import {
import { Cameras, createCameraKey } from '@/camera'; import { Cameras, createCameraKey } from '@/camera';
import { Tags } from '@/tag'; import { Tags } from '@/tag';
import { Films } from '@/film'; 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 { import {
GetPhotosOptions, GetPhotosOptions,
getLimitAndOffsetFromOptions, getLimitAndOffsetFromOptions,
@ -24,7 +29,11 @@ import { getWheresFromOptions } from '.';
import { FocalLengths } from '@/focal'; import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens'; import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration'; 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 { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Recipes } from '@/recipe'; import { Recipes } from '@/recipe';
@ -568,38 +577,55 @@ export const getPhoto = async (
.then(photos => photos.length > 0 ? photos[0] : undefined); .then(photos => photos.length > 0 ? photos[0] : undefined);
}, 'getPhoto'); }, 'getPhoto');
// Outdated queries // Sync queries
const outdatedWhereClause = const outdatedWhereClauses = [
// eslint-disable-next-line quotes `updated_at < $1`,
`WHERE updated_at < $1 OR (updated_at < $2 AND make = $3)`; `(updated_at < $2 AND make = $3)`,
];
const outdatedValues = [ const outdatedWhereValues = [
UPDATED_BEFORE_01.toISOString(), UPDATED_BEFORE_01.toISOString(),
UPDATED_BEFORE_02.toISOString(), UPDATED_BEFORE_02.toISOString(),
MAKE_FUJIFILM, 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(` () => query(`
SELECT * FROM photos SELECT * FROM photos
${outdatedWhereClause} ${needsSyncWhereStatement}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1000 LIMIT ${SYNC_QUERY_LIMIT}
`, `,
outdatedValues, outdatedWhereValues,
) )
.then(({ rows }) => rows.map(parsePhotoFromDb)), .then(({ rows }) => rows.map(parsePhotoFromDb)),
'getOutdatedPhotos', 'getPhotosInNeedOfSync',
); );
export const getOutdatedPhotosCount = () => safelyQueryPhotos( export const getPhotosInNeedOfSyncCount = () => safelyQueryPhotos(
() => query(` () => query(`
SELECT COUNT(*) FROM photos SELECT COUNT(*) FROM photos
${outdatedWhereClause} ${needsSyncWhereStatement}
`, `,
outdatedValues, outdatedWhereValues,
) )
.then(({ rows }) => parseInt(rows[0].count, 10)), .then(({ rows }) => parseInt(rows[0].count, 10)),
'getOutdatedPhotosCount', 'getPhotosInNeedOfSyncCount',
); );

View File

@ -23,6 +23,7 @@ import { isBefore } from 'date-fns';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
// INFINITE SCROLL: FEED // INFINITE SCROLL: FEED
export const INFINITE_SCROLL_FEED_INITIAL = export const INFINITE_SCROLL_FEED_INITIAL =
@ -96,7 +97,7 @@ export interface PhotoDb extends
updatedAt: Date updatedAt: Date
createdAt: Date createdAt: Date
takenAt: Date takenAt: Date
tags: string[] tags: string[] | null
} }
// Parsed db response // Parsed db response
@ -108,7 +109,9 @@ export interface Photo extends Omit<PhotoDb, 'recipeData'> {
exposureTimeFormatted?: string exposureTimeFormatted?: string
exposureCompensationFormatted?: string exposureCompensationFormatted?: string
takenAtNaiveFormatted: string takenAtNaiveFormatted: string
tags: string[]
recipeData?: FujifilmRecipe recipeData?: FujifilmRecipe
syncStatus: PhotoSyncStatus
} }
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
@ -130,15 +133,16 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
formatExposureTime(photoDb.exposureTime), formatExposureTime(photoDb.exposureTime),
exposureCompensationFormatted: exposureCompensationFormatted:
formatExposureCompensation(photoDb.exposureCompensation), formatExposureCompensation(photoDb.exposureCompensation),
takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive),
recipeData: photoDb.recipeData recipeData: photoDb.recipeData
// Legacy check on escaped, string-based JSON // Legacy check on escaped, string-based JSON
? typeof photoDb.recipeData === 'string' ? typeof photoDb.recipeData === 'string'
? JSON.parse(photoDb.recipeData) ? JSON.parse(photoDb.recipeData)
: photoDb.recipeData : photoDb.recipeData
: undefined, : undefined,
takenAtNaiveFormatted: syncStatus: generatePhotoSyncStatus(photoDb),
formatDateFromPostgresString(photoDb.takenAtNaive), } as Photo;
};
}; };
export const parseCachedPhotoDates = (photo: Photo) => ({ export const parseCachedPhotoDates = (photo: Photo) => ({

View File

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

87
src/photo/sync.ts Normal file
View File

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

View File

@ -13,7 +13,6 @@ import { cleanUpAiTextResponse } from '@/photo/ai';
const redis = HAS_REDIS_STORAGE ? Redis.fromEnv() : undefined; const redis = HAS_REDIS_STORAGE ? Redis.fromEnv() : undefined;
const RATE_LIMIT_IDENTIFIER = 'openai-image-query'; const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100;
const MODEL = 'gpt-4o'; const MODEL = 'gpt-4o';
const openai = AI_TEXT_GENERATION_ENABLED const openai = AI_TEXT_GENERATION_ENABLED
@ -21,18 +20,24 @@ const openai = AI_TEXT_GENERATION_ENABLED
: undefined; : undefined;
const ratelimit = redis const ratelimit = redis
? new Ratelimit({ ? {
redis, basic: new Ratelimit({
limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'), redis,
}) limiter: Ratelimit.slidingWindow(100, '1h'),
}),
batch: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1200, '1d'),
}),
}
: undefined; : undefined;
// Allows 100 requests per hour const checkRateLimitAndThrow = async (isBatch?: boolean) => {
const checkRateLimitAndThrow = async () => {
if (ratelimit) { if (ratelimit) {
let success = false; let success = false;
try { 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) { } catch (e: any) {
console.error('Failed to rate limit OpenAI', e); console.error('Failed to rate limit OpenAI', e);
throw new Error('Failed to rate limit OpenAI'); throw new Error('Failed to rate limit OpenAI');
@ -92,8 +97,9 @@ export const streamOpenAiImageQuery = async (
export const generateOpenAiImageQuery = async ( export const generateOpenAiImageQuery = async (
imageBase64: string, imageBase64: string,
query: string, query: string,
isBatch?: boolean,
) => { ) => {
await checkRateLimitAndThrow(); await checkRateLimitAndThrow(isBatch);
const args = getImageTextArgs(imageBase64, query); const args = getImageTextArgs(imageBase64, query);

View File

@ -1,6 +1,6 @@
import { RefObject, useCallback, useEffect, useState } from 'react'; import { RefObject, useCallback, useMemo, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside'; import useClickInsideOutside from '@/utility/useClickInsideOutside';
import useScrollIntoView from '@/utility/useScrollIntoView';
export default function useRecipeOverlay({ export default function useRecipeOverlay({
ref, ref,
@ -19,16 +19,18 @@ export default function useRecipeOverlay({
setIsShowingRecipeOverlay(current => !current), setIsShowingRecipeOverlay(current => !current),
[]); []);
const htmlElements = useMemo(() =>
[ref, ...refTriggers], [ref, refTriggers]);
useClickInsideOutside({ useClickInsideOutside({
htmlElements: [ref, ...refTriggers], htmlElements,
onClickOutside: hideRecipeOverlay, onClickOutside: hideRecipeOverlay,
}); });
useEffect(() => { useScrollIntoView({
if (isShowingRecipeOverlay && !isElementEntirelyInViewport(ref?.current)) { ref,
ref?.current?.scrollIntoView({ behavior: 'smooth' }); shouldScrollIntoView: isShowingRecipeOverlay,
} });
}, [ref, isShowingRecipeOverlay]);
return { return {
isShowingRecipeOverlay, isShowingRecipeOverlay,

View File

@ -151,7 +151,7 @@ export default function AppStateProvider({
if (userEmail) { if (userEmail) {
storeAuthEmailCookie(userEmail); storeAuthEmailCookie(userEmail);
} }
}, [userEmail, adminData]); }, [userEmail]);
const registerAdminUpdate = useCallback(() => const registerAdminUpdate = useCallback(() =>
setAdminUpdateTimes(updates => [...updates, new Date()]) setAdminUpdateTimes(updates => [...updates, new Date()])

View File

@ -22,11 +22,11 @@ export const parameterize = (
) => ) =>
string string
.trim() .trim()
// Replaces spaces, underscores, slashes,and dashes with dashes // Replace spaces, underscores, slashes, pluses, dashes with dashes
.replaceAll(/[\s_]/gi, '-') .replaceAll(/[\s_+]/gi, '-')
// Removes punctuation // Remove punctuation
.replaceAll(/['"!@#$%^&*()_+=[\]{};:/?,<>\\/|`~]/gi, '') .replaceAll(/['"!@#$%^&*()=[\]{};:/?,<>\\/|`~]/gi, '')
// Removes all non-alphanumeric characters // Removes non-alphanumeric characters, if configured
.replaceAll( .replaceAll(
shouldRemoveNonAlphanumeric shouldRemoveNonAlphanumeric
? /([^a-z0-9-])/gi ? /([^a-z0-9-])/gi

View File

@ -0,0 +1,20 @@
import { RefObject, useEffect } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
export default function useScrollIntoView({
ref,
shouldScrollIntoView,
}: {
ref?: RefObject<HTMLElement | null>
shouldScrollIntoView?: boolean
}) {
useEffect(() => {
if (
ref?.current &&
!isElementEntirelyInViewport(ref.current) &&
shouldScrollIntoView
) {
ref.current.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldScrollIntoView]);
}

View File

@ -1,6 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useEffect } from 'react';
export default function useVisualViewportHeight() { export default function useVisualViewportHeight() {
const [viewportHeight, setViewportHeight] = useState<number>(); const [viewportHeight, setViewportHeight] = useState<number>();