From 82acdca068cb53355f74729bdfa79da64be7d037 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 18 Apr 2025 19:10:39 -0500 Subject: [PATCH 01/21] Elevate AI fields in admin config --- src/admin/AdminAppConfigurationClient.tsx | 36 +++++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index c5d33cdb..93501daa 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -30,6 +30,7 @@ import AdminLink from './AdminLink'; import ScoreCardContainer from '@/components/ScoreCardContainer'; import { capitalize, deparameterize } from '@/utility/string'; import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; +import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai'; export default function AdminAppConfigurationClient({ // Storage @@ -365,6 +366,28 @@ export default function AdminAppConfigurationClient({ and improve accessibility: {renderEnvVars(['OPENAI_SECRET_KEY'])} + + {hasAiTextAutoGeneratedFields && + AI_AUTO_GENERATED_FIELDS_ALL.map(field => + + {renderSubStatus( + aiTextAutoGeneratedFields.includes(field) + ? 'checked' + : 'optional', + capitalize(field), + )} + )} + Comma-separated fields to auto-generate when + uploading photos. Accepted values: title, caption, + tags, description, all, or none + {' '} + (default: {'"title,tags,semantic"'}): + {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} + - - Comma-separated fields to auto-generate when - uploading photos. Accepted values: title, caption, - tags, description, all, or none - {' '} - (default: {'"title,tags,semantic"'}): - {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} - Date: Fri, 18 Apr 2025 22:23:46 -0500 Subject: [PATCH 02/21] Screen photos for missing AI text --- src/admin/AdminPhotoMenu.tsx | 3 +-- src/photo/db/query.ts | 2 +- src/photo/index.ts | 10 +++++++--- src/photo/outdated.ts | 13 ------------- src/photo/sync.ts | 28 ++++++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 19 deletions(-) delete mode 100644 src/photo/outdated.ts create mode 100644 src/photo/sync.ts diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index 7ce6a06b..c8c69221 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -21,7 +21,6 @@ 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'; @@ -79,7 +78,7 @@ export default function AdminPhotoMenu({ label: 'Sync', labelComplex: Sync - {isPhotoOutdated(photo) && + {photo.needsSync && { exposureCompensationFormatted?: string takenAtNaiveFormatted: string recipeData?: FujifilmRecipe + needsSync?: boolean } export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { const photoDb = camelcaseKeys( photoDbRaw as unknown as Record, ) as unknown as PhotoDb; - return { + const photo: Photo ={ ...photoDb, tags: photoDb.tags ?? [], focalLengthFormatted: @@ -130,15 +132,17 @@ 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), }; + photo.needsSync = doesPhotoNeedSync(photo); + return photo; }; export const parseCachedPhotoDates = (photo: Photo) => ({ diff --git a/src/photo/outdated.ts b/src/photo/outdated.ts deleted file mode 100644 index 31d1fdfe..00000000 --- a/src/photo/outdated.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; -import { Photo } from '.'; - -export const UPDATED_BEFORE_01 = new Date('2024-06-16'); -// UTC 2025-02-24 05:30:00 -export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0)); - -export const isPhotoOutdated = (photo: Photo) => { - return photo.updatedAt < UPDATED_BEFORE_01 || ( - photo.updatedAt < UPDATED_BEFORE_02 && - photo.make === MAKE_FUJIFILM - ); -}; diff --git a/src/photo/sync.ts b/src/photo/sync.ts new file mode 100644 index 00000000..f4a9d311 --- /dev/null +++ b/src/photo/sync.ts @@ -0,0 +1,28 @@ +import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; +import { Photo } from '.'; +import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; + +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: Photo) => + photo.updatedAt < UPDATED_BEFORE_01 || ( + photo.updatedAt < UPDATED_BEFORE_02 && + photo.make === MAKE_FUJIFILM + ); + +const doesPhotoNeedAiText = ({ + title, + caption, + tags = [], + semanticDescription, +}: Photo) => + (AI_TEXT_AUTO_GENERATED_FIELDS.includes('title') && !title) || + (AI_TEXT_AUTO_GENERATED_FIELDS.includes('caption') && !caption) || + (AI_TEXT_AUTO_GENERATED_FIELDS.includes('tags') && tags.length === 0) || + (AI_TEXT_AUTO_GENERATED_FIELDS.includes('semantic') && !semanticDescription); + +export const doesPhotoNeedSync = (photo: Photo) => + isPhotoOutdated(photo) || + doesPhotoNeedAiText(photo); From d36ae5c15af3802388406f0e86e3a51434f6377a Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 01:08:30 -0500 Subject: [PATCH 03/21] Create queries for missing AI text --- src/photo/db/query.ts | 57 +++++++++++++++++++++++++++++++++++++++---- src/photo/sync.ts | 2 ++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 267c143a..42681111 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -1,3 +1,4 @@ +/* eslint-disable quotes */ import { sql, query, @@ -14,7 +15,11 @@ import { import { Cameras, createCameraKey } from '@/camera'; import { Tags } from '@/tag'; import { Films } from '@/film'; -import { ADMIN_SQL_DEBUG_ENABLED } from '@/app/config'; +import { + ADMIN_SQL_DEBUG_ENABLED, + AI_TEXT_AUTO_GENERATED_FIELDS, + AI_TEXT_GENERATION_ENABLED, +} from '@/app/config'; import { GetPhotosOptions, getLimitAndOffsetFromOptions, @@ -24,7 +29,11 @@ import { getWheresFromOptions } from '.'; import { FocalLengths } from '@/focal'; import { Lenses, createLensKey } from '@/lens'; import { migrationForError } from './migration'; -import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../sync'; +import { + SYNC_QUERY_LIMIT, + UPDATED_BEFORE_01, + UPDATED_BEFORE_02, +} from '../sync'; import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { Recipes } from '@/recipe'; @@ -568,10 +577,9 @@ 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 outdatedValues = [ @@ -585,7 +593,7 @@ export const getOutdatedPhotos = () => safelyQueryPhotos( SELECT * FROM photos ${outdatedWhereClause} ORDER BY created_at DESC - LIMIT 1000 + LIMIT ${SYNC_QUERY_LIMIT} `, outdatedValues, ) @@ -603,3 +611,42 @@ export const getOutdatedPhotosCount = () => safelyQueryPhotos( .then(({ rows }) => parseInt(rows[0].count, 10)), 'getOutdatedPhotosCount', ); + +const photosThatNeedAiTextWhereClause = ( + AI_TEXT_GENERATION_ENABLED && + AI_TEXT_AUTO_GENERATED_FIELDS.length +) + ? 'WHERE ' + 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 `array_length(tags, 1) = 0`; + case 'semantic': return `(semantic_description <> '') IS NOT TRUE`; + } + }).join(' OR ') + : undefined; + +export const getPhotosThatNeedAiText = () => safelyQueryPhotos( + async () => photosThatNeedAiTextWhereClause + ? query(` + SELECT * FROM photos + ${photosThatNeedAiTextWhereClause} + ORDER BY created_at DESC + LIMIT ${SYNC_QUERY_LIMIT} + `) + .then(({ rows }) => rows.map(parsePhotoFromDb)) + : [] as Photo[], + 'getPhotosThatNeedAiText', +); + +export const getPhotosThatNeedAiTextCount = () => safelyQueryPhotos( + async () => photosThatNeedAiTextWhereClause + ? query(` + SELECT COUNT(*) FROM photos + ${photosThatNeedAiTextWhereClause} + `) + .then(({ rows }) => parseInt(rows[0].count, 10)) + : 0, + 'getPhotosThatNeedAiTextCount', +); diff --git a/src/photo/sync.ts b/src/photo/sync.ts index f4a9d311..47c76d32 100644 --- a/src/photo/sync.ts +++ b/src/photo/sync.ts @@ -2,6 +2,8 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { Photo } from '.'; import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; +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)); From 00932b6687e8789e47d65579b426c440d51780c3 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 11:23:32 -0500 Subject: [PATCH 04/21] Rename 'outdated' to 'sync,' change path --- README.md | 2 +- app/admin/{outdated => photos/sync}/page.tsx | 6 +++--- src/admin/AdminPhotosClient.tsx | 6 ++++-- ...edClient.tsx => AdminPhotosSyncClient.tsx} | 19 ++++++++----------- src/admin/insights/AdminAppInsightsClient.tsx | 8 ++++---- src/app/paths.ts | 2 +- 6 files changed, 21 insertions(+), 22 deletions(-) rename app/admin/{outdated => photos/sync}/page.tsx (67%) rename src/admin/{AdminOutdatedClient.tsx => AdminPhotosSyncClient.tsx} (89%) diff --git a/README.md b/README.md index 810bb866..1c03c07e 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider > There have been reports ([Issue 184](https://github.com/sambecker/exif-photo-blog/issues/184#issuecomment-2629474045) + [185](https://github.com/sambecker/exif-photo-blog/issues/185#issuecomment-2629478570)) that having large photos (over 30MB), or a CDN, e.g., Cloudflare in front of Vercel, may destabilize static optimization. #### Why don't my older photos look right? -> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the outdated photo page (`/admin/outdated`) to make batch updates. +> As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or use the photo sync page (`/admin/photos/sync`) to make batch 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. diff --git a/app/admin/outdated/page.tsx b/app/admin/photos/sync/page.tsx similarity index 67% rename from app/admin/outdated/page.tsx rename to app/admin/photos/sync/page.tsx index 4f012dca..2b103ce0 100644 --- a/app/admin/outdated/page.tsx +++ b/app/admin/photos/sync/page.tsx @@ -1,15 +1,15 @@ -import AdminOutdatedClient from '@/admin/AdminOutdatedClient'; +import AdminPhotosSyncClient from '@/admin/AdminPhotosSyncClient'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; import { getOutdatedPhotos } from '@/photo/db/query'; export const maxDuration = 60; -export default async function AdminOutdatedPage() { +export default async function AdminSyncPage() { const photos = await getOutdatedPhotos() .catch(() => []); return ( - diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 100567a8..734e53e2 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -5,7 +5,7 @@ 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_SYNC } from '@/app/paths'; import { Photo } from '@/photo'; import { StorageListResponse } from '@/platforms/storage'; import { LiaBroomSolid } from 'react-icons/lia'; @@ -53,11 +53,13 @@ export default function AdminPhotosClient({ {photosCountOutdated > 0 && } + // TODO: Add tooltip + // TODO: Use LinkWithStatus title={`${photosCountOutdated} Outdated Photos`} className={clsx( 'text-blue-600 dark:text-blue-400', diff --git a/src/admin/AdminOutdatedClient.tsx b/src/admin/AdminPhotosSyncClient.tsx similarity index 89% rename from src/admin/AdminOutdatedClient.tsx rename to src/admin/AdminPhotosSyncClient.tsx index 512c65f1..dee1f686 100644 --- a/src/admin/AdminOutdatedClient.tsx +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -15,7 +15,7 @@ import { LiaBroomSolid } from 'react-icons/lia'; const UPDATE_BATCH_SIZE_MAX = 4; -export default function AdminOutdatedClient({ +export default function AdminPhotosSyncClient({ photos, hasAiTextGeneration, }: { @@ -34,14 +34,9 @@ export default function AdminOutdatedClient({ - - Outdated ({photos.length}) - - - Outdated - - } + breadcrumb={ + Need Sync ({photos.length}) + } accessory={} @@ -84,9 +79,11 @@ export default function AdminOutdatedClient({ >
- {photos.length} outdated + {photos.length} {' '} - {photos.length === 1 ? 'photo' : 'photos'} found + {photos.length === 1 ? 'photo' : 'photos'} + {' '} + could benefit from being synced
Sync photos to import newer EXIF fields, improve blur data, {' '} diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index 9801b5ad..45e73690 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -28,7 +28,7 @@ import { import EnvVar from '@/components/EnvVar'; import { IoSyncCircle } from 'react-icons/io5'; import clsx from 'clsx/lite'; -import { PATH_ADMIN_OUTDATED } from '@/app/paths'; +import { PATH_ADMIN_PHOTOS_SYNC } from '@/app/paths'; import { LiaBroomSolid } from 'react-icons/lia'; import { IoMdGrid } from 'react-icons/io'; import { RiSpeedMiniLine } from 'react-icons/ri'; @@ -428,11 +428,11 @@ export default function AdminAppInsightsClient({ content={renderHighlightText( pluralize( photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, - 'outdated photo', - ), + 'photo', + ) + ' need to be synced', 'blue', )} - expandPath={PATH_ADMIN_OUTDATED} + expandPath={PATH_ADMIN_PHOTOS_SYNC} />} Date: Sat, 19 Apr 2025 15:00:24 -0500 Subject: [PATCH 05/21] Consolidate outdated/needs AI text sync statuses --- app/admin/photos/page.tsx | 5 +- app/admin/photos/sync/page.tsx | 4 +- src/admin/AdminPhotoMenu.tsx | 2 +- src/admin/AdminPhotosClient.tsx | 8 +- src/admin/AdminPhotosSyncClient.tsx | 4 +- src/admin/insights/AdminAppInsights.tsx | 10 +-- src/admin/insights/AdminAppInsightsClient.tsx | 10 +-- src/admin/insights/index.ts | 18 ++-- src/admin/insights/server.ts | 8 +- src/components/CopyButton.tsx | 20 ++--- src/components/primitives/LoaderButton.tsx | 56 ++++++++----- src/photo/db/query.ts | 83 +++++++------------ src/photo/index.ts | 14 ++-- src/photo/sync.ts | 18 ++-- 14 files changed, 123 insertions(+), 137 deletions(-) diff --git a/app/admin/photos/page.tsx b/app/admin/photos/page.tsx index 5749df9b..7b367166 100644 --- a/app/admin/photos/page.tsx +++ b/app/admin/photos/page.tsx @@ -1,11 +1,10 @@ import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache'; -import { getPhotos } from '@/photo/db/query'; +import { getPhotos, getPhotosInNeedOfSyncCount } from '@/photo/db/query'; import { getPhotosMetaCached } from '@/photo/cache'; import AdminPhotosClient from '@/admin/AdminPhotosClient'; import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; -import { getOutdatedPhotosCount } from '@/photo/db/query'; import { AI_TEXT_GENERATION_ENABLED, PRESERVE_ORIGINAL_UPLOADS, @@ -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() diff --git a/app/admin/photos/sync/page.tsx b/app/admin/photos/sync/page.tsx index 2b103ce0..877a4f02 100644 --- a/app/admin/photos/sync/page.tsx +++ b/app/admin/photos/sync/page.tsx @@ -1,11 +1,11 @@ import AdminPhotosSyncClient from '@/admin/AdminPhotosSyncClient'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; -import { getOutdatedPhotos } from '@/photo/db/query'; +import { getPhotosInNeedOfSync } from '@/photo/db/query'; export const maxDuration = 60; export default async function AdminSyncPage() { - const photos = await getOutdatedPhotos() + const photos = await getPhotosInNeedOfSync() .catch(() => []); return ( diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index c8c69221..99a71b62 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -78,7 +78,7 @@ export default function AdminPhotoMenu({ label: 'Sync', labelComplex: Sync - {photo.needsSync && + {(photo.syncStatus.isOutdated || photo.syncStatus.isMissingAiText) && } - // TODO: Add tooltip - // TODO: Use LinkWithStatus - title={`${photosCountOutdated} Outdated Photos`} + tooltip={( + pluralize(photosCountOutdated, 'photo') + + ' needs sync' + )} className={clsx( 'text-blue-600 dark:text-blue-400', 'border border-blue-200 dark:border-blue-800/60', diff --git a/src/admin/AdminPhotosSyncClient.tsx b/src/admin/AdminPhotosSyncClient.tsx index dee1f686..59e19095 100644 --- a/src/admin/AdminPhotosSyncClient.tsx +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -67,9 +67,7 @@ export default function AdminPhotosSyncClient({ > {arePhotoIdsSyncing ? 'Syncing' - : - Sync Next {updateBatchSize} Photos - } + : 'Sync All'} } >
diff --git a/src/admin/insights/AdminAppInsights.tsx b/src/admin/insights/AdminAppInsights.tsx index de07d2e9..d53a00dd 100644 --- a/src/admin/insights/AdminAppInsights.tsx +++ b/src/admin/insights/AdminAppInsights.tsx @@ -9,13 +9,13 @@ import { } from '@/photo/db/query'; import AdminAppInsightsClient from './AdminAppInsightsClient'; import { getAllInsights, getGitHubMetaForCurrentApp } from '.'; -import { getOutdatedPhotosCount } from '@/photo/db/query'; +import { getPhotosInNeedOfSyncCount } from '@/photo/db/query'; export default async function AdminAppInsights() { const [ { count: photosCount, dateRange }, { count: photosCountHidden }, - photosCountOutdated, + photosCountNeedSync, { count: photosCountPortrait }, codeMeta, cameras, @@ -27,7 +27,7 @@ export default async function AdminAppInsights() { ] = await Promise.all([ getPhotosMeta({ hidden: 'include' }), getPhotosMeta({ hidden: 'only' }), - getOutdatedPhotosCount(), + getPhotosInNeedOfSyncCount(), getPhotosMeta({ maximumAspectRatio: 0.9 }), getGitHubMetaForCurrentApp(), getUniqueCameras(), @@ -44,14 +44,14 @@ export default async function AdminAppInsights() { insights={getAllInsights({ codeMeta, photosCount, - photosCountOutdated, + photosCountNeedSync, photosCountPortrait, tagsCount: tags.length, })} photoStats={{ photosCount, photosCountHidden, - photosCountOutdated, + photosCountNeedSync, camerasCount: cameras.length, lensesCount: lenses.length, tagsCount: tags.length, diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index 45e73690..01969d8e 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -50,7 +50,7 @@ import { HiOutlineDocumentText } from 'react-icons/hi'; 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 +91,7 @@ export default function AdminAppInsightsClient({ photoStats: { photosCount, photosCountHidden, - photosCountOutdated, + photosCountNeedSync, camerasCount, lensesCount, tagsCount, @@ -114,7 +114,7 @@ export default function AdminAppInsightsClient({ noAiRateLimiting, noConfiguredDomain, noConfiguredMeta, - outdatedPhotos, + photosNeedSync, photoMatting, camerasFirst, gridFirst, @@ -417,7 +417,7 @@ export default function AdminAppInsightsClient({ } - {(outdatedPhotos || debug) && } content={renderHighlightText( pluralize( - photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, + photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT, 'photo', ) + ' need to be synced', 'blue', diff --git a/src/admin/insights/index.ts b/src/admin/insights/index.ts index 65b22eb3..7837ea14 100644 --- a/src/admin/insights/index.ts +++ b/src/admin/insights/index.ts @@ -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> - photosCountOutdated: number + photosCountNeedSync: number }) => { const { isAiTextGenerationEnabled, @@ -95,7 +95,7 @@ export const getSignificantInsights = ({ forkBehind: Boolean(codeMeta?.isBehind), noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage, noConfiguredDomain: !hasDomain, - outdatedPhotos: Boolean(photosCountOutdated), + photosNeedSync: Boolean(photosCountNeedSync), }; }; @@ -106,19 +106,19 @@ export const indicatorStatusForSignificantInsights = ( 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 +127,7 @@ export const getAllInsights = ({ photosCountPortrait: number tagsCount: number }) => ({ - ...getSignificantInsights({ codeMeta, photosCountOutdated }), + ...getSignificantInsights({ codeMeta, photosCountNeedSync }), noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo, noAi: !AI_TEXT_GENERATION_ENABLED, noConfiguredMeta: diff --git a/src/admin/insights/server.ts b/src/admin/insights/server.ts index 3b453def..168c167b 100644 --- a/src/admin/insights/server.ts +++ b/src/admin/insights/server.ts @@ -1,4 +1,4 @@ -import { getOutdatedPhotosCount } from '@/photo/db/query'; +import { getPhotosInNeedOfSyncCount } from '@/photo/db/query'; import { getSignificantInsights, indicatorStatusForSignificantInsights, @@ -8,15 +8,15 @@ import { getGitHubMetaForCurrentApp } from '.'; export const getInsightsIndicatorStatus = async () => { const [ codeMeta, - photosCountOutdated, + photosCountNeedSync, ] = await Promise.all([ getGitHubMetaForCurrentApp(), - getOutdatedPhotosCount(), + getPhotosInNeedOfSyncCount(), ]); const significantInsights = getSignificantInsights({ codeMeta, - photosCountOutdated, + photosCountNeedSync, }); return indicatorStatusForSignificantInsights(significantInsights); diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index e0743f16..5c6dd3ba 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -2,7 +2,6 @@ import { BiCopy } from 'react-icons/bi'; import LoaderButton from './primitives/LoaderButton'; import clsx from 'clsx/lite'; import { toastSuccess } from '@/toast'; -import Tooltip from './Tooltip'; import { ComponentProps } from 'react'; export default function CopyButton({ @@ -10,20 +9,18 @@ export default function CopyButton({ text, subtle, iconSize = 15, - tooltip, - tooltipColor, className, + ...props }: { label: string text?: string, subtle?: boolean iconSize?: number - tooltip?: string - tooltipColor?: ComponentProps['color'] className?: string -}) { - const button = +} & ComponentProps) { + return ( } className={clsx( subtle && 'text-gray-300 dark:text-gray-700', @@ -37,13 +34,6 @@ export default function CopyButton({ : undefined} styleAs="link" disabled={!text} - />; - - return ( - tooltip - ? - {button} - - : button + /> ); } diff --git a/src/components/primitives/LoaderButton.tsx b/src/components/primitives/LoaderButton.tsx index fa105096..d865c945 100644 --- a/src/components/primitives/LoaderButton.tsx +++ b/src/components/primitives/LoaderButton.tsx @@ -2,9 +2,29 @@ import Spinner, { SpinnerColor } from '@/components/Spinner'; import { clsx } from 'clsx/lite'; -import { ButtonHTMLAttributes, ReactNode } from 'react'; +import { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'; +import Tooltip from '../Tooltip'; -export default function LoaderButton(props: { +export default function LoaderButton({ + children, + isLoading, + icon, + spinnerColor, + spinnerClassName, + styleAs = 'button', + hideTextOnMobile = true, + confirmText, + shouldPreventDefault, + primary, + hideFocusOutline, + type = 'button', + onClick, + disabled, + className, + tooltip, + tooltipColor, + ...rest +}: { isLoading?: boolean icon?: ReactNode spinnerColor?: SpinnerColor @@ -15,27 +35,10 @@ export default function LoaderButton(props: { shouldPreventDefault?: boolean primary?: boolean hideFocusOutline?: boolean + tooltip?: string + tooltipColor?: ComponentProps['color'] } & ButtonHTMLAttributes) { - const { - children, - isLoading, - icon, - spinnerColor, - spinnerClassName, - styleAs = 'button', - hideTextOnMobile = true, - confirmText, - shouldPreventDefault, - primary, - hideFocusOutline, - type = 'button', - onClick, - disabled, - className, - ...rest - } = props; - - return ( + const button = + ; + + return ( + tooltip + ? + {button} + + : button ); } diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 42681111..246befa3 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -579,44 +579,22 @@ export const getPhoto = async ( // Sync queries -const outdatedWhereClause = - `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( - () => query(` - SELECT * FROM photos - ${outdatedWhereClause} - ORDER BY created_at DESC - LIMIT ${SYNC_QUERY_LIMIT} - `, - outdatedValues, - ) - .then(({ rows }) => rows.map(parsePhotoFromDb)), - 'getOutdatedPhotos', -); - -export const getOutdatedPhotosCount = () => safelyQueryPhotos( - () => query(` - SELECT COUNT(*) FROM photos - ${outdatedWhereClause} - `, - outdatedValues, - ) - .then(({ rows }) => parseInt(rows[0].count, 10)), - 'getOutdatedPhotosCount', -); - -const photosThatNeedAiTextWhereClause = ( +const needsAiTextWhereClauses = ( AI_TEXT_GENERATION_ENABLED && AI_TEXT_AUTO_GENERATED_FIELDS.length ) - ? 'WHERE ' + AI_TEXT_AUTO_GENERATED_FIELDS + ? AI_TEXT_AUTO_GENERATED_FIELDS .map(field => { switch (field) { case 'title': return `(title <> '') IS NOT TRUE`; @@ -624,29 +602,32 @@ const photosThatNeedAiTextWhereClause = ( case 'tags': return `array_length(tags, 1) = 0`; case 'semantic': return `(semantic_description <> '') IS NOT TRUE`; } - }).join(' OR ') - : undefined; + }) + : []; -export const getPhotosThatNeedAiText = () => safelyQueryPhotos( - async () => photosThatNeedAiTextWhereClause - ? query(` - SELECT * FROM photos - ${photosThatNeedAiTextWhereClause} - ORDER BY created_at DESC - LIMIT ${SYNC_QUERY_LIMIT} - `) - .then(({ rows }) => rows.map(parsePhotoFromDb)) - : [] as Photo[], - 'getPhotosThatNeedAiText', +const needsSyncWhereStatement = + `WHERE ${outdatedWhereClauses.concat(needsAiTextWhereClauses).join(' OR ')}`; + +export const getPhotosInNeedOfSync = () => safelyQueryPhotos( + () => query(` + SELECT * FROM photos + ${needsSyncWhereStatement} + ORDER BY created_at DESC + LIMIT ${SYNC_QUERY_LIMIT} + `, + outdatedWhereValues, + ) + .then(({ rows }) => rows.map(parsePhotoFromDb)), + 'getPhotosInNeedOfSync', ); -export const getPhotosThatNeedAiTextCount = () => safelyQueryPhotos( - async () => photosThatNeedAiTextWhereClause - ? query(` - SELECT COUNT(*) FROM photos - ${photosThatNeedAiTextWhereClause} - `) - .then(({ rows }) => parseInt(rows[0].count, 10)) - : 0, - 'getPhotosThatNeedAiTextCount', +export const getPhotosInNeedOfSyncCount = () => safelyQueryPhotos( + () => query(` + SELECT COUNT(*) FROM photos + ${needsSyncWhereStatement} + `, + outdatedWhereValues, + ) + .then(({ rows }) => parseInt(rows[0].count, 10)), + 'getPhotosInNeedOfSyncCount', ); diff --git a/src/photo/index.ts b/src/photo/index.ts index e03c97cd..fba70537 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -23,7 +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 { doesPhotoNeedSync } from './sync'; +import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync'; // INFINITE SCROLL: FEED export const INFINITE_SCROLL_FEED_INITIAL = @@ -97,7 +97,7 @@ export interface PhotoDb extends updatedAt: Date createdAt: Date takenAt: Date - tags: string[] + tags?: string[] } // Parsed db response @@ -109,15 +109,16 @@ export interface Photo extends Omit { exposureTimeFormatted?: string exposureCompensationFormatted?: string takenAtNaiveFormatted: string + tags: string[] recipeData?: FujifilmRecipe - needsSync?: boolean + syncStatus: PhotoSyncStatus } export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { const photoDb = camelcaseKeys( photoDbRaw as unknown as Record, ) as unknown as PhotoDb; - const photo: Photo ={ + return { ...photoDb, tags: photoDb.tags ?? [], focalLengthFormatted: @@ -140,9 +141,8 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => { ? JSON.parse(photoDb.recipeData) : photoDb.recipeData : undefined, - }; - photo.needsSync = doesPhotoNeedSync(photo); - return photo; + syncStatus: generatePhotoSyncStatus(photoDb), + } as Photo; }; export const parseCachedPhotoDates = (photo: Photo) => ({ diff --git a/src/photo/sync.ts b/src/photo/sync.ts index 47c76d32..4fb6d14e 100644 --- a/src/photo/sync.ts +++ b/src/photo/sync.ts @@ -1,14 +1,19 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; -import { Photo } from '.'; +import { PhotoDb } from '.'; import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; +export interface PhotoSyncStatus { + isOutdated: boolean; + isMissingAiText: boolean; +} + 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: Photo) => +const isPhotoOutdated = (photo: PhotoDb) => photo.updatedAt < UPDATED_BEFORE_01 || ( photo.updatedAt < UPDATED_BEFORE_02 && photo.make === MAKE_FUJIFILM @@ -19,12 +24,13 @@ const doesPhotoNeedAiText = ({ caption, tags = [], semanticDescription, -}: Photo) => +}: PhotoDb) => (AI_TEXT_AUTO_GENERATED_FIELDS.includes('title') && !title) || (AI_TEXT_AUTO_GENERATED_FIELDS.includes('caption') && !caption) || (AI_TEXT_AUTO_GENERATED_FIELDS.includes('tags') && tags.length === 0) || (AI_TEXT_AUTO_GENERATED_FIELDS.includes('semantic') && !semanticDescription); -export const doesPhotoNeedSync = (photo: Photo) => - isPhotoOutdated(photo) || - doesPhotoNeedAiText(photo); +export const generatePhotoSyncStatus = (photo: PhotoDb): PhotoSyncStatus => ({ + isOutdated: isPhotoOutdated(photo), + isMissingAiText: doesPhotoNeedAiText(photo), +}); From 5f2a979a114e26c551c0e836417905ba0f69609b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 15:23:11 -0500 Subject: [PATCH 06/21] Surface granular photo sync status text --- src/admin/AdminPhotosClient.tsx | 4 ++-- src/admin/AdminPhotosSyncClient.tsx | 8 ++++---- src/admin/AdminPhotosTable.tsx | 25 ++++++++++++++++++------- src/components/Tooltip.tsx | 2 +- src/components/icons/IconBroom.tsx | 6 ++++++ src/photo/sync.ts | 16 +++++++++++++++- 6 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 src/components/icons/IconBroom.tsx diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 10adbec8..07d3e251 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -8,12 +8,12 @@ import PathLoaderButton from '@/components/primitives/PathLoaderButton'; import { PATH_ADMIN_PHOTOS_SYNC } 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'; export default function AdminPhotosClient({ photos, @@ -55,7 +55,7 @@ export default function AdminPhotosClient({ {photosCountOutdated > 0 && } diff --git a/src/admin/AdminPhotosSyncClient.tsx b/src/admin/AdminPhotosSyncClient.tsx index 59e19095..b55ee092 100644 --- a/src/admin/AdminPhotosSyncClient.tsx +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -34,8 +34,8 @@ export default function AdminPhotosSyncClient({ - Need Sync ({photos.length}) + breadcrumb={ + Needs Sync ({photos.length}) } accessory={ Sync photos to import newer EXIF fields, improve blur data, {' '} - and leverage AI-generated text where possible + and generate AI text when configured
@@ -95,7 +95,7 @@ export default function AdminPhotosSyncClient({ hasAiTextGeneration={hasAiTextGeneration} canEdit={false} canDelete={false} - showUpdatedAt + dateType="updatedAt" />
diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 0c93e850..85e82acc 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -15,6 +15,8 @@ import PhotoSyncButton from './PhotoSyncButton'; import DeletePhotoButton from './DeletePhotoButton'; import { Timezone } from '@/utility/timezone'; import IconHidden from '@/components/icons/IconHidden'; +import Tooltip from '@/components/Tooltip'; +import { photoHasSyncStatusText, photoSyncStatusText } from '@/photo/sync'; export default function AdminPhotosTable({ photos, @@ -22,7 +24,7 @@ export default function AdminPhotosTable({ revalidatePhoto, photoIdsSyncing = [], hasAiTextGeneration, - showUpdatedAt, + dateType = 'createdAt', canEdit = true, canDelete = true, timezone, @@ -32,7 +34,7 @@ export default function AdminPhotosTable({ revalidatePhoto?: RevalidatePhoto photoIdsSyncing?: string[] hasAiTextGeneration: boolean - showUpdatedAt?: boolean + dateType?: 'createdAt' | 'updatedAt' canEdit?: boolean canDelete?: boolean timezone?: Timezone @@ -90,11 +92,20 @@ export default function AdminPhotosTable({ 'lg:w-[50%] uppercase', 'text-dim', )}> - + {<> + + {photoHasSyncStatusText(photo) && } + }
- {children ?? } + {children ?? } ); } diff --git a/src/components/icons/IconBroom.tsx b/src/components/icons/IconBroom.tsx new file mode 100644 index 00000000..fbb7c29e --- /dev/null +++ b/src/components/icons/IconBroom.tsx @@ -0,0 +1,6 @@ +import { IconBaseProps } from 'react-icons'; +import { LiaBroomSolid } from 'react-icons/lia'; + +export default function IconBroom(props: IconBaseProps) { + return ; +} diff --git a/src/photo/sync.ts b/src/photo/sync.ts index 4fb6d14e..d28dcbce 100644 --- a/src/photo/sync.ts +++ b/src/photo/sync.ts @@ -1,5 +1,5 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; -import { PhotoDb } from '.'; +import { Photo, PhotoDb } from '.'; import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; export interface PhotoSyncStatus { @@ -34,3 +34,17 @@ export const generatePhotoSyncStatus = (photo: PhotoDb): PhotoSyncStatus => ({ isOutdated: isPhotoOutdated(photo), isMissingAiText: doesPhotoNeedAiText(photo), }); + +export const photoHasSyncStatusText = (photo: Photo) => + photo.syncStatus.isOutdated || photo.syncStatus.isMissingAiText; + +export const photoSyncStatusText = (photo: Photo) => { + const { isOutdated, isMissingAiText } = photo.syncStatus; + const text: string[] = []; + if (isOutdated) { + text.push('Outdated'); + } else if (isMissingAiText) { + text.push('Missing AI Text'); + } + return text.join(' and '); +}; From fa94b707de342545390a88749bc4c72391c4129d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 16:46:41 -0500 Subject: [PATCH 07/21] Support mobile sync status tooltips --- src/admin/AdminPhotosTable.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 85e82acc..71ab56d7 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -98,13 +98,15 @@ export default function AdminPhotosTable({ dateType, timezone, }} /> - {photoHasSyncStatusText(photo) && } + {photoHasSyncStatusText(photo) && + } }
From eea8f94eea8af1113bad5e1864a815f9290df0f2 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 23:25:01 -0500 Subject: [PATCH 08/21] Refine photo sync status checks --- src/admin/AdminPhotosSyncClient.tsx | 6 ++--- src/admin/AdminPhotosTable.tsx | 6 +---- src/components/Tooltip.tsx | 2 +- src/photo/actions.ts | 2 +- src/photo/db/query.ts | 2 +- src/photo/index.ts | 2 +- src/photo/sync.ts | 41 +++++++++++++++++++---------- 7 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/admin/AdminPhotosSyncClient.tsx b/src/admin/AdminPhotosSyncClient.tsx index b55ee092..94479816 100644 --- a/src/admin/AdminPhotosSyncClient.tsx +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -2,7 +2,6 @@ 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'; @@ -12,6 +11,7 @@ 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'; const UPDATE_BATCH_SIZE_MAX = 4; @@ -37,7 +37,7 @@ export default function AdminPhotosSyncClient({ breadcrumb={ Needs Sync ({photos.length}) } - accessory={} hideTextOnMobile={false} @@ -68,7 +68,7 @@ export default function AdminPhotosSyncClient({ {arePhotoIdsSyncing ? 'Syncing' : 'Sync All'} - } + } >
{<> - + {photoHasSyncStatusText(photo) && - {children ?? } + {children ?? } ); } diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 6513ba29..3ec326bd 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -414,7 +414,7 @@ export const syncPhotoAction = async (photoId: string) => semanticDescription: aiSemanticDescription, } = await generateAiImageQueries( imageResizedBase64, - AI_TEXT_AUTO_GENERATED_FIELDS, + photo.syncStatus.missingAiTextFields, ); const formDataFromPhoto = convertPhotoToFormData(photo); diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 246befa3..07e73777 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -599,7 +599,7 @@ const needsAiTextWhereClauses = ( switch (field) { case 'title': return `(title <> '') IS NOT TRUE`; case 'caption': return `(caption <> '') IS NOT TRUE`; - case 'tags': return `array_length(tags, 1) = 0`; + case 'tags': return `(tags IS NULL OR array_length(tags, 1) = 0)`; case 'semantic': return `(semantic_description <> '') IS NOT TRUE`; } }) diff --git a/src/photo/index.ts b/src/photo/index.ts index fba70537..a93c3675 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -97,7 +97,7 @@ export interface PhotoDb extends updatedAt: Date createdAt: Date takenAt: Date - tags?: string[] + tags: string[] | null } // Parsed db response diff --git a/src/photo/sync.ts b/src/photo/sync.ts index d28dcbce..657bce5c 100644 --- a/src/photo/sync.ts +++ b/src/photo/sync.ts @@ -1,10 +1,11 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { Photo, PhotoDb } from '.'; import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; +import { AiAutoGeneratedField } from './ai'; export interface PhotoSyncStatus { isOutdated: boolean; - isMissingAiText: boolean; + missingAiTextFields: AiAutoGeneratedField[]; } export const SYNC_QUERY_LIMIT = 1000; @@ -19,32 +20,44 @@ const isPhotoOutdated = (photo: PhotoDb) => photo.make === MAKE_FUJIFILM ); -const doesPhotoNeedAiText = ({ +const getMissingAiTextFields = ({ title, caption, - tags = [], + tags, semanticDescription, -}: PhotoDb) => - (AI_TEXT_AUTO_GENERATED_FIELDS.includes('title') && !title) || - (AI_TEXT_AUTO_GENERATED_FIELDS.includes('caption') && !caption) || - (AI_TEXT_AUTO_GENERATED_FIELDS.includes('tags') && tags.length === 0) || - (AI_TEXT_AUTO_GENERATED_FIELDS.includes('semantic') && !semanticDescription); +}: PhotoDb | Photo): AiAutoGeneratedField[] => + 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), - isMissingAiText: doesPhotoNeedAiText(photo), + missingAiTextFields: getMissingAiTextFields(photo), }); export const photoHasSyncStatusText = (photo: Photo) => - photo.syncStatus.isOutdated || photo.syncStatus.isMissingAiText; + photo.syncStatus.isOutdated || + photo.syncStatus.missingAiTextFields.length > 0; export const photoSyncStatusText = (photo: Photo) => { - const { isOutdated, isMissingAiText } = photo.syncStatus; + const { isOutdated, missingAiTextFields } = photo.syncStatus; const text: string[] = []; if (isOutdated) { - text.push('Outdated'); - } else if (isMissingAiText) { - text.push('Missing AI Text'); + 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 '); }; From a2a0c30e87379d6543032e76f0741da00564c2bc Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 19 Apr 2025 23:32:41 -0500 Subject: [PATCH 09/21] Alter sync checks --- src/admin/AdminPhotoMenu.tsx | 3 ++- src/admin/AdminPhotosTable.tsx | 4 ++-- src/photo/sync.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index 99a71b62..1b82aeda 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -24,6 +24,7 @@ import IconGrSync from '@/components/icons/IconGrSync'; 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, @@ -78,7 +79,7 @@ export default function AdminPhotoMenu({ label: 'Sync', labelComplex: Sync - {(photo.syncStatus.isOutdated || photo.syncStatus.isMissingAiText) && + {photoNeedsToBeSynced(photo) && {<> - {photoHasSyncStatusText(photo) && + {photoNeedsToBeSynced(photo) && ({ missingAiTextFields: getMissingAiTextFields(photo), }); -export const photoHasSyncStatusText = (photo: Photo) => +export const photoNeedsToBeSynced = (photo: Photo) => photo.syncStatus.isOutdated || photo.syncStatus.missingAiTextFields.length > 0; From 9169391514e90fa6135f35d44db328489c1bd7ae Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 20 Apr 2025 00:04:08 -0500 Subject: [PATCH 10/21] Only flag photos with missing text when AI is enabled --- src/photo/db/query.ts | 26 ++++++++++++-------------- src/photo/sync.ts | 31 ++++++++++++++++++------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts index 07e73777..64f306d0 100644 --- a/src/photo/db/query.ts +++ b/src/photo/db/query.ts @@ -590,20 +590,18 @@ const outdatedWhereValues = [ MAKE_FUJIFILM, ]; -const needsAiTextWhereClauses = ( - AI_TEXT_GENERATION_ENABLED && - AI_TEXT_AUTO_GENERATED_FIELDS.length -) - ? 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 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 ')}`; diff --git a/src/photo/sync.ts b/src/photo/sync.ts index c1a4d910..d801c0ec 100644 --- a/src/photo/sync.ts +++ b/src/photo/sync.ts @@ -1,6 +1,9 @@ import { MAKE_FUJIFILM } from '@/platforms/fujifilm'; import { Photo, PhotoDb } from '.'; -import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config'; +import { + AI_TEXT_AUTO_GENERATED_FIELDS, + AI_TEXT_GENERATION_ENABLED, +} from '@/app/config'; import { AiAutoGeneratedField } from './ai'; export interface PhotoSyncStatus { @@ -26,18 +29,20 @@ const getMissingAiTextFields = ({ tags, semanticDescription, }: PhotoDb | Photo): AiAutoGeneratedField[] => - 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[]); + 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), From 5fe45872dfc4144bcc00683560d18495479242c6 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 20 Apr 2025 00:12:51 -0500 Subject: [PATCH 11/21] Refine sync insight text --- src/admin/insights/AdminAppInsightsClient.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index 01969d8e..550d876e 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -425,13 +425,17 @@ export default function AdminAppInsightsClient({ TEXT_COLOR_BLUE, )} />} - content={renderHighlightText( - pluralize( - photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT, - 'photo', - ) + ' need to be synced', - 'blue', - )} + content={<> + {renderHighlightText( + pluralize( + photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT, + 'photo', + ), + 'blue', + )} + {' '} + to sync + } expandPath={PATH_ADMIN_PHOTOS_SYNC} />} Date: Sun, 20 Apr 2025 00:13:18 -0500 Subject: [PATCH 12/21] Bump deps --- package.json | 10 +- pnpm-lock.yaml | 315 ++++++++++++++++++++++++++----------------------- 2 files changed, 171 insertions(+), 154 deletions(-) diff --git a/package.json b/package.json index 0113c130..5cdbddaa 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "@ai-sdk/openai": "^1.3.16", "@aws-sdk/client-s3": "3.787.0", "@aws-sdk/s3-request-presigner": "3.787.0", - "@radix-ui/react-dialog": "^1.1.7", - "@radix-ui/react-dropdown-menu": "^2.1.7", - "@radix-ui/react-tooltip": "^1.2.0", - "@radix-ui/react-visually-hidden": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.11", + "@radix-ui/react-tooltip": "^1.2.3", + "@radix-ui/react-visually-hidden": "^1.2.0", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.8", "@vercel/analytics": "^1.5.0", @@ -62,7 +62,7 @@ "@types/react-dom": "19.1.2", "@types/sanitize-html": "^2.15.0", "cross-fetch": "^4.1.0", - "eslint": "9.24.0", + "eslint": "9.25.0", "eslint-config-next": "15.3.1", "eslint-plugin-react-hooks": "^5.2.0", "jest": "^29.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42965596..dd57a200 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,17 +18,17 @@ importers: specifier: 3.787.0 version: 3.787.0 '@radix-ui/react-dialog': - specifier: ^1.1.7 - version: 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': - specifier: ^2.1.7 - version: 2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^2.1.11 + version: 2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tooltip': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-visually-hidden': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-visually-hidden': - specifier: ^1.1.3 - version: 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@upstash/ratelimit': specifier: ^2.0.5 version: 2.0.5(@upstash/redis@1.34.8) @@ -163,14 +163,14 @@ importers: specifier: ^4.1.0 version: 4.1.0 eslint: - specifier: 9.24.0 - version: 9.24.0(jiti@2.4.2) + specifier: 9.25.0 + version: 9.25.0(jiti@2.4.2) eslint-config-next: specifier: 15.3.1 - version: 15.3.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + version: 15.3.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.24.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.25.0(jiti@2.4.2)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.14.1)(ts-node@10.9.2(@types/node@22.14.1)(typescript@5.8.3)) @@ -598,28 +598,28 @@ packages: resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.2.0': - resolution: {integrity: sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==} + '@eslint/config-helpers@0.2.1': + resolution: {integrity: sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.12.0': - resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==} + '@eslint/core@0.13.0': + resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.24.0': - resolution: {integrity: sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==} + '@eslint/js@9.25.0': + resolution: {integrity: sha512-iWhsUS8Wgxz9AXNfvfOPFSW4VfMXdVhp1hjkZVhXCrpgh/aLcc45rX6MPu+tIVUWDw0HfNwth7O28M1xDxNf9w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.2.7': - resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==} + '@eslint/plugin-kit@0.2.8': + resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@fastify/busboy@2.1.1': @@ -952,8 +952,8 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} - '@radix-ui/react-arrow@1.1.3': - resolution: {integrity: sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==} + '@radix-ui/react-arrow@1.1.4': + resolution: {integrity: sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -965,8 +965,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.3': - resolution: {integrity: sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==} + '@radix-ui/react-collection@1.1.4': + resolution: {integrity: sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1005,8 +1005,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.7': - resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} + '@radix-ui/react-dialog@1.1.10': + resolution: {integrity: sha512-m6pZb0gEM5uHPSb+i2nKKGQi/HMSVjARMsLMWQfKDP+eJ6B+uqryHnXhpnohTWElw+vEcMk/o4wJODtdRKHwqg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1027,8 +1027,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.6': - resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} + '@radix-ui/react-dismissable-layer@1.1.7': + resolution: {integrity: sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1040,8 +1040,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.7': - resolution: {integrity: sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==} + '@radix-ui/react-dropdown-menu@2.1.11': + resolution: {integrity: sha512-wbPE3cFBfLl+S+LCxChWQGX0k14zUxgvep1HEnLhJ9mNhjyO3ETzRviAeKZ3XomT/iVRRZAWFsnFZ3N0wI8OmA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1062,8 +1062,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.3': - resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} + '@radix-ui/react-focus-scope@1.1.4': + resolution: {integrity: sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1093,8 +1093,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-menu@2.1.7': - resolution: {integrity: sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==} + '@radix-ui/react-menu@2.1.11': + resolution: {integrity: sha512-sbFI4Qaw02J0ogmR9tOMsSqsdrGNpUanlPYAqTE2JJafow8ecHtykg4fSTjNHBdDl4deiKMK+RhTEwyVhP7UDA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1106,8 +1106,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.3': - resolution: {integrity: sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==} + '@radix-ui/react-popper@1.2.4': + resolution: {integrity: sha512-3p2Rgm/a1cK0r/UVkx5F/K9v/EplfjAeIFCGOPYPO4lZ0jtg4iSQXt/YGTSLWaf4x7NG6Z4+uKFcylcTZjeqDA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1119,8 +1119,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.5': - resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} + '@radix-ui/react-portal@1.1.6': + resolution: {integrity: sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1158,8 +1158,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.0.3': - resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + '@radix-ui/react-primitive@2.1.0': + resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1171,8 +1171,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.3': - resolution: {integrity: sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==} + '@radix-ui/react-roving-focus@1.1.7': + resolution: {integrity: sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1202,8 +1202,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-tooltip@1.2.0': - resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} + '@radix-ui/react-tooltip@1.2.3': + resolution: {integrity: sha512-0KX7jUYFA02np01Y11NWkk6Ip6TqMNmD4ijLelYAzeIndl2aVeltjJFJ2gwjNa1P8U/dgjQ+8cr9Y3Ni+ZNoRA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1224,8 +1224,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.1.1': - resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1278,8 +1287,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.1.3': - resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} + '@radix-ui/react-visually-hidden@1.2.0': + resolution: {integrity: sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2489,8 +2498,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.24.0: - resolution: {integrity: sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==} + eslint@9.25.0: + resolution: {integrity: sha512-MsBdObhM4cEwkzCiraDv7A6txFXEqtNXOb877TsSp2FCkBNl8JfVQrmiuDqC1IkejT6JLPzYBXx/xAiYhyzgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -5077,9 +5086,9 @@ snapshots: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.4.1(eslint@9.24.0(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.4.1(eslint@9.25.0(jiti@2.4.2))': dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -5092,9 +5101,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.2.0': {} + '@eslint/config-helpers@0.2.1': {} - '@eslint/core@0.12.0': + '@eslint/core@0.13.0': dependencies: '@types/json-schema': 7.0.15 @@ -5112,13 +5121,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.24.0': {} + '@eslint/js@9.25.0': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.2.7': + '@eslint/plugin-kit@0.2.8': dependencies: - '@eslint/core': 0.12.0 + '@eslint/core': 0.13.0 levn: 0.4.1 '@fastify/busboy@2.1.1': {} @@ -5484,20 +5493,20 @@ snapshots: '@radix-ui/primitive@1.1.2': {} - '@radix-ui/react-arrow@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-arrow@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-collection@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-collection@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5523,20 +5532,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-dialog@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dialog@1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) aria-hidden: 1.2.4 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5551,11 +5560,11 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-dismissable-layer@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dismissable-layer@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 @@ -5564,15 +5573,15 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-dropdown-menu@2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-dropdown-menu@2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-menu': 2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5585,10 +5594,10 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-focus-scope@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-focus-scope@1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5610,22 +5619,22 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-menu@2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-menu@2.1.11(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) aria-hidden: 1.2.4 @@ -5636,13 +5645,13 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-popper@1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-popper@1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-arrow': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.2)(react@19.1.0) @@ -5654,9 +5663,9 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-portal@1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-portal@1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -5683,7 +5692,7 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-primitive@2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 @@ -5692,17 +5701,17 @@ snapshots: '@types/react': 19.1.2 '@types/react-dom': 19.1.2(@types/react@19.1.2) - '@radix-ui/react-roving-focus@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-roving-focus@1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-direction': 1.1.1(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5723,20 +5732,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-tooltip@1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-tooltip@1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popper': 1.2.4(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.6(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': 1.2.0(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -5749,9 +5758,17 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.1.2)(react@19.1.0)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.2)(react@19.1.0)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.2)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.2 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.2)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.2)(react@19.1.0) react: 19.1.0 optionalDependencies: '@types/react': 19.1.2 @@ -5789,9 +5806,9 @@ snapshots: optionalDependencies: '@types/react': 19.1.2 - '@radix-ui/react-visually-hidden@1.1.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-visually-hidden@1.2.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: @@ -6357,15 +6374,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.24.1 - '@typescript-eslint/type-utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.24.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -6374,14 +6391,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.24.1 '@typescript-eslint/types': 8.24.1 '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.24.1 debug: 4.4.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -6391,12 +6408,12 @@ snapshots: '@typescript-eslint/types': 8.24.1 '@typescript-eslint/visitor-keys': 8.24.1 - '@typescript-eslint/type-utils@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) debug: 4.4.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) ts-api-utils: 2.0.1(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -6418,13 +6435,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/utils@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.24.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.25.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.24.1 '@typescript-eslint/types': 8.24.1 '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -6778,7 +6795,7 @@ snapshots: cmdk@1.1.1(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.1.2)(react@19.1.0) - '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-dialog': 1.1.10(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-id': 1.1.0(@types/react@19.1.2)(react@19.1.0) '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 @@ -7109,19 +7126,19 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@15.3.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3): + eslint-config-next@15.3.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3): dependencies: '@next/eslint-plugin-next': 15.3.1 '@rushstack/eslint-patch': 1.10.5 - '@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + '@typescript-eslint/eslint-plugin': 8.24.1(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-react: 7.37.4(eslint@9.24.0(jiti@2.4.2)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-react: 7.37.4(eslint@9.25.0(jiti@2.4.2)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.25.0(jiti@2.4.2)) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -7137,33 +7154,33 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 enhanced-resolve: 5.18.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) get-tsconfig: 4.10.0 is-bun-module: 1.3.0 stable-hash: 0.0.4 tinyglobby: 0.2.11 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.24.0(jiti@2.4.2) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.1(eslint-plugin-import@2.31.0)(eslint@9.25.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -7172,9 +7189,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.24.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.1)(eslint@9.25.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7186,13 +7203,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.24.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.24.1(eslint@9.25.0(jiti@2.4.2))(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.25.0(jiti@2.4.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.8 @@ -7202,7 +7219,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -7211,11 +7228,11 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-react-hooks@5.2.0(eslint@9.25.0(jiti@2.4.2)): dependencies: - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) - eslint-plugin-react@7.37.4(eslint@9.24.0(jiti@2.4.2)): + eslint-plugin-react@7.37.4(eslint@9.25.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -7223,7 +7240,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.24.0(jiti@2.4.2) + eslint: 9.25.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -7246,16 +7263,16 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.24.0(jiti@2.4.2): + eslint@9.25.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.1(eslint@9.24.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.4.1(eslint@9.25.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.20.0 - '@eslint/config-helpers': 0.2.0 - '@eslint/core': 0.12.0 + '@eslint/config-helpers': 0.2.1 + '@eslint/core': 0.13.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.24.0 - '@eslint/plugin-kit': 0.2.7 + '@eslint/js': 9.25.0 + '@eslint/plugin-kit': 0.2.8 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.2 From b3dba5f6767a3c1edc6fe5cc97f629084702c573 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 20 Apr 2025 11:48:50 -0500 Subject: [PATCH 13/21] Increase AI rate limit window for batch requests --- src/photo/actions.ts | 5 +++-- src/photo/ai/server.ts | 6 ++++++ src/platforms/openai.ts | 24 +++++++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 3ec326bd..0768c850 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -374,7 +374,7 @@ export const getExifDataAction = async ( // - strip GPS data if necessary // - update blur data (or destroy if blur is disabled) // - generate AI text data, if enabled, and auto-generated fields are empty -export const syncPhotoAction = async (photoId: string) => +export const syncPhotoAction = async (photoId: string, isBatch?: boolean) => runAuthenticatedAdminServerAction(async () => { const photo = await getPhoto(photoId ?? '', true); @@ -415,6 +415,7 @@ export const syncPhotoAction = async (photoId: string) => } = await generateAiImageQueries( imageResizedBase64, photo.syncStatus.missingAiTextFields, + isBatch, ); const formDataFromPhoto = convertPhotoToFormData(photo); @@ -451,7 +452,7 @@ export const syncPhotoAction = async (photoId: string) => export const syncPhotosAction = async (photoIds: string[]) => runAuthenticatedAdminServerAction(async () => { for (const photoId of photoIds) { - await syncPhotoAction(photoId); + await syncPhotoAction(photoId, true); } revalidateAllKeysAndPaths(); }); diff --git a/src/photo/ai/server.ts b/src/photo/ai/server.ts index e1ac4130..d053a355 100644 --- a/src/photo/ai/server.ts +++ b/src/photo/ai/server.ts @@ -9,6 +9,7 @@ import { getUniqueTags } from '../db/query'; export const generateAiImageQueries = async ( imageBase64?: string, textFieldsToGenerate: AiAutoGeneratedField[] = [], + isBatch?: boolean, ): Promise<{ title?: string caption?: string @@ -31,6 +32,7 @@ export const generateAiImageQueries = async ( const titleAndCaption = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('title-and-caption'), + isBatch, ); if (titleAndCaption) { const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption); @@ -42,12 +44,14 @@ export const generateAiImageQueries = async ( title = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('title'), + isBatch, ); } if (textFieldsToGenerate.includes('caption')) { caption = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('caption'), + isBatch, ); } } @@ -57,6 +61,7 @@ export const generateAiImageQueries = async ( tags = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('tags', existingTags), + isBatch, ); } @@ -64,6 +69,7 @@ export const generateAiImageQueries = async ( semanticDescription = await generateOpenAiImageQuery( imageBase64, getAiImageQuery('description-small'), + isBatch, ); } } diff --git a/src/platforms/openai.ts b/src/platforms/openai.ts index 23fb55c5..64f1d7a0 100644 --- a/src/platforms/openai.ts +++ b/src/platforms/openai.ts @@ -13,7 +13,6 @@ import { cleanUpAiTextResponse } from '@/photo/ai'; const redis = HAS_REDIS_STORAGE ? Redis.fromEnv() : undefined; const RATE_LIMIT_IDENTIFIER = 'openai-image-query'; -const RATE_LIMIT_MAX_QUERIES_PER_HOUR = 100; const MODEL = 'gpt-4o'; const openai = AI_TEXT_GENERATION_ENABLED @@ -21,18 +20,24 @@ const openai = AI_TEXT_GENERATION_ENABLED : undefined; const ratelimit = redis - ? new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(RATE_LIMIT_MAX_QUERIES_PER_HOUR, '1h'), - }) + ? { + basic: new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(100, '1h'), + }), + batch: new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(1200, '1d'), + }), + } : undefined; -// Allows 100 requests per hour -const checkRateLimitAndThrow = async () => { +const checkRateLimitAndThrow = async (isBatch?: boolean) => { if (ratelimit) { let success = false; try { - success = (await ratelimit.limit(RATE_LIMIT_IDENTIFIER)).success; + const limiter = isBatch ? ratelimit.batch : ratelimit.basic; + success = (await limiter.limit(RATE_LIMIT_IDENTIFIER)).success; } catch (e: any) { console.error('Failed to rate limit OpenAI', e); throw new Error('Failed to rate limit OpenAI'); @@ -92,8 +97,9 @@ export const streamOpenAiImageQuery = async ( export const generateOpenAiImageQuery = async ( imageBase64: string, query: string, + isBatch?: boolean, ) => { - await checkRateLimitAndThrow(); + await checkRateLimitAndThrow(isBatch); const args = getImageTextArgs(imageBase64, query); From f8c0a46f2ffe20c314e33d41259bb7e38ac34f26 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 20 Apr 2025 12:33:51 -0500 Subject: [PATCH 14/21] Support pluses in lens makes --- src/photo/db/index.ts | 16 ++++++++++------ src/utility/string.ts | 10 +++++----- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts index f15d954c..a73a9591 100644 --- a/src/photo/db/index.ts +++ b/src/photo/db/index.ts @@ -7,13 +7,17 @@ import { Lens } from '@/lens'; export const GENERATE_STATIC_PARAMS_LIMIT = 1000; export const PHOTO_DEFAULT_LIMIT = 100; -// Trim whitespace -// Make lowercase -// Remove commas, slashes -// Replace spaces with dashes +const DB_PARAMETERIZE_REPLACEMENTS = [ + [',', ''], + ['/', ''], + ['+', '-'], + [' ', '-'], +]; + const parameterizeForDb = (field: string) => - // eslint-disable-next-line max-len - `REPLACE(REPLACE(REPLACE(LOWER(TRIM(${field})), ',', ''), '/', ''), ' ', '-')`; + DB_PARAMETERIZE_REPLACEMENTS.reduce((acc, [from, to]) => + `REPLACE(${acc}, '${from}', '${to}')` + , `LOWER(TRIM(${field}))`); export type GetPhotosOptions = { sortBy?: 'createdAt' | 'createdAtAsc' | 'takenAt' | 'priority' diff --git a/src/utility/string.ts b/src/utility/string.ts index b733b6c2..71b3b633 100644 --- a/src/utility/string.ts +++ b/src/utility/string.ts @@ -22,11 +22,11 @@ export const parameterize = ( ) => string .trim() - // Replaces spaces, underscores, slashes,and dashes with dashes - .replaceAll(/[\s_–—]/gi, '-') - // Removes punctuation - .replaceAll(/['"!@#$%^&*()_+=[\]{};:/?,<>\\/|`~]/gi, '') - // Removes all non-alphanumeric characters + // Replace spaces, underscores, slashes, pluses, dashes with dashes + .replaceAll(/[\s_–—+]/gi, '-') + // Remove punctuation + .replaceAll(/['"!@#$%^&*()=[\]{};:/?,<>\\/|`~]/gi, '') + // Removes non-alphanumeric characters, if configured .replaceAll( shouldRemoveNonAlphanumeric ? /([^a-z0-9-])/gi From fde890ed1794b52782f088282bee3720f071f769 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 20 Apr 2025 17:02:06 -0500 Subject: [PATCH 15/21] Finalize batch sync logic --- src/admin/AdminPhotosSyncClient.tsx | 85 +++++++++++++++------- src/admin/AdminPhotosTable.tsx | 6 +- src/admin/PhotoSyncButton.tsx | 18 ++++- src/components/primitives/LoaderButton.tsx | 10 ++- src/recipe/useRecipeOverlay.ts | 18 +++-- src/utility/useScrollIntoView.ts | 20 +++++ src/utility/useVisualViewport.ts | 4 +- 7 files changed, 121 insertions(+), 40 deletions(-) create mode 100644 src/utility/useScrollIntoView.ts diff --git a/src/admin/AdminPhotosSyncClient.tsx b/src/admin/AdminPhotosSyncClient.tsx index 94479816..1e17068c 100644 --- a/src/admin/AdminPhotosSyncClient.tsx +++ b/src/admin/AdminPhotosSyncClient.tsx @@ -6,14 +6,15 @@ 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 { 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'; -const UPDATE_BATCH_SIZE_MAX = 4; +const SYNC_BATCH_SIZE_MAX = 3; export default function AdminPhotosSyncClient({ photos, @@ -22,9 +23,14 @@ export default function AdminPhotosSyncClient({ photos: Photo[] hasAiTextGeneration: boolean }) { - const updateBatchSize = Math.min(UPDATE_BATCH_SIZE_MAX, photos.length); + // Use refs for non-reactive while loop state + const photoIdsToSync = useRef(photos.map(photo => photo.id)); + const errorRef = useRef(undefined); + // Use state for updating progress button and error UI const [photoIdsSyncing, setPhotoIdsSyncing] = useState([]); + const [error, setError] = useState(); + const [progress, setProgress] = useState(0); const arePhotoIdsSyncing = photoIdsSyncing.length > 0; @@ -41,36 +47,64 @@ export default function AdminPhotosSyncClient({ primary icon={} hideTextOnMobile={false} + progress={progress} 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([]); + if (window.confirm([ + 'Are you sure you want to sync', + photos.length === 1 + ? '1 outdated photo?' + : `all ${photos.length} outdated 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={!updateBatchSize} + disabled={photoIdsSyncing.length > 0} > {arePhotoIdsSyncing - ? 'Syncing' + ? 'Syncing ...' : 'Sync All'} } >
+ {error && + + Issue syncing: + + {' '} + {error.message} + } } @@ -81,11 +115,11 @@ export default function AdminPhotosSyncClient({ {' '} {photos.length === 1 ? 'photo' : 'photos'} {' '} - could benefit from being synced + found
- Sync photos to import newer EXIF fields, improve blur data, + Sync to capture newer EXIF fields, improve blur data, {' '} - and generate AI text when configured + and use AI to generate missing text (if configured)
@@ -96,6 +130,7 @@ export default function AdminPhotosSyncClient({ canEdit={false} canDelete={false} dateType="updatedAt" + shouldScrollIntoViewOnExternalSync />
diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index 937a70d4..af2459c3 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -28,6 +28,7 @@ export default function AdminPhotosTable({ canEdit = true, canDelete = true, timezone, + shouldScrollIntoViewOnExternalSync, }: { photos: Photo[], onLastPhotoVisible?: () => void @@ -38,6 +39,7 @@ export default function AdminPhotosTable({ canEdit?: boolean canDelete?: boolean timezone?: Timezone + shouldScrollIntoViewOnExternalSync?: boolean }) { const { invalidateSwr } = useAppState(); @@ -70,7 +72,7 @@ export default function AdminPhotosTable({ - {titleForPhoto(photo)} + {titleForPhoto(photo, false)} {photo.hidden && {' '} {canDelete && ) { + const ref = useRef(null); + const [isSyncing, setIsSyncing] = useState(false); const confirmText = ['Overwrite']; @@ -33,10 +39,18 @@ export default function PhotoSyncButton({ 'AI text will be generated for undefined fields.'); } confirmText.push('This action cannot be undone.'); + useScrollIntoView({ + ref, + shouldScrollIntoView: + isSyncingExternal && + shouldScrollIntoViewOnExternalSync, + }); + return ( } diff --git a/src/components/primitives/LoaderButton.tsx b/src/components/primitives/LoaderButton.tsx index d865c945..169d8809 100644 --- a/src/components/primitives/LoaderButton.tsx +++ b/src/components/primitives/LoaderButton.tsx @@ -2,10 +2,16 @@ import Spinner, { SpinnerColor } from '@/components/Spinner'; import { clsx } from 'clsx/lite'; -import { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'; +import { + ButtonHTMLAttributes, + ComponentProps, + ReactNode, + RefObject, +} from 'react'; import Tooltip from '../Tooltip'; export default function LoaderButton({ + ref, children, isLoading, icon, @@ -25,6 +31,7 @@ export default function LoaderButton({ tooltipColor, ...rest }: { + ref?: RefObject isLoading?: boolean icon?: ReactNode spinnerColor?: SpinnerColor @@ -41,6 +48,7 @@ export default function LoaderButton({ const button =