Chromatic sorting (#284)

* Test color palette extraction

* Fix import

* Add hex <> oklch conversions

* Add 'hue' storage to photos

* Consolidate color modules

* Add chromatic config, track missing color data

* Bump deps

* Fix lens text test

* Finalize color storage

* Refactor color imports

* Hide form color data when disabled

* Store all average oklch color components

* Finalize color-config language

* Optimize photo syncing for color data

* Only update color data when syncing if possible

* Build out all color sorts

* Debug image colors

* Improve color debugging

* Improve color logging

* Simplify color sorting

* Bump deps

* Fix color sync logic

* Switch to sort params: ascending, descending

* Fix commandk sort menu

* Update tr-tr sorting language

* Add color capture to all photo extractions

* Add color visualization to photo form

* Standardize photo update language

* Create global debug color update function

* Improve color data capture logging

* Update maximum function duration for admin photos

* Add note to remove maxDuration

* Use AI to generate sorting color

* Conditionally use AI to analyze colors

* Manage AI color analysis batched requests

* Fix color reporting in admin photo table

* Only update color where AI fields are missing

* Temporarily upgrade admin/photos timeout

* Fix pro-based max duration

* Standardize color sorting foundations

* Update color sorting language

* Refactor color calculations

* Restore max duration time

* Update color-based sort menu labels

* Finalize color documentation

* Clean up color test actions

* Round color sort values before submitting to db

* Consolidate color server actions
This commit is contained in:
Sam Becker 2025-08-03 19:31:02 -05:00 committed by GitHub
parent 8ba32c5549
commit 59f5c74269
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 3116 additions and 2244 deletions

View File

@ -14,16 +14,17 @@
"cmdk",
"Consolas",
"CredentialsSignin",
"culori",
"datetime",
"depluralize",
"depluralizes",
"Eterna",
"exif",
"exiftool",
"fieldset",
"favicons",
"Favoriting",
"favs",
"fieldset",
"ghijklmnopqrstuv",
"GPSH",
"Hasselblad",
@ -43,6 +44,7 @@
"nanoids",
"nextjs",
"nowrap",
"Oklab",
"oklch",
"parameterizes",
"presigner",

View File

@ -152,13 +152,17 @@ Application behavior can be changed by configuring the following environment var
- `taken-at-oldest-first`
- `uploaded-at`
- `uploaded-at-oldest-first`
- `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
- `NEXT_PUBLIC_NAV_SORT_CONTROL`
- Controls sort UI on grid/full homepages
- Accepted values:
- `none`
- `toggle` (default)
- `menu`
- Color-based sorting (experimental)
- `NEXT_PUBLIC_SORT_BY_COLOR = 1` enables color-based sorting (forces nav sort control to "menu," flags photos missing color data in admin dashboard)—color identification benefits greatly from AI being enabled
- `NEXT_PUBLIC_COLOR_SORT_STARTING_HUE` controls which colors start first (accepts a hue of 0 to 360, default: 80)
- `NEXT_PUBLIC_COLOR_SORT_CHROMA_CUTOFF` controls which colors are considered sufficiently vibrant (accepts a chroma of 0 to 0.37, default: 0.05):
- `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
#### Display
@ -286,13 +290,13 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
Partial internationalization (for non-admin, user-facing text) provided for a handful of languages. Configure locale by setting environment variable `NEXT_PUBLIC_LOCALE`.
### Supported Languages
- `bd-bn`
- `en-us`
- `id-id`
- `pt-br`
- `pt-pt`
- `id-id`
- `zh-cn`
- `bd-bn`
- `tr-tr`
- `zh-cn`
To add support for a new language, open a PR following instructions in [/src/i18n/index.ts](https://github.com/sambecker/exif-photo-blog/blob/main/src/i18n/index.ts), using [en-us.ts](https://github.com/sambecker/exif-photo-blog/blob/main/src/i18n/locales/en-us.ts) as reference.

View File

@ -8,7 +8,7 @@ import {
import { PATH_ADMIN } from '@/app/path';
import PhotoEditPageClient from '@/photo/PhotoEditPageClient';
import {
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
BLUR_ENABLED,
IS_PREVIEW,
} from '@/app/config';
@ -36,10 +36,10 @@ export default async function PhotoEditPage({
if (!photo) { redirect(PATH_ADMIN); }
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
const hasAiTextGeneration = AI_CONTENT_GENERATION_ENABLED;
// Only generate image thumbnails when AI generation is enabled
const imageThumbnailBase64 = AI_TEXT_GENERATION_ENABLED
const imageThumbnailBase64 = AI_CONTENT_GENERATION_ENABLED
? await resizeImageFromUrl(
getNextImageUrlForManipulation(photo.url, IS_PREVIEW),
)

View File

@ -1,12 +1,12 @@
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotos, getPhotosInNeedOfSyncCount } from '@/photo/db/query';
import { getPhotos, getPhotosInNeedOfUpdateCount } 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 {
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
@ -34,7 +34,7 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
getPhotosInNeedOfSyncCount()
getPhotosInNeedOfUpdateCount()
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()
@ -47,7 +47,7 @@ export default async function AdminPhotosPage() {
photosCount,
photosCountNeedsSync,
shouldResize: !PRESERVE_ORIGINAL_UPLOADS,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
hasAiTextGeneration: AI_CONTENT_GENERATION_ENABLED,
onLastUpload: async () => {
'use server';
// Update upload count in admin nav

View File

@ -1,17 +1,17 @@
import AdminPhotosSyncClient from '@/admin/AdminPhotosSyncClient';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getPhotosInNeedOfSync } from '@/photo/db/query';
import AdminPhotosUpdateClient from '@/admin/AdminPhotosUpdateClient';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getPhotosInNeedOfUpdate } from '@/photo/db/query';
export const maxDuration = 60;
export default async function AdminUpdatesPage() {
const photos = await getPhotosInNeedOfSync()
const photos = await getPhotosInNeedOfUpdate()
.catch(() => []);
return (
<AdminPhotosSyncClient {...{
<AdminPhotosUpdateClient {...{
photos,
hasAiTextGeneration: AI_TEXT_GENERATION_ENABLED,
hasAiTextGeneration: AI_CONTENT_GENERATION_ENABLED,
}} />
);
}

View File

@ -9,7 +9,7 @@ import {
import UploadPageClient from '@/photo/UploadPageClient';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/app/config';
import ErrorNote from '@/components/ErrorNote';
@ -35,12 +35,12 @@ export default async function UploadPage({ params, searchParams }: Params) {
} = await extractImageDataFromBlobPath(uploadPath, {
includeInitialPhotoFields: true,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
generateResizedImage: AI_CONTENT_GENERATION_ENABLED,
});
const isDataMissing =
!formDataFromExif ||
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64);
(AI_CONTENT_GENERATION_ENABLED && !imageThumbnailBase64);
if (isDataMissing && !error) {
// Only redirect if there's no error to report
@ -64,7 +64,7 @@ export default async function UploadPage({ params, searchParams }: Params) {
: undefined,
]);
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
const hasAiTextGeneration = AI_CONTENT_GENERATION_ENABLED;
let textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS;
if (formDataFromExif) {

View File

@ -10,14 +10,14 @@
},
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
"@aws-sdk/client-s3": "3.848.0",
"@aws-sdk/s3-request-presigner": "3.848.0",
"@aws-sdk/client-s3": "3.857.0",
"@aws-sdk/s3-request-presigner": "3.857.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.3",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
"@vercel/speed-insights": "^1.2.0",
@ -25,17 +25,20 @@
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"extract-colors": "^4.2.0",
"fast-average-color": "^9.5.0",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^12.23.6",
"framer-motion": "^12.23.12",
"nanoid": "^5.1.5",
"next": "15.4.2",
"next": "15.4.5",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-icons": "^5.5.0",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.3",
@ -47,26 +50,28 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@next/bundle-analyzer": "15.4.2",
"@next/eslint-plugin-next": "^15.4.2",
"@next/bundle-analyzer": "15.4.5",
"@next/eslint-plugin-next": "^15.4.5",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@types/culori": "^4.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.15",
"@types/pg": "^8.15.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/node": "^24.1.0",
"@types/pg": "^8.15.5",
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
"eslint": "9.31.0",
"eslint-config-next": "15.4.2",
"eslint": "9.32.0",
"eslint-config-next": "15.4.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jest": "^30.0.4",
"jest-environment-jsdom": "^30.0.4",
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",
"knip": "^5.62.0",
"postcss": "8.5.6",
"tailwindcss": "4.1.11",
"ts-node": "^10.9.2",
@ -76,6 +81,7 @@
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"@vercel/speed-insights",
"oxc-resolver",
"sharp",
"unrs-resolver"
]

3840
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -100,7 +100,7 @@ export default function AdminAppMenu({
items.push({
label: appText.admin.updatePlural,
annotation: <>
<span className="mr-3">
<span className="mr-3 text-blue-500">
{photosCountNeedSync}
</span>
<InsightsIndicatorDot

View File

@ -7,6 +7,7 @@ import Spinner from '@/components/Spinner';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_INSIGHTS,
PATH_ADMIN_PHOTOS_UPDATES,
checkPathPrefix,
isPathAdminInfo,
isPathTopLevelAdmin,
@ -60,7 +61,10 @@ export default function AdminNavClient({
return () => clearInterval(interval);
}, [updateTimes]);
const shouldShowBanner = hasRecentUpdates && isPathTopLevelAdmin(pathname);
const shouldShowBanner =
hasRecentUpdates &&
isPathTopLevelAdmin(pathname) &&
pathname !== PATH_ADMIN_PHOTOS_UPDATES;
return (
<AppGrid

View File

@ -30,7 +30,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';
import { photoNeedsToBeUpdated } from '@/photo/update';
import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client';
import IconLock from '@/components/icons/IconLock';
@ -124,7 +124,7 @@ export default function AdminPhotoMenu({
label: appText.admin.sync,
labelComplex: <span className="inline-flex items-center gap-2">
<span>{appText.admin.sync}</span>
{photoNeedsToBeSynced(photo) &&
{photoNeedsToBeUpdated(photo) &&
<InsightsIndicatorDot
colorOverride="blue"
className="ml-1 translate-y-[1.5px]"

View File

@ -16,6 +16,7 @@ import { pluralize } from '@/utility/string';
import IconBroom from '@/components/icons/IconBroom';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppText } from '@/i18n/state/client';
import SyncColorButton from '@/photo/color/SyncColorButton';
export default function AdminPhotosClient({
photos,
@ -28,6 +29,7 @@ export default function AdminPhotosClient({
infiniteScrollInitial,
infiniteScrollMultiple,
timezone,
debugColorData,
}: {
photos: Photo[]
photosCount: number
@ -39,6 +41,7 @@ export default function AdminPhotosClient({
infiniteScrollInitial: number
infiniteScrollMultiple: number
timezone: Timezone
debugColorData?: boolean
}) {
const { uploadState: { isUploading } } = useAppState();
@ -56,6 +59,8 @@ export default function AdminPhotosClient({
onLastUpload={onLastUpload}
/>
</div>
{debugColorData &&
<SyncColorButton />}
{photosCountNeedsSync > 0 &&
<PathLoaderButton
path={PATH_ADMIN_PHOTOS_UPDATES}
@ -67,7 +72,7 @@ export default function AdminPhotosClient({
pluralize(
photosCountNeedsSync,
appText.photo.photo,
appText.photo.photoPlural,
appText.photo.photoPlural.toLocaleLowerCase(),
) +
' missing data or AI-generated text'
)}
@ -108,6 +113,7 @@ export default function AdminPhotosClient({
photos={photos}
hasAiTextGeneration={hasAiTextGeneration}
timezone={timezone}
debugColorData={debugColorData}
/>
{photosCount > photos.length &&
<AdminPhotosTableInfinite
@ -115,6 +121,7 @@ export default function AdminPhotosClient({
itemsPerPage={infiniteScrollMultiple}
hasAiTextGeneration={hasAiTextGeneration}
timezone={timezone}
debugColorData={debugColorData}
/>}
</div>
</div>}

View File

@ -14,10 +14,12 @@ import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton';
import { Timezone } from '@/utility/timezone';
import Tooltip from '@/components/Tooltip';
import { photoNeedsToBeSynced, getPhotoSyncStatusText } from '@/photo/sync';
import { photoNeedsToBeUpdated } from '@/photo/update';
import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
import { doesPhotoHaveDefaultVisibility } from '@/photo/visibility';
import UpdateTooltip from '@/photo/update/UpdateTooltip';
import PhotoColors from '@/photo/color/PhotoColors';
import SyncColorButton from '@/photo/color/SyncColorButton';
export default function AdminPhotosTable({
photos,
@ -30,6 +32,8 @@ export default function AdminPhotosTable({
canDelete = true,
timezone,
shouldScrollIntoViewOnExternalSync,
updateMode,
debugColorData,
}: {
photos: Photo[],
onLastPhotoVisible?: () => void
@ -41,6 +45,9 @@ export default function AdminPhotosTable({
canDelete?: boolean
timezone?: Timezone
shouldScrollIntoViewOnExternalSync?: boolean
// Only sync color data where possible
updateMode?: boolean
debugColorData?: boolean
}) {
const { invalidateSwr } = useAppState();
@ -78,6 +85,10 @@ export default function AdminPhotosTable({
>
{titleForPhoto(photo, false)}
</Link>
{debugColorData && photo.colorData &&
<div>
<PhotoColors colorData={photo.colorData} />
</div>}
</span>
{!doesPhotoHaveDefaultVisibility(photo) &&
<span className={clsx(
@ -86,16 +97,9 @@ export default function AdminPhotosTable({
)}>
<PhotoVisibilityIcon photo={photo} />
</span>}
{photoNeedsToBeSynced(photo) &&
{photoNeedsToBeUpdated(photo) &&
<span>
<Tooltip
content={getPhotoSyncStatusText(photo)}
classNameTrigger={clsx(
'text-blue-600 dark:text-blue-400',
'translate-y-[0.5px]',
)}
supportMobile
/>
<UpdateTooltip photo={photo} />
</span>}
{photo.priorityOrder !== null &&
<span className={clsx(
@ -134,7 +138,10 @@ export default function AdminPhotosTable({
shouldToast
shouldScrollIntoViewOnExternalSync={
shouldScrollIntoViewOnExternalSync}
updateMode={updateMode}
/>
{debugColorData &&
<SyncColorButton photoId={photo.id} />}
{canDelete &&
<DeletePhotoButton
photo={photo}

View File

@ -11,6 +11,7 @@ export default function AdminPhotosTableInfinite({
hasAiTextGeneration,
canEdit,
canDelete,
debugColorData,
}: {
initialOffset: number
itemsPerPage: number
@ -32,6 +33,7 @@ export default function AdminPhotosTableInfinite({
hasAiTextGeneration={hasAiTextGeneration}
canEdit={canEdit}
canDelete={canDelete}
debugColorData={debugColorData}
/>}
</InfinitePhotoScroll>
);

View File

@ -2,22 +2,25 @@
import { Photo } from '@/photo';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import IconGrSync from '@/components/icons/IconGrSync';
import Note from '@/components/Note';
import AdminChildPage from '@/components/AdminChildPage';
import { PATH_ADMIN_PHOTOS } from '@/app/path';
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { syncPhotosAction } from '@/photo/actions';
import { useRouter } from 'next/navigation';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { LiaBroomSolid } from 'react-icons/lia';
import ProgressButton from '@/components/primitives/ProgressButton';
import ErrorNote from '@/components/ErrorNote';
import { getPhotosSyncStatusText } from '@/photo/sync';
import {
getPhotosUpdateStatusText,
isPhotoOnlyMissingColorData,
} from '@/photo/update';
import IconBroom from '@/components/icons/IconBroom';
const SYNC_BATCH_SIZE_MAX = 3;
export default function AdminPhotosSyncClient({
export default function AdminPhotosUpdateClient({
photos,
hasAiTextGeneration,
}: {
@ -37,7 +40,13 @@ export default function AdminPhotosSyncClient({
const router = useRouter();
const statusText = useMemo(() => getPhotosSyncStatusText(photos), [photos]);
const statusText = useMemo(() => getPhotosUpdateStatusText(photos), [photos]);
useEffect(() => {
if (photos.length === 0 && !error && !errorRef.current) {
router.push(PATH_ADMIN_PHOTOS);
}
}, [photos.length, router, error]);
return (
<AdminChildPage
@ -48,12 +57,12 @@ export default function AdminPhotosSyncClient({
</ResponsiveText>}
accessory={<ProgressButton
primary
icon={<IconGrSync className="translate-y-[1px]" />}
icon={<IconBroom size={18} />}
hideText="never"
progress={progress}
tooltip={photos.length === 1
? 'Sync data for 1 photo'
: `Sync data for all ${photos.length} photos`}
? 'Update 1 photo'
: `Update all ${photos.length} photos`}
onClick={async () => {
if (window.confirm([
'Are you sure you want to sync',
@ -69,7 +78,12 @@ export default function AdminPhotosSyncClient({
const photoIds = photoIdsToSync.current
.slice(0, SYNC_BATCH_SIZE_MAX);
setPhotoIdsSyncing(photoIds);
await syncPhotosAction(photoIds)
await syncPhotosAction(photoIds.map(id => ({
photoId: id,
onlySyncColorData: isPhotoOnlyMissingColorData(
photos.find(photo => photo.id === id),
),
})))
.then(() => {
photoIdsToSync.current = photoIdsToSync.current.filter(
id => !photoIds.includes(id),
@ -86,21 +100,17 @@ export default function AdminPhotosSyncClient({
});
if (errorRef.current) { break; }
}
if (!errorRef.current) {
router.push(PATH_ADMIN_PHOTOS);
} else {
setProgress(0);
setPhotoIdsSyncing([]);
router.refresh();
}
setProgress(0);
setPhotoIdsSyncing([]);
router.refresh();
}
}}
isLoading={arePhotoIdsSyncing}
disabled={photoIdsSyncing.length > 0}
>
{arePhotoIdsSyncing
? 'Syncing ...'
: 'Sync All'}
? 'Updating ...'
: 'Update All'}
</ProgressButton>}
>
<div className="space-y-6">
@ -133,6 +143,7 @@ export default function AdminPhotosSyncClient({
canDelete={false}
dateType="updatedAt"
shouldScrollIntoViewOnExternalSync
updateMode
/>
</div>
</div>

View File

@ -106,6 +106,7 @@ export default function AdminUploadsTableRow({
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col grow gap-2">
<FieldsetWithStatus
id={`title-${url}`}
label="Title"
value={draftTitle}
onChange={titleUpdated =>

View File

@ -1,5 +1,5 @@
import LoaderButton from '@/components/primitives/LoaderButton';
import { syncPhotoAction } from '@/photo/actions';
import { storeColorDataForPhotoAction, syncPhotoAction } from '@/photo/actions';
import IconGrSync from '@/components/icons/IconGrSync';
import { toastSuccess } from '@/toast';
import { ComponentProps, useRef, useState } from 'react';
@ -8,10 +8,13 @@ import clsx from 'clsx/lite';
import useScrollIntoView from '@/utility/useScrollIntoView';
import { Photo } from '@/photo';
import { syncPhotoConfirmText } from './confirm';
import { isPhotoOnlyMissingColorData } from '@/photo/update';
import IconBroom from '@/components/icons/IconBroom';
export default function PhotoSyncButton({
photo,
onSyncComplete,
updateMode,
className,
isSyncingExternal,
hasAiTextGeneration,
@ -22,6 +25,7 @@ export default function PhotoSyncButton({
}: {
photo: Photo
onSyncComplete?: () => void
updateMode?: boolean
isSyncingExternal?: boolean
hasAiTextGeneration: boolean
shouldConfirm?: boolean
@ -39,21 +43,34 @@ export default function PhotoSyncButton({
shouldScrollIntoViewOnExternalSync,
});
const onlySyncColorData = updateMode &&
isPhotoOnlyMissingColorData(photo);
return (
<Tooltip content="Regenerate photo data">
<Tooltip content={onlySyncColorData
? 'Update color data'
: 'Regenerate photo data'}>
<LoaderButton
ref={ref}
className={clsx('scroll-mt-8', className)}
icon={<IconGrSync
className="translate-y-[0.5px] translate-x-[0.5px]"
/>}
icon={updateMode
? <IconBroom size={18} />
: <IconGrSync
className="translate-y-[0.5px] translate-x-[0.5px]"
/>}
onClick={() => {
if (
!shouldConfirm ||
window.confirm(syncPhotoConfirmText(photo, hasAiTextGeneration))
window.confirm(syncPhotoConfirmText(
photo,
hasAiTextGeneration,
onlySyncColorData,
))
) {
setIsSyncing(true);
syncPhotoAction(photo.id)
(onlySyncColorData
? storeColorDataForPhotoAction
: syncPhotoAction)(photo.id)
.then(() => {
onSyncComplete?.();
if (shouldToast) {

View File

@ -11,7 +11,7 @@ import {
getPhotosMeta,
getUniqueTags,
getUniqueRecipes,
getPhotosInNeedOfSyncCount,
getPhotosInNeedOfUpdateCount,
} from '@/photo/db/query';
import {
getGitHubMetaForCurrentApp,
@ -37,7 +37,7 @@ export const getAdminDataAction = async () =>
getPhotosMeta({ hidden: 'only' })
.then(({ count }) => count)
.catch(() => 0),
getPhotosInNeedOfSyncCount(),
getPhotosInNeedOfUpdateCount(),
getGitHubMetaForCurrentApp(),
getStorageUploadUrlsNoStore()
.then(urls => urls.length)

View File

@ -30,6 +30,8 @@ import {
ConfigSectionKey,
getAdminConfigSections,
} from '.';
import ColorDot from '@/photo/color/ColorDot';
import { Oklch } from '@/photo/color/client';
export default function AdminAppConfigurationClient({
// Storage
@ -85,9 +87,13 @@ export default function AdminAppConfigurationClient({
// Sort
hasDefaultSortBy,
defaultSortBy,
isSortWithPriority,
hasNavSortControl,
navSortControl,
isColorSortEnabled,
hasColorSortConfiguration,
colorSortStartingHue,
colorSortChromaCutoff,
isSortWithPriority,
// Display
showKeyboardShortcutTooltips,
showExifInfo,
@ -201,6 +207,13 @@ export default function AdminAppConfigurationClient({
{children || href}
</Link>;
const renderColorDot = (color: Oklch | string, includeTooltip?: boolean) =>
<ColorDot
color={color}
className="size-3! ml-1"
includeTooltip={includeTooltip}
/>;
const renderGroupContent = (key: ConfigSectionKey): JSX.Element => {
switch (key) {
case 'Storage':
@ -406,7 +419,7 @@ export default function AdminAppConfigurationClient({
</ChecklistRow>
</>}
</>;
case 'AI Text Generation':
case 'AI Content Generation':
return <>
<ChecklistRow
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
@ -426,7 +439,7 @@ export default function AdminAppConfigurationClient({
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title={'Auto-generated fields'}
title={'Auto-generated text fields'}
status={hasAiTextAutoGeneratedFields}
optional
>
@ -611,19 +624,70 @@ export default function AdminAppConfigurationClient({
optional
>
<div>
{SORT_BY_OPTIONS.map(({sortBy, string }) =>
<Fragment key={ sortBy }>
{renderSubStatus(
sortBy === defaultSortBy ? 'checked' : 'optional',
`${string}${sortBy === APP_DEFAULT_SORT_BY
? ' (default)'
: ''}`,
)}
</Fragment>)}
{SORT_BY_OPTIONS
.filter(({ canBeDefault }) => canBeDefault)
.map(({sortBy, string }) =>
<Fragment key={ sortBy }>
{renderSubStatus(
sortBy === defaultSortBy ? 'checked' : 'optional',
`${string}${sortBy === APP_DEFAULT_SORT_BY
? ' (default)'
: ''}`,
)}
</Fragment>)}
</div>
Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
</ChecklistRow>
<ChecklistRow
title={`Nav sort control: ${navSortControl}`}
status={hasNavSortControl}
optional
>
Set environment variable to {'"none"'}, {'"toggle"'} (default),
or {'"menu"'}, to control sort UI on grid/full homepages:
{renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
</ChecklistRow>
<ChecklistRow
title="Color sort"
status={isColorSortEnabled}
experimental
optional
>
Set environment variable to {'"1"'} to enable color-based sorting
(forces nav sort control to {'"menu,"'} flags photos missing
color data in admin dashboard)color identification
benefits greatly from AI being enabled:
{renderEnvVars([
'NEXT_PUBLIC_COLOR_SORT',
])}
</ChecklistRow>
<ChecklistRow
title="Color sort configuration"
status={hasColorSortConfiguration}
experimental
optional
>
Configure which colors start first
(accepts a hue of 0 to 360, default: 80)
and which are considered sufficiently vibrant
(accepts a chroma of 0 to 0.37, default: 0.05):
<div>
<EnvVar
variable="NEXT_PUBLIC_COLOR_SORT_STARTING_HUE"
value={colorSortStartingHue}
accessory={renderColorDot({
l: 0.85,
c: 0.15,
h: colorSortStartingHue,
}, false)}
/>
<EnvVar
variable="NEXT_PUBLIC_COLOR_SORT_CHROMA_CUTOFF"
value={colorSortChromaCutoff}
/>
</div>
</ChecklistRow>
<ChecklistRow
title="Priority-based"
status={isSortWithPriority}
@ -634,15 +698,6 @@ export default function AdminAppConfigurationClient({
performance consequences):
{renderEnvVars(['NEXT_PUBLIC_PRIORITY_BASED_SORTING'])}
</ChecklistRow>
<ChecklistRow
title={`Nav sort control: ${navSortControl}`}
status={hasNavSortControl}
optional
>
Set environment variable to {'"none"'}, {'"toggle"'} (default),
or {'"menu"'}, to control sort UI on grid/full homepages:
{renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
</ChecklistRow>
</>;
case 'Display':
return <>
@ -768,17 +823,11 @@ export default function AdminAppConfigurationClient({
<div className="pt-1 flex flex-col gap-1">
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR"
accessory={matteColor && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColor }}
/>}
accessory={matteColor && renderColorDot(matteColor)}
/>
<EnvVar
variable="NEXT_PUBLIC_MATTE_COLOR_DARK"
accessory={matteColorDark && <span
className="size-[15px] border-medium rounded-sm ml-1"
style={{ backgroundColor: matteColorDark }}
/>}
accessory={matteColorDark && renderColorDot(matteColorDark)}
/>
</div>
</ChecklistRow>

View File

@ -27,7 +27,7 @@ const ADMIN_CONFIG_SECTIONS = [{
required: true,
icon: <BiPencil size={16} />,
}, {
title: 'AI Text Generation',
title: 'AI Content Generation',
titleShort: 'AI',
required: false,
icon: <HiSparkles size={14} />,

View File

@ -3,12 +3,17 @@ import { Photo } from '@/photo';
export const syncPhotoConfirmText = (
photo: Photo,
hasAiTextGeneration: boolean,
onlySyncColorData?: boolean,
) => {
const confirmText = ['Sync'];
if (photo.title) { confirmText.push(`"${photo.title}"`); }
confirmText.push('data from original image file?');
if (hasAiTextGeneration) { confirmText.push(
'AI text will be generated for undefined fields.'); }
if (onlySyncColorData) {
confirmText.push('color data?');
} else {
confirmText.push('data from original image file?');
if (hasAiTextGeneration) { confirmText.push(
'AI text will be generated for undefined fields.'); }
}
confirmText.push('This action cannot be undone.');
return confirmText.join(' ');
};

View File

@ -6,7 +6,7 @@ import {
getUniqueLenses,
getUniqueRecipes,
getUniqueTags,
getPhotosInNeedOfSyncCount,
getPhotosInNeedOfUpdateCount,
} from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient';
import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
@ -27,7 +27,7 @@ export default async function AdminAppInsights() {
] = await Promise.all([
getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }),
getPhotosInNeedOfSyncCount(),
getPhotosInNeedOfUpdateCount(),
getPhotosMeta({ maximumAspectRatio: 0.9 }),
getGitHubMetaForCurrentApp(),
getUniqueCameras(),

View File

@ -11,7 +11,7 @@ import {
IS_META_TITLE_CONFIGURED,
HAS_STATIC_OPTIMIZATION,
GRID_HOMEPAGE_ENABLED,
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
} from '@/app/config';
import { PhotoDateRange } from '@/photo';
import { getGitHubMeta } from '@/platforms/github';
@ -132,7 +132,7 @@ export const getAllInsights = ({
}) => ({
...getSignificantInsights({ codeMeta, photosCountNeedSync }),
noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo,
noAi: !AI_TEXT_GENERATION_ENABLED,
noAi: !AI_CONTENT_GENERATION_ENABLED,
noConfiguredMeta:
!IS_META_TITLE_CONFIGURED ||
!IS_META_DESCRIPTION_CONFIGURED,

View File

@ -9,6 +9,7 @@ import {
shortenUrl,
} from '@/utility/url';
import { getNavSortControlFromString, getSortByFromString } from '@/photo/sort';
import { parseChromaCutoff, parseStartingHue } from '@/photo/color/sort';
// HARD-CODED GLOBAL CONFIGURATION
@ -205,7 +206,7 @@ export const CURRENT_STORAGE: StorageType =
// AI
export const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL;
export const AI_TEXT_GENERATION_ENABLED =
export const AI_CONTENT_GENERATION_ENABLED =
Boolean(process.env.OPENAI_SECRET_KEY);
export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsString(
process.env.AI_TEXT_AUTO_GENERATED_FIELDS);
@ -280,8 +281,15 @@ export const USER_DEFAULT_SORT_OPTIONS = {
sortBy: USER_DEFAULT_SORT_BY,
sortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
};
export const NAV_SORT_CONTROL =
getNavSortControlFromString(process.env.NEXT_PUBLIC_NAV_SORT_CONTROL);
export const COLOR_SORT_ENABLED =
process.env.NEXT_PUBLIC_COLOR_SORT === '1';
export const COLOR_SORT_STARTING_HUE =
parseStartingHue(process.env.NEXT_PUBLIC_COLOR_SORT_STARTING_HUE);
export const COLOR_SORT_CHROMA_CUTOFF =
parseChromaCutoff(process.env.NEXT_PUBLIC_COLOR_SORT_CHROMA_CUTOFF);
export const NAV_SORT_CONTROL = COLOR_SORT_ENABLED
? 'menu'
: getNavSortControlFromString(process.env.NEXT_PUBLIC_NAV_SORT_CONTROL);
// DISPLAY
@ -392,7 +400,7 @@ export const APP_CONFIGURATION = {
hasPageAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT),
// AI
hasOpenaiBaseUrl: Boolean(OPENAI_BASE_URL),
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
isAiTextGenerationEnabled: AI_CONTENT_GENERATION_ENABLED,
aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS
? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0
? ['none']
@ -421,9 +429,15 @@ export const APP_CONFIGURATION = {
// Sort
hasDefaultSortBy: Boolean(process.env.NEXT_PUBLIC_DEFAULT_SORT),
defaultSortBy: USER_DEFAULT_SORT_BY,
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
hasNavSortControl: Boolean(process.env.NEXT_PUBLIC_NAV_SORT_CONTROL),
navSortControl: NAV_SORT_CONTROL,
isColorSortEnabled: COLOR_SORT_ENABLED,
hasColorSortConfiguration:
Boolean(process.env.NEXT_PUBLIC_COLOR_SORT_STARTING_HUE) ||
Boolean(process.env.NEXT_PUBLIC_COLOR_SORT_CHROMA_CUTOFF),
colorSortStartingHue: COLOR_SORT_STARTING_HUE,
colorSortChromaCutoff: COLOR_SORT_CHROMA_CUTOFF,
isSortWithPriority: USER_DEFAULT_SORT_WITH_PRIORITY,
// Display
showKeyboardShortcutTooltips: SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
showExifInfo: SHOW_EXIF_DATA,

View File

@ -26,8 +26,9 @@ export const PATH_FULL_INFERRED = GRID_HOMEPAGE_ENABLED
// Sort
export const PARAM_SORT_TYPE_TAKEN_AT = 'taken-at';
export const PARAM_SORT_TYPE_UPLOADED_AT = 'uploaded-at';
export const PARAM_SORT_ORDER_NEWEST = 'newest-first';
export const PARAM_SORT_ORDER_OLDEST = 'oldest-first';
export const PARAM_SORT_TYPE_COLOR = 'color';
export const PARAM_SORT_ORDER_DESCENDING = 'descending';
export const PARAM_SORT_ORDER_ASCENDING = 'ascending';
export const doesPathOfferSort = (pathname: string) =>
pathname === PATH_ROOT ||
pathname.startsWith(PATH_GRID) ||

View File

@ -3,7 +3,7 @@ import PhotoHeader from '@/photo/PhotoHeader';
import { Camera, cameraFromPhoto } from '.';
import PhotoCamera from './PhotoCamera';
import { descriptionForCameraPhotos } from './meta';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function CameraHeader({
@ -45,7 +45,7 @@ export default async function CameraHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -182,8 +182,8 @@ export default function CommandKClient({
const {
doesPathOfferSort,
isSortedByDefault,
pathNewest,
pathOldest,
pathDescending,
pathAscending,
pathTakenAt,
pathUploadedAt,
pathClearSort,
@ -526,11 +526,11 @@ export default function CommandKClient({
const sortItems = [{
label: appText.sort.newestFirst,
path: pathNewest,
path: pathDescending,
annotation: renderCheck(!isAscending),
}, {
label: appText.sort.oldestFirst,
path: pathOldest,
path: pathAscending,
annotation: renderCheck(isAscending),
}, {
label: appText.sort.byTakenAt,

View File

@ -12,7 +12,7 @@ export default function EnvVar({
className,
}: {
variable: string,
value?: string,
value?: string | number,
accessory?: ReactNode,
includeCopyButton?: boolean,
trailingContent?: ReactNode,

View File

@ -19,6 +19,7 @@ export default function FieldsetWithStatus({
icon,
note,
noteShort,
noteComplex,
tooltip,
error,
value,
@ -48,6 +49,7 @@ export default function FieldsetWithStatus({
icon?: ReactNode
note?: string
noteShort?: string
noteComplex?: ReactNode
tooltip?: string
error?: string
value: string
@ -157,6 +159,7 @@ export default function FieldsetWithStatus({
>
({note})
</ResponsiveText>}
{noteComplex}
{isModified && !error &&
<span className={clsx(
'text-main font-medium text-[0.9rem]',

View File

@ -6,7 +6,7 @@ import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFilm from '@/film/PhotoFilm';
import { getRecipePropsFromPhotos } from '@/recipe';
import { useAppState } from '@/app/AppState';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function FilmHeader({
@ -57,7 +57,7 @@ export default function FilmHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -2,7 +2,7 @@ import { Photo, PhotoDateRange } from '@/photo';
import { descriptionForFocalLengthPhotos } from '.';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFocalLength from './PhotoFocalLength';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function FocalLengthHeader({
@ -41,7 +41,7 @@ export default async function FocalLengthHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: 'সাজান',
newest: 'নতুনতম',
oldest: 'পুরাতনতম',
descending: 'অবরোহী',
ascending: 'আরোহী',
newestFirst: 'নতুনতম প্রথমে',
oldestFirst: 'পুরাতনতম প্রথমে',
viewNewest: 'নতুনতম দেখুন',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: 'আপলোড হয়েছে',
byUploadedAt: 'আপলোডের সময় অনুযায়ী',
uploadedAtShort: 'আপলোড',
color: 'ক্রোমাটিক',
byColor: 'রঙ অনুযায়ী',
clearSort: 'সাজানো মুছুন',
},
cmdk: {

View File

@ -56,6 +56,8 @@ export const TEXT = {
sort: 'Sort',
newest: 'Newest',
oldest: 'Oldest',
descending: 'Descending',
ascending: 'Ascending',
newestFirst: 'Newest first',
oldestFirst: 'Oldest first',
viewNewest: 'View newest',
@ -65,6 +67,8 @@ export const TEXT = {
uploadedAt: 'Uploaded at',
byUploadedAt: 'By uploaded at',
uploadedAtShort: 'Uploaded',
color: 'Chromatic',
byColor: 'By color',
clearSort: 'Clear sort',
},
cmdk: {

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: 'Urutkan',
newest: 'Terbaru',
oldest: 'Terlama',
descending: 'Menurun',
ascending: 'Menaik',
newestFirst: 'Terbaru dulu',
oldestFirst: 'Terlama dulu',
viewNewest: 'Lihat terbaru',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: 'Diunggah pada',
byUploadedAt: 'Berdasarkan waktu unggahan',
uploadedAtShort: 'Diunggah',
color: 'Kromatik',
byColor: 'Berdasarkan warna',
clearSort: 'Hapus pengurutan',
},
cmdk: {

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: 'Ordenar',
newest: 'Mais recentes',
oldest: 'Mais antigas',
descending: 'Decrescente',
ascending: 'Crescente',
newestFirst: 'Mais recentes primeiro',
oldestFirst: 'Mais antigas primeiro',
viewNewest: 'Ver mais recentes',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: 'Enviado em',
byUploadedAt: 'Por data de envio',
uploadedAtShort: 'Enviado',
color: 'Cromático',
byColor: 'Por cor',
clearSort: 'Limpar ordenação',
},
cmdk: {

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: 'Ordenar',
newest: 'Mais recentes',
oldest: 'Mais antigas',
descending: 'Decrescente',
ascending: 'Crescente',
newestFirst: 'Mais recentes primeiro',
oldestFirst: 'Mais antigas primeiro',
viewNewest: 'Ver mais recentes',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: 'Enviado em',
byUploadedAt: 'Por data de envio',
uploadedAtShort: 'Enviado',
color: 'Cromático',
byColor: 'Por cor',
clearSort: 'Limpar ordenação',
},
cmdk: {

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: 'Sırala',
newest: 'En Yeni',
oldest: 'En Eski',
descending: 'Azalan',
ascending: 'Artan',
newestFirst: 'En Yeniden Eskiye',
oldestFirst: 'En Eskiden Yeniye',
viewNewest: 'En yeniye bak',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: 'Yüklenme Zamanı',
byUploadedAt: 'Yüklenme zamanına göre',
uploadedAtShort: 'Yüklenme',
color: 'Kromatik',
byColor: 'Renge göre',
clearSort: 'Sıralamayı temizle',
},
cmdk: {

View File

@ -57,6 +57,8 @@ export const TEXT: I18N = {
sort: '排序',
newest: '最新',
oldest: '最旧',
descending: '降序',
ascending: '升序',
newestFirst: '最新优先',
oldestFirst: '最旧优先',
viewNewest: '查看最新',
@ -66,6 +68,8 @@ export const TEXT: I18N = {
uploadedAt: '上传时间',
byUploadedAt: '按上传时间',
uploadedAtShort: '上传',
color: '色度',
byColor: '按颜色',
clearSort: '清除排序',
},
cmdk: {

View File

@ -3,7 +3,7 @@ import PhotoHeader from '@/photo/PhotoHeader';
import { Lens, lensFromPhoto } from '.';
import PhotoLens from './PhotoLens';
import { descriptionForLensPhotos } from './meta';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function LensHeader({
@ -46,7 +46,7 @@ export default async function LensHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -114,7 +114,7 @@ export const formatLensText = (
switch (length) {
case 'long':
return make ? `${make} ${model}` : modelRaw;
return make ? `${make} ${modelRaw}` : modelRaw;
case 'medium':
case 'short':
return model;

View File

@ -14,7 +14,7 @@ import PhotoHeader from './PhotoHeader';
import RecipeHeader from '@/recipe/RecipeHeader';
import { ReactNode } from 'react';
import LensHeader from '@/lens/LensHeader';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import YearHeader from '@/years/YearHeader';
import RecentsHeader from '@/recents/RecentsHeader';
@ -135,7 +135,7 @@ export default function PhotoDetailPage({
selectedPhoto={photo}
photos={photos}
recipe={recipe}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
/>}
/>
<AnimateItems

View File

@ -14,6 +14,7 @@ import { useRef } from 'react';
import useVisible from '@/utility/useVisible';
import LinkWithStatus from '@/components/LinkWithStatus';
import Spinner from '@/components/Spinner';
import PhotoColors from './color/PhotoColors';
export default function PhotoMedium({
photo,
@ -22,6 +23,7 @@ export default function PhotoMedium({
prefetch = SHOULD_PREFETCH_ALL_LINKS,
className,
onVisible,
debugColor = true,
...categories
}: {
photo: Photo
@ -30,6 +32,7 @@ export default function PhotoMedium({
prefetch?: boolean
className?: string
onVisible?: () => void
debugColor?: boolean
} & PhotoSetCategory) {
const ref = useRef<HTMLAnchorElement>(null);
@ -40,6 +43,7 @@ export default function PhotoMedium({
ref={ref}
href={pathForPhoto({ photo, ...categories })}
className={clsx(
'group',
'active:brightness-75',
selected && 'brightness-50',
className,
@ -57,6 +61,16 @@ export default function PhotoMedium({
)}>
<Spinner size={20} color="text" />
</div>}
{debugColor && photo.colorData &&
<div className={clsx(
'absolute inset-2 z-10',
'opacity-0 group-hover:opacity-100 transition-opacity',
)}>
<PhotoColors
className="justify-end"
colorData={photo.colorData}
/>
</div>}
<ImageMedium
src={photo.url}
aspectRatio={photo.aspectRatio}

View File

@ -13,6 +13,8 @@ import {
deletePhotoRecipeGlobally,
renamePhotoRecipeGlobally,
getPhotosNeedingRecipeTitleCount,
updateColorDataForPhoto,
getColorDataForPhotos,
} from '@/photo/db/query';
import { PhotoQueryOptions, areOptionsSensitive } from './db';
import {
@ -51,7 +53,7 @@ import { AiImageQuery, getAiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/app/config';
import { generateAiImageQueries } from './ai/server';
@ -60,6 +62,10 @@ import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
import {
getColorFieldsForImageUrl,
getColorFieldsForPhotoDbInsert,
} from '@/photo/color/server';
// Private actions
@ -124,11 +130,11 @@ const addUpload = async ({
} = await extractImageDataFromBlobPath(url, {
includeInitialPhotoFields: true,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
generateResizedImage: AI_CONTENT_GENERATION_ENABLED,
});
if (formDataFromExif) {
if (AI_TEXT_GENERATION_ENABLED) {
if (AI_CONTENT_GENERATION_ENABLED) {
onStreamUpdate?.('Generating AI text');
}
@ -207,7 +213,7 @@ export const addUploadsAction = async ({
shouldRevalidateAllKeysAndPaths?: boolean
}) =>
runAuthenticatedAdminServerAction(async () => {
const PROGRESS_TASK_COUNT = AI_TEXT_GENERATION_ENABLED ? 5 : 4;
const PROGRESS_TASK_COUNT = AI_CONTENT_GENERATION_ENABLED ? 5 : 4;
const addedUploadUrls: string[] = [];
let currentUploadUrl = '';
@ -401,6 +407,39 @@ export const getPhotosNeedingRecipeTitleCountAction = async (
),
);
export const storeColorDataForPhotoAction = async (photoId: string) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId, true);
if (photo) {
const colorFields = await getColorFieldsForImageUrl(
photo.url,
photo.colorData,
);
if (colorFields) {
await updatePhoto(convertPhotoToPhotoDbInsert({
...photo,
...colorFields,
}));
}
revalidatePhoto(photo.id);
}
});
export const recalculateColorDataForAllPhotosAction = async () =>
runAuthenticatedAdminServerAction(async () => {
const photos = await getColorDataForPhotos();
for (const { id, url, colorData: _colorData } of photos) {
const colorFields = await getColorFieldsForPhotoDbInsert(url, _colorData);
if (colorFields && colorFields.colorSort) {
await updateColorDataForPhoto(
id,
colorFields.colorData,
colorFields.colorSort,
);
}
}
});
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;
@ -463,7 +502,7 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
} = await extractImageDataFromBlobPath(photo.url, {
includeInitialPhotoFields: false,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
generateResizedImage: AI_CONTENT_GENERATION_ENABLED,
});
let urlToDelete: string | undefined;
@ -490,7 +529,7 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
semanticDescription: aiSemanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
photo.syncStatus.missingAiTextFields,
photo.updateStatus.isMissingAiTextFields,
undefined,
isBatch,
);
@ -526,10 +565,15 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
}
});
export const syncPhotosAction = async (photoIds: string[]) =>
export const syncPhotosAction = async (photosToSync: {
photoId: string,
onlySyncColorData?: boolean,
}[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
await syncPhotoAction(photoId, true);
for (const { photoId, onlySyncColorData } of photosToSync) {
await (onlySyncColorData
? storeColorDataForPhotoAction(photoId)
: syncPhotoAction(photoId, true));
}
revalidateAllKeysAndPaths();
});

View File

@ -0,0 +1,49 @@
import clsx from 'clsx/lite';
import { convertOklchToCss, Oklch } from './client';
import Tooltip from '@/components/Tooltip';
const renderColor = (letter: string, value: number, shouldRound?: boolean) => (
<div className="flex gap-2">
<span className="text-dim">{letter}</span>
<span>{shouldRound ? Math.round(value) : value.toFixed(2)}</span>
</div>
);
export default function ColorDot({
color,
title,
className,
includeTooltip = true,
}: {
color: Oklch | string
title?: string
className?: string
includeTooltip?: boolean
}) {
const isColorHex = typeof color === 'string';
return (
<Tooltip content={includeTooltip && <>
{title &&
<div className="text-dim mb-1 text-left">
{title}
</div>}
{isColorHex
? <div>{color}</div>
: <>
{renderColor('L', color.l)}
{renderColor('C', color.c)}
{renderColor('H', color.h, true)}
</>}
</>}>
<div
className={clsx(
'size-4 rounded-full outline outline-white/25',
className,
)}
style={{ backgroundColor: isColorHex
? color
: convertOklchToCss(color) }}
/>
</Tooltip>
);
}

View File

@ -0,0 +1,40 @@
import clsx from 'clsx/lite';
import ColorDot from './ColorDot';
import { PhotoColorData } from './client';
export default function PhotoColors({
className,
classNameDot,
colorData,
}: {
className?: string
classNameDot?: string
colorData?: PhotoColorData
}) {
return colorData
? <div className={clsx(
'flex gap-1 flex-wrap justify-start',
className,
)}>
{colorData.ai &&
<ColorDot
title="AI"
className={classNameDot}
color={colorData.ai}
/>}
<ColorDot
title="Average"
className={classNameDot}
color={colorData.average}
/>
{colorData.colors.map((color, index) =>
<ColorDot
key={index}
title={`Color ${index + 1}`}
className={classNameDot}
color={color}
/>,
)}
</div>
: null;
}

View File

@ -0,0 +1,37 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import { IoColorFilterOutline } from 'react-icons/io5';
import {
recalculateColorDataForAllPhotosAction,
storeColorDataForPhotoAction,
} from '../actions';
import { useState } from 'react';
export default function SyncColorButton({
photoId,
}: {
photoId?: string
}) {
const [isUpdatingColorData, setIsUpdatingColorData] = useState(false);
return (
<LoaderButton
icon={<IoColorFilterOutline size={20} />}
onClick={() => {
setIsUpdatingColorData(true);
(photoId
? storeColorDataForPhotoAction(photoId)
: recalculateColorDataForAllPhotosAction())
.finally(() => setIsUpdatingColorData(false));
}}
tooltip={photoId
? 'Update color data'
: 'Update color data for all photos'}
confirmText={!photoId
? 'Are you sure you want to update all photo color data?'
: undefined}
isLoading={isUpdatingColorData}
/>
);
}

43
src/photo/color/client.ts Normal file
View File

@ -0,0 +1,43 @@
export interface Oklch {
l: number
c: number
h: number
}
export interface PhotoColorData {
ai?: Oklch
average: Oklch
colors: Oklch[]
}
export const convertJsonStringToOklch = (jsonString = '') => {
const matches = jsonString
.match(/`*{ *l: *([0-9\.]+), *c: *([0-9\.]+), *h: *([0-9\.]+) *}`*/);
if (matches &&
matches[1] &&
matches[2] &&
matches[3]
) {
return {
l: parseFloat(matches[1]),
c: parseFloat(matches[2]),
h: parseInt(matches[3]),
} as Oklch;
}
};
export const convertOklchToCss = (oklch: Oklch) =>
`oklch(${oklch.l} ${oklch.c} ${oklch.h})`;
export const logOklch = (oklch: Oklch) =>
`L:${oklch.l.toFixed(2)} C:${oklch.c.toFixed(2)} H:${oklch.h.toFixed(2)}`;
export const generateColorDataFromString = (colorData?: string) => {
if (colorData) {
try {
return JSON.parse(colorData) as PhotoColorData;
} catch (error) {
console.log('Error parsing color data', error);
}
}
};

134
src/photo/color/server.ts Normal file
View File

@ -0,0 +1,134 @@
import { convertRgbToOklab, parseHex } from 'culori';
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
import {
AI_CONTENT_GENERATION_ENABLED,
IS_PREVIEW,
} from '@/app/config';
import { FastAverageColor } from 'fast-average-color';
import { Oklch, PhotoColorData } from './client';
import sharp from 'sharp';
import { extractColors } from 'extract-colors';
import { getImageBase64FromUrl } from '../server';
import { generateOpenAiImageQuery } from '@/platforms/openai';
import { calculateColorSort } from './sort';
const NULL_RGB = { r: 0, g: 0, b: 0 };
export const convertHexToOklch = (hex: string): Oklch => {
const rgb = parseHex(hex) ?? NULL_RGB;
const { a, b, l } = convertRgbToOklab(rgb);
const c = Math.sqrt(a * a + b * b);
const _h = Math.atan2(b, a) * (180 / Math.PI);
const h = _h < 0 ? _h + 360 : _h;
return {
l: +(l.toFixed(3)),
c: +(c.toFixed(3)),
h: +(h.toFixed(3)),
};
};
// Convert image url to byte array
const getImageDataFromUrl = async (_url: string) => {
const url = getNextImageUrlForManipulation(_url, IS_PREVIEW);
const imageBuffer = await fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer());
const image = sharp(imageBuffer);
const { width, height } = await image.metadata();
const buffer = await image.ensureAlpha().raw().toBuffer();
return {
data: new Uint8ClampedArray(buffer.buffer),
width,
height,
};
};
// algorithm library: fast-average-color
const getAverageColorFromImageUrl = async (url: string) => {
const { data } = await getImageDataFromUrl(url);
const fac = new FastAverageColor();
const color = fac.prepareResult(fac.getColorFromArray4(data));
return convertHexToOklch(color.hex);
};
// algorithm library: extract-colors
const getExtractedColorsFromImageUrl = async (url: string) => {
const data = await getImageDataFromUrl(url);
return extractColors(data).then(colors =>
colors.map(({ hex }) => convertHexToOklch(hex)));
};
const getColorDataFromImageUrl = async (
url: string,
isBatch?: boolean,
): Promise<PhotoColorData> => {
const ai = AI_CONTENT_GENERATION_ENABLED
? await getColorFromAI(url, isBatch)
: undefined;
const average = await getAverageColorFromImageUrl(url);
const colors = await getExtractedColorsFromImageUrl(url);
return {
...ai && { ai },
average,
colors,
};
};
export const getColorFieldsForImageUrl = async (
url: string,
_colorData?: PhotoColorData,
isBatch?: boolean,
) => {
try {
const colorData = _colorData ??
await getColorDataFromImageUrl(url, isBatch);
return {
colorData,
colorSort: calculateColorSort(colorData),
};
} catch {
console.log('Error fetching image url data', url);
}
};
// Used when inserting colors into database
export const getColorFieldsForPhotoDbInsert = async (
...args: Parameters<typeof getColorFieldsForImageUrl>
) => {
const { colorData, ...rest } = await getColorFieldsForImageUrl(...args) ?? {};
if (colorData) {
return {
colorData: JSON.stringify(colorData),
...rest,
};
}
};
// Used when preparing colors for form
export const getColorFieldsForPhotoForm = async (
...args: Parameters<typeof getColorFieldsForImageUrl>
) => {
const { colorSort, ...rest } =
await getColorFieldsForPhotoDbInsert(...args) ?? {};
return {
colorSort: `${colorSort}`,
...rest,
};
};
export const getColorFromAI = async (
_url: string,
useBatch?: boolean,
) => {
const url = getNextImageUrlForManipulation(_url, IS_PREVIEW);
const image = await getImageBase64FromUrl(url);
const hexColor = await generateOpenAiImageQuery(image, `
Does this image have a primary subject color?
If yes, what is the approximate hex color of the subject.
If not, what is the approximate hex color of the background?
Respond only with a hex color value:
`, useBatch);
const hex = hexColor?.match(/#*([a-f0-9]{6})/i)?.[1];
if (hex) {
return convertHexToOklch(`#${hex}`);
}
};

51
src/photo/color/sort.ts Normal file
View File

@ -0,0 +1,51 @@
import {
COLOR_SORT_STARTING_HUE,
COLOR_SORT_CHROMA_CUTOFF,
} from '@/app/config';
import { PhotoColorData } from './client';
// Start with yellow
const DEFAULT_HUE_MAXIMA = 80;
// Only sort sufficiently vibrant colors
const DEFAULT_CHROMA_CUTOFF = 0.05;
const SECTION_OFFSET_HUE = 200;
const SECTION_OFFSET_LOW_CHROMA = 100;
const SECTION_OFFSET_BLACK_AND_WHITE = 0;
export const parseStartingHue = (hueMaxima = '') => {
const hueMaximaInt = parseInt(hueMaxima);
return isNaN(hueMaximaInt) ? DEFAULT_HUE_MAXIMA : hueMaximaInt;
};
export const parseChromaCutoff = (chromaCutoff = '') => {
const chromaCutoffFloat = parseFloat(chromaCutoff);
return isNaN(chromaCutoffFloat) ? DEFAULT_CHROMA_CUTOFF : chromaCutoffFloat;
};
export const calculateColorSort = (colorData: PhotoColorData) => {
// Prefer AI-generated colors when available
const colorPreferred = colorData.ai ?? colorData.average;
// Re-center hues based on start point
const hueNormalized = colorPreferred.h >= COLOR_SORT_STARTING_HUE
? 360 - Math.abs(colorPreferred.h - COLOR_SORT_STARTING_HUE)
: Math.abs(colorPreferred.h - COLOR_SORT_STARTING_HUE);
// Analyze average chroma to determine if colors are sufficiently vibrant
const allColors = colorData.ai ? [colorData.ai] : [];
allColors.push(...colorData.colors, colorData.average);
const chromaAverage = allColors.reduce(
(acc, color) => acc + color.c, 0) / allColors.length;
const colorSort = colorPreferred.c >= COLOR_SORT_CHROMA_CUTOFF
// Organize by hue
? hueNormalized + SECTION_OFFSET_HUE
: chromaAverage > 0
// Organize by lightness (with some chroma)
? colorData.average.l * 100 + SECTION_OFFSET_LOW_CHROMA
// Organize by lightness (strictly black and white)
: colorData.average.l * 100 + SECTION_OFFSET_BLACK_AND_WHITE;
return Math.round(colorSort);
};

View File

@ -178,6 +178,14 @@ export const getOrderByFromOptions = (options: PhotoQueryOptions) => {
return sortWithPriority
? 'ORDER BY priority_order ASC, created_at ASC'
: 'ORDER BY created_at ASC';
case 'hue':
return sortWithPriority
? 'ORDER BY priority_order ASC, color_sort DESC'
: 'ORDER BY color_sort DESC';
case 'hueAsc':
return sortWithPriority
? 'ORDER BY priority_order ASC, color_sort ASC'
: 'ORDER BY color_sort ASC';
}
};

View File

@ -78,6 +78,14 @@ export const MIGRATIONS: Migration[] = [{
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS exclude_from_feeds BOOLEAN DEFAULT FALSE
`,
}, {
label: '07: Color Data',
fields: ['color_data', 'color_sort'],
run: () => sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS color_data JSONB,
ADD COLUMN IF NOT EXISTS color_sort SMALLINT
`,
}];
export const migrationForError = (e: any) =>
@ -87,4 +95,4 @@ export const migrationForError = (e: any) =>
new RegExp(`column "${field}" of relation "photos" does not exist`, 'i').test(e.message) ||
new RegExp(`column "${field}" does not exist`, 'i').test(e.message)
)),
);
);

View File

@ -18,7 +18,8 @@ import { Films } from '@/film';
import {
ADMIN_SQL_DEBUG_ENABLED,
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
COLOR_SORT_ENABLED,
} from '@/app/config';
import {
PhotoQueryOptions,
@ -30,13 +31,14 @@ import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration';
import {
SYNC_QUERY_LIMIT,
UPDATE_QUERY_LIMIT,
UPDATED_BEFORE_01,
UPDATED_BEFORE_02,
} from '../sync';
} from '../update';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Recipes } from '@/recipe';
import { Years } from '@/years';
import { PhotoColorData } from '../color/client';
const createPhotosTable = () =>
sql`
@ -66,6 +68,8 @@ const createPhotosTable = () =>
film VARCHAR(255),
recipe_title VARCHAR(255),
recipe_data JSONB,
color_data JSONB,
color_sort SMALLINT,
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
@ -193,6 +197,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
film,
recipe_title,
recipe_data,
color_data,
color_sort,
priority_order,
exclude_from_feeds,
hidden,
@ -225,6 +231,8 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
${photo.film},
${photo.recipeTitle},
${photo.recipeData},
${photo.colorData},
${photo.colorSort},
${photo.priorityOrder},
${photo.excludeFromFeeds},
${photo.hidden},
@ -260,6 +268,8 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
film=${photo.film},
recipe_title=${photo.recipeTitle},
recipe_data=${photo.recipeData},
color_data=${photo.colorData},
color_sort=${photo.colorSort},
priority_order=${photo.priorityOrder || null},
exclude_from_feeds=${photo.excludeFromFeeds},
hidden=${photo.hidden},
@ -616,7 +626,7 @@ export const getPhoto = async (
.then(photos => photos.length > 0 ? photos[0] : undefined);
}, 'getPhoto');
// Sync queries
// Update queries
const outdatedWhereClauses = [
`updated_at < $1`,
@ -630,7 +640,7 @@ const outdatedWhereValues = [
];
const needsAiTextWhereClauses =
AI_TEXT_GENERATION_ENABLED
AI_CONTENT_GENERATION_ENABLED
? AI_TEXT_AUTO_GENERATED_FIELDS
.map(field => {
switch (field) {
@ -642,29 +652,71 @@ const needsAiTextWhereClauses =
})
: [];
const needsColorDataWhereClauses = COLOR_SORT_ENABLED
? [`(
color_data IS NULL OR
color_sort IS NULL
)`]
: [];
const needsSyncWhereStatement =
`WHERE ${outdatedWhereClauses.concat(needsAiTextWhereClauses).join(' OR ')}`;
`WHERE ${[
...outdatedWhereClauses,
...needsAiTextWhereClauses,
...needsColorDataWhereClauses,
].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 getPhotosInNeedOfUpdate = () =>
safelyQueryPhotos(
() => query(`
SELECT * FROM photos
${needsSyncWhereStatement}
ORDER BY created_at DESC
LIMIT ${UPDATE_QUERY_LIMIT}
`,
outdatedWhereValues,
)
.then(({ rows }) => rows.map(parsePhotoFromDb)),
'getPhotosInNeedOfUpdate',
);
export const getPhotosInNeedOfSyncCount = () => safelyQueryPhotos(
() => query(`
SELECT COUNT(*) FROM photos
${needsSyncWhereStatement}
`,
outdatedWhereValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getPhotosInNeedOfSyncCount',
);
export const getPhotosInNeedOfUpdateCount = () =>
safelyQueryPhotos(
() => query(`
SELECT COUNT(*) FROM photos
${needsSyncWhereStatement}
`,
outdatedWhereValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getPhotosInNeedOfUpdateCount',
);
// Backfills and experimentation
export const getColorDataForPhotos = () =>
safelyQueryPhotos(() => sql<{
id: string,
url: string,
color_data?: PhotoColorData,
}>`
SELECT id, url, color_data FROM photos
LIMIT ${UPDATE_QUERY_LIMIT}
`.then(({ rows }) => rows.map(({ id, url, color_data }) =>
({ id, url, colorData: color_data })))
, 'getColorDataForPhotos');
export const updateColorDataForPhoto = (
photoId: string,
colorData: string,
colorSort: number,
) =>
safelyQueryPhotos(
() => sql`
UPDATE photos SET
color_data=${colorData},
color_sort=${colorSort}
WHERE id=${photoId}
`,
'updateColorDataForPhoto',
);

View File

@ -49,6 +49,8 @@ import { useAppText } from '@/i18n/state/client';
import IconAddUpload from '@/components/icons/IconAddUpload';
import { didVisibilityChange } from '../visibility';
import FieldsetVisibility from '../visibility/FieldsetVisibility';
import PhotoColors from '../color/PhotoColors';
import { generateColorDataFromString } from '../color/client';
const THUMBNAIL_SIZE = 300;
@ -117,8 +119,12 @@ export default function PhotoForm({
let a = currentForm[key];
let b = value;
if (FIELDS_WITH_JSON.includes(key)) {
a = a ? JSON.parse(a) : undefined;
b = b ? JSON.parse(b) : undefined;
try {
a = a ? JSON.parse(a) : undefined;
b = b ? JSON.parse(b) : undefined;
} catch (error) {
console.log(`Error parsing JSON: ${key}`, error);
}
}
if (!deepEqual(a, b)) {
changedKeys.push(key as keyof PhotoFormData);
@ -445,6 +451,17 @@ export default function PhotoForm({
film={formData.film}
onMatchResults={onMatchResults}
/>;
case 'colorData':
return <FieldsetWithStatus
key={key}
{...fieldProps}
noteComplex={<PhotoColors
className="translate-y-[1.5px]"
classNameDot="size-[13px]!"
// eslint-disable-next-line max-len
colorData={generateColorDataFromString(formData.colorData)}
/>}
/>;
case 'visibility':
return <FieldsetVisibility
key={key}

View File

@ -14,6 +14,7 @@ import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { ReactNode } from 'react';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
import { COLOR_SORT_ENABLED } from '@/app/config';
type VirtualFields =
'visibility' |
@ -182,6 +183,16 @@ const FORM_METADATA = (
label: 'taken at (naive)',
validate: validationMessageNaivePostgresDateString,
},
colorData: {
type: 'textarea',
label: 'color data',
isJson: true,
shouldHide: () => !COLOR_SORT_ENABLED,
},
colorSort: {
label: 'color sort',
shouldHide: () => !COLOR_SORT_ENABLED,
},
priorityOrder: { label: 'priority order' },
excludeFromFeeds: { label: 'exclude from feeds', type: 'hidden' },
hidden: { label: 'hidden', type: 'hidden' },
@ -257,6 +268,8 @@ export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
return value ? 'true' : 'false';
case 'recipeData':
return JSON.stringify(value);
case 'colorData':
return JSON.stringify(value);
default:
return value !== undefined && value !== null
? value.toString()
@ -343,6 +356,9 @@ export const convertFormDataToPhotoDbInsert = (
exposureCompensation: photoForm.exposureCompensation
? parseFloat(photoForm.exposureCompensation)
: undefined,
colorSort: photoForm.colorSort
? parseInt(photoForm.colorSort)
: undefined,
priorityOrder: photoForm.priorityOrder
? parseFloat(photoForm.priorityOrder)
: undefined,

View File

@ -22,8 +22,9 @@ import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
import { PhotoUpdateStatus, generatePhotoUpdateStatus } from './update';
import { AppTextState } from '@/i18n/state';
import { PhotoColorData } from './color/client';
// INFINITE SCROLL: FULL
export const INFINITE_SCROLL_FULL_INITIAL =
@ -83,6 +84,8 @@ export interface PhotoDbInsert extends PhotoExif {
tags?: string[]
recipeTitle?: string
locationName?: string
colorData?: string
colorSort?: number
priorityOrder?: number
excludeFromFeeds?: boolean
hidden?: boolean
@ -100,7 +103,7 @@ export interface PhotoDb extends
}
// Parsed db response
export interface Photo extends Omit<PhotoDb, 'recipeData'> {
export interface Photo extends Omit<PhotoDb, 'recipeData' | 'colorData'> {
focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string
@ -110,7 +113,8 @@ export interface Photo extends Omit<PhotoDb, 'recipeData'> {
takenAtNaiveFormatted: string
tags: string[]
recipeData?: FujifilmRecipe
syncStatus: PhotoSyncStatus
colorData?: PhotoColorData
updateStatus: PhotoUpdateStatus
}
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
@ -144,7 +148,10 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
? JSON.parse(photoDb.recipeData)
: photoDb.recipeData
: undefined,
syncStatus: generatePhotoSyncStatus(photoDb),
colorData: photoDb.colorData
? photoDb.colorData
: undefined,
updateStatus: generatePhotoUpdateStatus(photoDb),
} as Photo;
};
@ -164,15 +171,9 @@ export const convertPhotoToPhotoDbInsert = (
...photo,
takenAt: photo.takenAt.toISOString(),
recipeData: JSON.stringify(photo.recipeData),
colorData: JSON.stringify(photo.colorData),
});
export const photoStatsAsString = (photo: Photo) => [
photo.model,
photo.focalLengthFormatted,
photo.fNumberFormatted,
photo.isoFormatted,
].join(' ');
export const descriptionForPhoto = (
photo: Photo,
includeSemanticDescription?: boolean,

View File

@ -25,17 +25,18 @@ import {
} from './db/query';
import { PhotoDbInsert } from '.';
import { convertExifToFormData } from './form/server';
import { getColorFieldsForPhotoForm } from './color/server';
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
export const extractImageDataFromBlobPath = async (
blobPath: string,
options?: {
options: {
includeInitialPhotoFields?: boolean
generateBlurData?: boolean
generateResizedImage?: boolean
},
} = {},
): Promise<{
blobId?: string
formDataFromExif?: Partial<PhotoFormData>
@ -48,7 +49,7 @@ export const extractImageDataFromBlobPath = async (
includeInitialPhotoFields,
generateBlurData,
generateResizedImage,
} = options ?? {};
} = options;
const url = decodeURIComponent(blobPath);
@ -112,6 +113,8 @@ export const extractImageDataFromBlobPath = async (
if (error) { console.log(error); }
const colorFields = await getColorFieldsForPhotoForm(url);
return {
blobId,
...exifData && {
@ -123,7 +126,8 @@ export const extractImageDataFromBlobPath = async (
url,
},
...generateBlurData && { blurData },
...convertExifToFormData (exifData, film, recipe),
...convertExifToFormData(exifData, film, recipe),
...colorFields,
},
},
imageResizedBase64,
@ -135,17 +139,20 @@ export const extractImageDataFromBlobPath = async (
const generateBase64 = async (
image: ArrayBuffer,
middleware: (sharp: Sharp) => Sharp,
middleware?: (sharp: Sharp) => Sharp,
) =>
middleware(sharp(image))
(middleware ? middleware(sharp(image)) : sharp(image))
.withMetadata()
.toFormat('jpeg', { quality: 90 })
.toBuffer()
.then(data => `data:image/jpeg;base64,${data.toString('base64')}`);
const resizeImage = async (image: ArrayBuffer) =>
const resizeImage = async (
image: ArrayBuffer,
width = IMAGE_WIDTH_RESIZE,
) =>
generateBase64(image, sharp => sharp
.resize(IMAGE_WIDTH_RESIZE),
.resize(width),
);
const blurImage = async (image: ArrayBuffer) =>
@ -155,10 +162,22 @@ const blurImage = async (image: ArrayBuffer) =>
.blur(4),
);
export const resizeImageFromUrl = async (url: string) =>
export const getImageBase64FromUrl = async (url: string) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => resizeImage(buffer))
.then(buffer => generateBase64(buffer))
.catch(e => {
console.log(`Error getting image base64 from URL (${url})`, e);
return '';
});
export const resizeImageFromUrl = async (
url: string,
width?: number,
) =>
fetch(decodeURIComponent(url))
.then(res => res.arrayBuffer())
.then(buffer => resizeImage(buffer, width))
.catch(e => {
console.log(`Error resizing image from URL (${url})`, e);
return '';
@ -210,6 +229,7 @@ export const convertFormDataToPhotoDbInsertAndLookupRecipeTitle =
photo.recipeData,
photo.film,
);
// Only replace recipe title when a new one is found
if (recipeTitle) {
photo.recipeTitle = recipeTitle;
}

View File

@ -4,6 +4,7 @@ import { getSortStateFromPath } from './path';
import IconCheck from '@/components/icons/IconCheck';
import { clsx } from 'clsx/lite';
import { useAppText } from '@/i18n/state/client';
import { COLOR_SORT_ENABLED } from '@/app/config';
export default function SortMenu({
isOpen,
@ -11,10 +12,12 @@ export default function SortMenu({
isAscending,
isTakenAt,
isUploadedAt,
pathNewest,
pathOldest,
isColor,
pathDescending,
pathAscending,
pathTakenAt,
pathUploadedAt,
pathColor,
}: {
isOpen?: boolean
setIsOpen?: (isOpen: boolean) => void
@ -32,6 +35,40 @@ export default function SortMenu({
</span>,
});
const itemsSortOrder = [{
...renderLabel(
isColor ? appText.sort.descending : appText.sort.newest,
!isAscending,
),
icon: renderIcon(!isAscending),
href: pathDescending,
}, {
...renderLabel(
isColor ? appText.sort.ascending : appText.sort.oldest,
isAscending,
),
icon: renderIcon(isAscending),
href: pathAscending,
}];
const itemsSortType = [{
...renderLabel(appText.sort.takenAt, isTakenAt),
icon: renderIcon(isTakenAt),
href: pathTakenAt,
}, {
...renderLabel(appText.sort.uploadedAtShort, isUploadedAt),
icon: renderIcon(isUploadedAt),
href: pathUploadedAt,
}];
if (COLOR_SORT_ENABLED) {
itemsSortType.push({
...renderLabel(appText.sort.color, isColor),
icon: renderIcon(isColor),
href: pathColor,
});
}
return (
<SwitcherItemMenu
{...{ isOpen, setIsOpen }}
@ -40,25 +77,9 @@ export default function SortMenu({
className="shrink-0 translate-x-[0.5px] translate-y-[1px]"
/>}
sections={[{
items: [{
...renderLabel(appText.sort.newest, !isAscending),
icon: renderIcon(!isAscending),
href: pathNewest,
}, {
...renderLabel(appText.sort.oldest, isAscending),
icon: renderIcon(isAscending),
href: pathOldest,
}],
items: itemsSortOrder,
}, {
items: [{
...renderLabel(appText.sort.takenAt, isTakenAt),
icon: renderIcon(isTakenAt),
href: pathTakenAt,
}, {
...renderLabel(appText.sort.uploadedAtShort, isUploadedAt),
icon: renderIcon(isUploadedAt),
href: pathUploadedAt,
}],
items: itemsSortType,
}]}
align="start"
side="top"

View File

@ -16,19 +16,27 @@ export const getNavSortControlFromString = (
export const SORT_BY_OPTIONS = [{
sortBy: 'takenAt',
string: 'taken-at',
label: 'Taken At (Newest First)',
canBeDefault: true,
}, {
sortBy: 'takenAtAsc',
string: 'taken-at-oldest-first',
label: 'Taken At (Oldest First)',
canBeDefault: true,
}, {
sortBy: 'createdAt',
string: 'uploaded-at',
label: 'Uploaded At (Newest First)',
canBeDefault: true,
}, {
sortBy: 'createdAtAsc',
string: 'uploaded-at-oldest-first',
label: 'Uploaded At (Oldest First)',
canBeDefault: true,
}, {
sortBy: 'hue',
string: 'hue',
canBeDefault: false,
}, {
sortBy: 'hueAsc',
string: 'hue-oldest-first',
canBeDefault: false,
}] as const;
export type SortBy = (typeof SORT_BY_OPTIONS)[number]['sortBy'];
@ -50,9 +58,13 @@ export const getSortByFromString = (sortBy = ''): SortBy => {
case 'taken-at-oldest-first': return 'takenAtAsc';
case 'uploaded-at': return 'createdAt';
case 'uploaded-at-oldest-first': return 'createdAtAsc';
default:return 'takenAt';
case 'hue': return 'hue';
case 'hue-oldest-first': return 'hueAsc';
default: return 'takenAt';
}
};
export const isSortAscending = (sortBy: SortBy) =>
sortBy === 'takenAtAsc' || sortBy === 'createdAtAsc';
sortBy === 'takenAtAsc' ||
sortBy === 'createdAtAsc' ||
sortBy === 'hueAsc';

View File

@ -3,8 +3,9 @@
import {
doesPathOfferSort as _doesPathOfferSort,
PARAM_SORT_ORDER_NEWEST,
PARAM_SORT_ORDER_OLDEST,
PARAM_SORT_ORDER_ASCENDING,
PARAM_SORT_ORDER_DESCENDING,
PARAM_SORT_TYPE_COLOR,
PARAM_SORT_TYPE_TAKEN_AT,
PARAM_SORT_TYPE_UPLOADED_AT,
PATH_FULL_INFERRED,
@ -24,19 +25,27 @@ const getSortByComponents = (sortBy: SortBy): {
switch (sortBy) {
case 'takenAt': return {
sortType: PARAM_SORT_TYPE_TAKEN_AT,
sortOrder: PARAM_SORT_ORDER_NEWEST,
sortOrder: PARAM_SORT_ORDER_DESCENDING,
};
case 'takenAtAsc': return {
sortType: PARAM_SORT_TYPE_TAKEN_AT,
sortOrder: PARAM_SORT_ORDER_OLDEST,
sortOrder: PARAM_SORT_ORDER_ASCENDING,
};
case 'createdAt': return {
sortType: PARAM_SORT_TYPE_UPLOADED_AT,
sortOrder: PARAM_SORT_ORDER_NEWEST,
sortOrder: PARAM_SORT_ORDER_DESCENDING,
};
case 'createdAtAsc': return {
sortType: PARAM_SORT_TYPE_UPLOADED_AT,
sortOrder: PARAM_SORT_ORDER_OLDEST,
sortOrder: PARAM_SORT_ORDER_ASCENDING,
};
case 'hue': return {
sortType: PARAM_SORT_TYPE_COLOR,
sortOrder: PARAM_SORT_ORDER_DESCENDING,
};
case 'hueAsc': return {
sortType: PARAM_SORT_TYPE_COLOR,
sortOrder: PARAM_SORT_ORDER_ASCENDING,
};
}
};
@ -54,7 +63,7 @@ const _getSortOptionsFromParams = (
sortWithPriority: boolean
} => {
let sortBy: SortBy = 'takenAt';
const isAscending = sortOrder === PARAM_SORT_ORDER_OLDEST;
const isAscending = sortOrder === PARAM_SORT_ORDER_ASCENDING;
switch (sortType) {
case PARAM_SORT_TYPE_TAKEN_AT: {
sortBy = isAscending
@ -68,6 +77,12 @@ const _getSortOptionsFromParams = (
: 'createdAt';
break;
}
case PARAM_SORT_TYPE_COLOR: {
sortBy = isAscending
? 'hueAsc'
: 'hue';
break;
}
}
return {
sortBy,
@ -107,12 +122,13 @@ export const getSortStateFromPath = (pathname: string) => {
} = getPathSortComponents(pathname);
const isSortedByDefault = sortBy === USER_DEFAULT_SORT_BY;
const sortOrderReversed = sortOrder === PARAM_SORT_ORDER_OLDEST
? PARAM_SORT_ORDER_NEWEST
: PARAM_SORT_ORDER_OLDEST;
const isAscending = sortOrder === PARAM_SORT_ORDER_OLDEST;
const sortOrderReversed = sortOrder === PARAM_SORT_ORDER_DESCENDING
? PARAM_SORT_ORDER_ASCENDING
: PARAM_SORT_ORDER_DESCENDING;
const isAscending = sortOrder === PARAM_SORT_ORDER_ASCENDING;
const isTakenAt = sortType === PARAM_SORT_TYPE_TAKEN_AT;
const isUploadedAt = sortType === PARAM_SORT_TYPE_UPLOADED_AT;
const isColor = sortType === PARAM_SORT_TYPE_COLOR;
const getPath = ({
gridOrFull = _gridOrFull,
@ -147,14 +163,16 @@ export const getSortStateFromPath = (pathname: string) => {
getPath({ sortType, sortOrder: sortOrderReversed });
// Sort menu paths
const pathNewest =
getPath({ sortType, sortOrder: PARAM_SORT_ORDER_NEWEST });
const pathOldest =
getPath({ sortType, sortOrder: PARAM_SORT_ORDER_OLDEST });
const pathDescending =
getPath({ sortType, sortOrder: PARAM_SORT_ORDER_DESCENDING });
const pathAscending =
getPath({ sortType, sortOrder: PARAM_SORT_ORDER_ASCENDING });
const pathTakenAt =
getPath({ sortType: PARAM_SORT_TYPE_TAKEN_AT, sortOrder });
const pathUploadedAt =
getPath({ sortType: PARAM_SORT_TYPE_UPLOADED_AT, sortOrder });
const pathColor =
getPath({ sortType: PARAM_SORT_TYPE_COLOR, sortOrder });
// Sort clear
const pathClearSort = _gridOrFull === 'grid'
@ -168,12 +186,14 @@ export const getSortStateFromPath = (pathname: string) => {
isAscending,
isTakenAt,
isUploadedAt,
isColor,
pathGrid,
pathFull,
pathNewest,
pathOldest,
pathDescending,
pathAscending,
pathTakenAt,
pathUploadedAt,
pathColor,
pathClearSort,
pathSortToggle,
};

View File

@ -1,87 +0,0 @@
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Photo, PhotoDb } from '.';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
} from '@/app/config';
import { AiAutoGeneratedField } from './ai';
export interface PhotoSyncStatus {
isOutdated: boolean;
missingAiTextFields: AiAutoGeneratedField[];
}
export const SYNC_QUERY_LIMIT = 1000;
export const UPDATED_BEFORE_01 = new Date('2024-06-16');
// UTC 2025-02-24 05:30:00
export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0));
const isPhotoOutdated = (photo: PhotoDb) =>
photo.updatedAt < UPDATED_BEFORE_01 || (
photo.updatedAt < UPDATED_BEFORE_02 &&
photo.make === MAKE_FUJIFILM
);
const getMissingAiTextFields = ({
title,
caption,
tags,
semanticDescription,
}: PhotoDb | Photo): AiAutoGeneratedField[] =>
AI_TEXT_GENERATION_ENABLED
? AI_TEXT_AUTO_GENERATED_FIELDS.reduce((fields, field) => {
switch (field) {
case 'title':
return !title ? [...fields, 'title'] : fields;
case 'caption':
return !caption ? [...fields, 'caption'] : fields;
case 'tags':
return (tags ?? []).length === 0 ? [...fields, 'tags'] : fields;
case 'semantic':
return !semanticDescription ? [...fields, 'semantic'] : fields;
}
}, [] as AiAutoGeneratedField[])
: [];
export const generatePhotoSyncStatus = (photo: PhotoDb): PhotoSyncStatus => ({
isOutdated: isPhotoOutdated(photo),
missingAiTextFields: getMissingAiTextFields(photo),
});
export const photoNeedsToBeSynced = (photo: Photo) =>
photo.syncStatus.isOutdated ||
photo.syncStatus.missingAiTextFields.length > 0;
export const getPhotoSyncStatusText = (photo: Photo) => {
const { isOutdated, missingAiTextFields } = photo.syncStatus;
const text: string[] = [];
if (isOutdated) {
text.push('Outdated Data');
} else if (missingAiTextFields.length > 0) {
const missingFieldsText = missingAiTextFields
.map(field => field.toLocaleUpperCase())
.join(', ');
text.push(`Missing AI Text (${missingFieldsText})`);
}
return text.join(' and ') + '—sync to update';
};
export const getPhotosSyncStatusText = (photos: Photo[]) => {
const statusText = [] as string[];
const photosCountOutdated = photos.filter(
photo => photo.syncStatus.isOutdated,
).length;
const photosCountMissingAiText = photos.filter(
photo => photo.syncStatus.missingAiTextFields.length > 0,
).length;
if (photosCountOutdated > 0) {
statusText.push(`${photosCountOutdated} outdated`);
}
if (photosCountMissingAiText > 0) {
statusText.push(`${photosCountMissingAiText} missing AI text`);
}
return statusText.join(', ');
};

View File

@ -0,0 +1,21 @@
import clsx from 'clsx/lite';
import { getPhotoUpdateStatusText } from '.';
import Tooltip from '@/components/Tooltip';
import { Photo } from '..';
export default function UpdateTooltip({
photo,
}: {
photo: Photo
}) {
return (
<Tooltip
content={getPhotoUpdateStatusText(photo)}
classNameTrigger={clsx(
'text-blue-600 dark:text-blue-400',
'translate-y-[0.5px]',
)}
supportMobile
/>
);
}

142
src/photo/update/index.ts Normal file
View File

@ -0,0 +1,142 @@
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Photo, PhotoDb } from '..';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_CONTENT_GENERATION_ENABLED,
COLOR_SORT_ENABLED,
} from '@/app/config';
import { AiAutoGeneratedField } from '../ai';
import { capitalize } from '@/utility/string';
export interface PhotoUpdateStatus {
isOutdated: boolean
isMissingAiTextFields: AiAutoGeneratedField[]
isMissingColorData: boolean
}
export const UPDATE_QUERY_LIMIT = 1000;
export const UPDATED_BEFORE_01 = new Date('2024-06-16');
// UTC 2025-02-24 05:30:00
export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0));
const isPhotoOutdated = (photo: PhotoDb) =>
photo.updatedAt < UPDATED_BEFORE_01 || (
photo.updatedAt < UPDATED_BEFORE_02 &&
photo.make === MAKE_FUJIFILM
);
const getMissingAiTextFields = ({
title,
caption,
tags,
semanticDescription,
}: PhotoDb | Photo): AiAutoGeneratedField[] =>
AI_CONTENT_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 isPhotoMissingColorData = (photo: PhotoDb) =>
// "== null" intentional check for undefined or null
COLOR_SORT_ENABLED && (
photo.colorData == null ||
photo.colorSort == null
);
export const generatePhotoUpdateStatus = (
photo: PhotoDb,
): PhotoUpdateStatus => ({
isOutdated: isPhotoOutdated(photo),
isMissingAiTextFields: getMissingAiTextFields(photo),
isMissingColorData: isPhotoMissingColorData(photo),
});
export const photoNeedsToBeUpdated = (photo: Photo) =>
photo.updateStatus.isOutdated ||
photo.updateStatus.isMissingAiTextFields.length > 0 ||
photo.updateStatus.isMissingColorData;
export const isPhotoOnlyMissingColorData = (photo?: Photo) =>
photo?.updateStatus.isMissingColorData &&
!photo?.updateStatus.isOutdated &&
(photo?.updateStatus.isMissingAiTextFields.length ?? 0) === 0;
export const getPhotoUpdateStatusText = (photo: Photo) => {
const {
isOutdated,
isMissingAiTextFields,
isMissingColorData,
} = photo.updateStatus;
const cta = 'sync to update';
if (isOutdated) {
return `Outdated data—${cta}`;
} else {
const textParts: string[] = [];
if (isMissingAiTextFields.length > 0) {
const missingFields = isMissingAiTextFields
.map(field => field.toLocaleUpperCase())
.join(', ');
textParts.push(`AI text (${missingFields})`);
}
if (isMissingColorData) {
textParts.push('color data');
}
if (textParts.length > 0) {
return `Missing ${textParts.join(', ')}${cta}`;
} else {
return capitalize(cta);
}
}
};
export const getPhotosUpdateStatusCounts = (photos: Photo[]) => {
const photosCountOutdated = photos.filter(
photo => photo.updateStatus.isOutdated,
).length;
const photosCountMissingAiText = photos.filter(
photo => photo.updateStatus.isMissingAiTextFields.length > 0,
).length;
const photosCountMissingColorData = photos.filter(
photo => photo.updateStatus.isMissingColorData,
).length;
return {
photosCountOutdated,
photosCountMissingAiText,
photosCountMissingColorData,
};
};
export const getPhotosUpdateStatusText = (photos: Photo[]) => {
const statusText = [] as string[];
const {
photosCountOutdated,
photosCountMissingAiText,
photosCountMissingColorData,
} = getPhotosUpdateStatusCounts(photos);
if (photosCountOutdated > 0) {
statusText.push(`${photosCountOutdated} outdated`);
}
if (photosCountMissingAiText > 0) {
statusText.push(`${photosCountMissingAiText} missing AI text`);
}
if (photosCountMissingColorData > 0) {
statusText.push(`${photosCountMissingColorData} missing color data`);
}
return statusText.join(', ');
};

View File

@ -4,7 +4,7 @@ import { createOpenAI } from '@ai-sdk/openai';
import { Redis } from '@upstash/redis';
import { Ratelimit } from '@upstash/ratelimit';
import {
AI_TEXT_GENERATION_ENABLED,
AI_CONTENT_GENERATION_ENABLED,
HAS_REDIS_STORAGE,
OPENAI_BASE_URL,
} from '@/app/config';
@ -16,7 +16,7 @@ const redis = HAS_REDIS_STORAGE ? Redis.fromEnv() : undefined;
const RATE_LIMIT_IDENTIFIER = 'openai-image-query';
const MODEL = 'gpt-4o';
const openai = AI_TEXT_GENERATION_ENABLED
const openai = AI_CONTENT_GENERATION_ENABLED
? createOpenAI({
apiKey: process.env.OPENAI_SECRET_KEY,
...OPENAI_BASE_URL && { baseURL: OPENAI_BASE_URL },

View File

@ -2,7 +2,7 @@
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
import PhotoRecents from './PhotoRecents';
@ -37,7 +37,7 @@ export default function RecentsHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -5,7 +5,7 @@ import PhotoHeader from '@/photo/PhotoHeader';
import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/app/AppState';
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function RecipeHeader({
@ -53,7 +53,7 @@ export default function RecipeHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -1,7 +1,7 @@
import { Photo, photoQuantityText } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoPrivate from './PhotoPrivate';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function PrivateHeader({
@ -25,7 +25,7 @@ export default async function PrivateHeader({
selectedPhoto={selectedPhoto}
indexNumber={indexNumber}
count={count}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
/>
);
}

View File

@ -3,7 +3,7 @@ import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos, isTagFavs } from '.';
import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFavs from './PhotoFavs';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default async function TagHeader({
@ -47,7 +47,7 @@ export default async function TagHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);

View File

@ -2,7 +2,7 @@
import { descriptionForPhotoSet, Photo, PhotoDateRange } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED } from '@/app/config';
import PhotoYear from './PhotoYear';
import { useAppText } from '@/i18n/state/client';
@ -43,7 +43,7 @@ export default function YearHeader({
indexNumber={indexNumber}
count={count}
dateRange={dateRange}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
hasAiTextGeneration={AI_CONTENT_GENERATION_ENABLED}
includeShareButton
/>
);