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.
#### Why don't my older photos look right?
> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the outdated photo page (`/admin/outdated`) to make batch updates.
> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or go to photo updates (`/admin/photos/updates`) to sync all photos that need updates.
#### Why don't my OG images load when I share a link?
> Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1` and `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1`. Keep in mind that this will increase platform usage.
@ -314,6 +314,9 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
#### I've added my OpenAI key but can't seem to make it work. Why am I seeing connection errors?
> You may need to pre-purchase credits before accessing the OpenAI API. See [Issue #110](https://github.com/sambecker/exif-photo-blog/issues/110) for discussion.
#### How do I generate AI text for preexisting photos?
> Once AI text generation is configured, photos missing text will show up in photo updates (`/admin/photos/updates`).
#### Will there be support for image storage providers beyond Vercel, AWS, and Cloudflare?
> At this time, there are no plans to introduce support for new storage providers. While configuring a new, AWS-compatible provider (e.g., Cloudflare R2) should not be too difficult, there's nuance to consider surrounding details like IAM, CORS, and domain configuration, which can differ slightly from platform to platform. If 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 { getPhotos } from '@/photo/db/query';
import { getPhotos, getPhotosInNeedOfSyncCount } from '@/photo/db/query';
import { getPhotosMetaCached } from '@/photo/cache';
import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
import { getOutdatedPhotosCount } from '@/photo/db/query';
import {
AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
@ -24,7 +23,7 @@ export default async function AdminPhotosPage() {
const [
photos,
photosCount,
photosCountOutdated,
photosCountNeedsSync,
blobPhotoUrls,
] = await Promise.all([
getPhotos({
@ -35,7 +34,7 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
getOutdatedPhotosCount()
getPhotosInNeedOfSyncCount()
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()
@ -46,7 +45,7 @@ export default async function AdminPhotosPage() {
<AdminPhotosClient {...{
photos,
photosCount,
photosCountOutdated,
photosCountNeedsSync,
shouldResize: !PRESERVE_ORIGINAL_UPLOADS,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
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",
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-tooltip": "^1.2.0",
"@radix-ui/react-visually-hidden": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-tooltip": "^1.2.3",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.34.8",
"@vercel/analytics": "^1.5.0",
@ -62,7 +62,7 @@
"@types/react-dom": "19.1.2",
"@types/sanitize-html": "^2.15.0",
"cross-fetch": "^4.1.0",
"eslint": "9.24.0",
"eslint": "9.25.0",
"eslint-config-next": "15.3.1",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^29.7.0",

315
pnpm-lock.yaml generated
View File

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

View File

@ -30,6 +30,7 @@ import AdminLink from './AdminLink';
import ScoreCardContainer from '@/components/ScoreCardContainer';
import { capitalize, deparameterize } from '@/utility/string';
import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category';
import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai';
export default function AdminAppConfigurationClient({
// Storage
@ -365,6 +366,28 @@ export default function AdminAppConfigurationClient({
and improve accessibility:
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</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
title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection'
@ -380,19 +403,6 @@ export default function AdminAppConfigurationClient({
on Vercel dashboard and connect to this project
to enable rate limiting
</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
title="Performance"

View File

@ -5,6 +5,7 @@ import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_PHOTOS_UPDATES,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
@ -26,6 +27,8 @@ import IconSignOut from '@/components/icons/IconSignOut';
import IconLock from '@/components/icons/IconLock';
import { IoMdCheckboxOutline } from 'react-icons/io';
import Spinner from '@/components/Spinner';
import IconBroom from '@/components/icons/IconBroom';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
export default function AdminAppMenu({
active,
@ -38,6 +41,7 @@ export default function AdminAppMenu({
}) {
const {
photosCountTotal = 0,
photosCountNeedSync = 0,
uploadsCount = 0,
tagsCount = 0,
recipesCount = 0,
@ -71,11 +75,30 @@ export default function AdminAppMenu({
annotation: `${uploadsCount}`,
icon: <IconFolder
size={16}
className="translate-y-[0.5px]"
className="translate-x-[1px] translate-y-[0.5px]"
/>,
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) {
items.push({
label: 'Manage Photos',
@ -121,7 +144,10 @@ export default function AdminAppMenu({
size={18}
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,
action: () => {
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 MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/components/icons/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync';
export default function AdminPhotoMenu({
photo,
@ -79,10 +79,11 @@ export default function AdminPhotoMenu({
label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{isPhotoOutdated(photo) &&
{photoNeedsToBeSynced(photo) &&
<InsightsIndicatorDot
colorOverride="blue"
className="translate-y-[1.5px]"
className="ml-1 translate-y-[1.5px]"
size="small"
/>}
</span>,
icon: <IconGrSync className="translate-x-[-1px]" />,

View File

@ -5,19 +5,21 @@ import AppGrid from '@/components/AppGrid';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import AdminPhotosTableInfinite from '@/admin/AdminPhotosTableInfinite';
import PathLoaderButton from '@/components/primitives/PathLoaderButton';
import { PATH_ADMIN_OUTDATED } from '@/app/paths';
import { PATH_ADMIN_PHOTOS_UPDATES } from '@/app/paths';
import { Photo } from '@/photo';
import { StorageListResponse } from '@/platforms/storage';
import { LiaBroomSolid } from 'react-icons/lia';
import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { pluralize } from '@/utility/string';
import IconBroom from '@/components/icons/IconBroom';
import ResponsiveText from '@/components/primitives/ResponsiveText';
export default function AdminPhotosClient({
photos,
photosCount,
photosCountOutdated,
photosCountNeedsSync,
blobPhotoUrls,
shouldResize,
hasAiTextGeneration,
@ -28,7 +30,7 @@ export default function AdminPhotosClient({
}: {
photos: Photo[]
photosCount: number
photosCountOutdated: number
photosCountNeedsSync: number
blobPhotoUrls: StorageListResponse
shouldResize: boolean
hasAiTextGeneration: boolean
@ -51,14 +53,17 @@ export default function AdminPhotosClient({
onLastUpload={onLastUpload}
/>
</div>
{photosCountOutdated > 0 &&
{photosCountNeedsSync > 0 &&
<PathLoaderButton
path={PATH_ADMIN_OUTDATED}
icon={<LiaBroomSolid
path={PATH_ADMIN_PHOTOS_UPDATES}
icon={<IconBroom
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(
'text-blue-600 dark:text-blue-400',
'border border-blue-200 dark:border-blue-800/60',
@ -70,7 +75,9 @@ export default function AdminPhotosClient({
spinnerClassName="text-blue-200 dark:text-blue-600/40"
hideTextOnMobile={false}
>
{photosCountOutdated}
<ResponsiveText shortText={photosCountNeedsSync}>
{pluralize(photosCountNeedsSync, 'Update')}
</ResponsiveText>
</PathLoaderButton>}
</div>
{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 { Timezone } from '@/utility/timezone';
import IconHidden from '@/components/icons/IconHidden';
import Tooltip from '@/components/Tooltip';
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
export default function AdminPhotosTable({
photos,
@ -22,20 +24,22 @@ export default function AdminPhotosTable({
revalidatePhoto,
photoIdsSyncing = [],
hasAiTextGeneration,
showUpdatedAt,
dateType = 'createdAt',
canEdit = true,
canDelete = true,
timezone,
shouldScrollIntoViewOnExternalSync,
}: {
photos: Photo[],
onLastPhotoVisible?: () => void
revalidatePhoto?: RevalidatePhoto
photoIdsSyncing?: string[]
hasAiTextGeneration: boolean
showUpdatedAt?: boolean
dateType?: 'createdAt' | 'updatedAt'
canEdit?: boolean
canDelete?: boolean
timezone?: Timezone
shouldScrollIntoViewOnExternalSync?: boolean
}) {
const { invalidateSwr } = useAppState();
@ -68,7 +72,7 @@ export default function AdminPhotosTable({
<span className={clsx(
photo.hidden && 'text-dim',
)}>
{titleForPhoto(photo)}
{titleForPhoto(photo, false)}
{photo.hidden && <span className="whitespace-nowrap">
{' '}
<IconHidden
@ -90,11 +94,18 @@ export default function AdminPhotosTable({
'lg:w-[50%] uppercase',
'text-dim',
)}>
<PhotoDate {...{
photo,
dateType: showUpdatedAt ? 'updatedAt' : 'createdAt',
timezone,
}} />
{<>
<PhotoDate {...{ photo, dateType, timezone }} />
{photoNeedsToBeSynced(photo) &&
<Tooltip
content={getPhotoSyncStatusText(photo)}
classNameTrigger={clsx(
'translate-y-1 ml-1.5',
'text-blue-600 dark:text-blue-400',
)}
supportMobile
/>}
</>}
</div>
</div>
<div className={clsx(
@ -113,6 +124,8 @@ export default function AdminPhotosTable({
className={opacityForPhotoId(photo.id)}
shouldConfirm
shouldToast
shouldScrollIntoViewOnExternalSync={
shouldScrollIntoViewOnExternalSync}
/>
{canDelete &&
<DeletePhotoButton

View File

@ -2,8 +2,10 @@ import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/components/icons/IconGrSync';
import { toastSuccess } from '@/toast';
import { ComponentProps, useState } from 'react';
import { ComponentProps, useRef, useState } from 'react';
import Tooltip from '@/components/Tooltip';
import clsx from 'clsx/lite';
import useScrollIntoView from '@/utility/useScrollIntoView';
export default function PhotoSyncButton({
photoId,
@ -15,6 +17,7 @@ export default function PhotoSyncButton({
disabled,
shouldConfirm,
shouldToast,
shouldScrollIntoViewOnExternalSync,
}: {
photoId: string
photoTitle?: string
@ -23,7 +26,10 @@ export default function PhotoSyncButton({
hasAiTextGeneration?: boolean
shouldConfirm?: boolean
shouldToast?: boolean
shouldScrollIntoViewOnExternalSync?: boolean
} & ComponentProps<typeof LoaderButton>) {
const ref = useRef<HTMLButtonElement>(null);
const [isSyncing, setIsSyncing] = useState(false);
const confirmText = ['Overwrite'];
@ -33,10 +39,18 @@ export default function PhotoSyncButton({
'AI text will be generated for undefined fields.'); }
confirmText.push('This action cannot be undone.');
useScrollIntoView({
ref,
shouldScrollIntoView:
isSyncingExternal &&
shouldScrollIntoViewOnExternalSync,
});
return (
<Tooltip content="Regenerate photo data">
<LoaderButton
className={className}
ref={ref}
className={clsx('scroll-mt-8', className)}
icon={<IconGrSync
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 { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { getInsightsIndicatorStatus } from '@/admin/insights/server';
import {
getPhotosMeta,
getUniqueTags,
getUniqueRecipes,
getPhotosInNeedOfSyncCount,
} from '@/photo/db/query';
import {
getGitHubMetaForCurrentApp,
indicatorStatusForSignificantInsights,
} from './insights';
export type AdminData = Awaited<ReturnType<typeof getAdminDataAction>>;
@ -21,10 +25,11 @@ export const getAdminDataAction = async () =>
const [
photosCount,
photosCountHidden,
photosCountNeedSync,
codeMeta,
uploadsCount,
tagsCount,
recipesCount,
insightsIndicatorStatus,
] = await Promise.all([
getPhotosMeta()
.then(({ count }) => count)
@ -32,6 +37,8 @@ export const getAdminDataAction = async () =>
getPhotosMeta({ hidden: 'only' })
.then(({ count }) => count)
.catch(() => 0),
getPhotosInNeedOfSyncCount(),
getGitHubMetaForCurrentApp(),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)
.catch(e => {
@ -44,9 +51,13 @@ export const getAdminDataAction = async () =>
getUniqueRecipes()
.then(recipes => recipes.length)
.catch(() => 0),
getInsightsIndicatorStatus(),
]);
const insightsIndicatorStatus = indicatorStatusForSignificantInsights({
codeMeta,
photosCountNeedSync,
});
const photosCountTotal = (
photosCount !== undefined &&
photosCountHidden !== undefined
@ -57,12 +68,13 @@ export const getAdminDataAction = async () =>
return {
photosCount,
photosCountHidden,
photosCountNeedSync,
photosCountTotal,
uploadsCount,
tagsCount,
recipesCount,
insightsIndicatorStatus,
};
} as const;
});
const scanForError = (

View File

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

View File

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

View File

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

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

View File

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

View File

@ -2,9 +2,36 @@
import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ReactNode } from 'react';
import {
ButtonHTMLAttributes,
ComponentProps,
ReactNode,
RefObject,
} from 'react';
import Tooltip from '../Tooltip';
export default function LoaderButton(props: {
export default function LoaderButton({
ref,
children,
isLoading,
icon,
spinnerColor,
spinnerClassName,
styleAs = 'button',
hideTextOnMobile = true,
confirmText,
shouldPreventDefault,
primary,
hideFocusOutline,
type = 'button',
onClick,
disabled,
className,
tooltip,
tooltipColor,
...rest
}: {
ref?: RefObject<HTMLButtonElement | null>
isLoading?: boolean
icon?: ReactNode
spinnerColor?: SpinnerColor
@ -15,29 +42,13 @@ export default function LoaderButton(props: {
shouldPreventDefault?: boolean
primary?: boolean
hideFocusOutline?: boolean
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const {
children,
isLoading,
icon,
spinnerColor,
spinnerClassName,
styleAs = 'button',
hideTextOnMobile = true,
confirmText,
shouldPreventDefault,
primary,
hideFocusOutline,
type = 'button',
onClick,
disabled,
className,
...rest
} = props;
return (
const button =
<button
{...rest}
ref={ref}
type={type}
onClick={e => {
if (shouldPreventDefault) { e.preventDefault(); }
@ -86,6 +97,13 @@ export default function LoaderButton(props: {
)}>
{children}
</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
// - update blur data (or destroy if blur is disabled)
// - generate AI text data, if enabled, and auto-generated fields are empty
export const syncPhotoAction = async (photoId: string) =>
export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId ?? '', true);
@ -414,7 +414,8 @@ export const syncPhotoAction = async (photoId: string) =>
semanticDescription: aiSemanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
AI_TEXT_AUTO_GENERATED_FIELDS,
photo.syncStatus.missingAiTextFields,
isBatch,
);
const formDataFromPhoto = convertPhotoToFormData(photo);
@ -451,7 +452,7 @@ export const syncPhotoAction = async (photoId: string) =>
export const syncPhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
await syncPhotoAction(photoId);
await syncPhotoAction(photoId, true);
}
revalidateAllKeysAndPaths();
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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 { useEffect } from 'react';
import { useState, useEffect } from 'react';
export default function useVisualViewportHeight() {
const [viewportHeight, setViewportHeight] = useState<number>();