@@ -133,6 +143,7 @@ export default function AdminPhotosSyncClient({
canDelete={false}
dateType="updatedAt"
shouldScrollIntoViewOnExternalSync
+ updateMode
/>
diff --git a/src/admin/AdminUploadsTableRow.tsx b/src/admin/AdminUploadsTableRow.tsx
index 090432e8..235ea6d6 100644
--- a/src/admin/AdminUploadsTableRow.tsx
+++ b/src/admin/AdminUploadsTableRow.tsx
@@ -106,6 +106,7 @@ export default function AdminUploadsTableRow({
diff --git a/src/admin/PhotoSyncButton.tsx b/src/admin/PhotoSyncButton.tsx
index 4f69bef3..b11d182f 100644
--- a/src/admin/PhotoSyncButton.tsx
+++ b/src/admin/PhotoSyncButton.tsx
@@ -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 (
-
+
}
+ icon={updateMode
+ ?
+ : }
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) {
diff --git a/src/admin/actions.ts b/src/admin/actions.ts
index c108be96..37186bda 100644
--- a/src/admin/actions.ts
+++ b/src/admin/actions.ts
@@ -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)
diff --git a/src/admin/config/AdminAppConfigurationClient.tsx b/src/admin/config/AdminAppConfigurationClient.tsx
index 942d98f5..f8255938 100644
--- a/src/admin/config/AdminAppConfigurationClient.tsx
+++ b/src/admin/config/AdminAppConfigurationClient.tsx
@@ -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}
;
+ const renderColorDot = (color: Oklch | string, includeTooltip?: boolean) =>
+ ;
+
const renderGroupContent = (key: ConfigSectionKey): JSX.Element => {
switch (key) {
case 'Storage':
@@ -406,7 +419,7 @@ export default function AdminAppConfigurationClient({
>}
>;
- case 'AI Text Generation':
+ case 'AI Content Generation':
return <>
@@ -611,19 +624,70 @@ export default function AdminAppConfigurationClient({
optional
>
- {SORT_BY_OPTIONS.map(({sortBy, string }) =>
-
- {renderSubStatus(
- sortBy === defaultSortBy ? 'checked' : 'optional',
- `${string}${sortBy === APP_DEFAULT_SORT_BY
- ? ' (default)'
- : ''}`,
- )}
- )}
+ {SORT_BY_OPTIONS
+ .filter(({ canBeDefault }) => canBeDefault)
+ .map(({sortBy, string }) =>
+
+ {renderSubStatus(
+ sortBy === defaultSortBy ? 'checked' : 'optional',
+ `${string}${sortBy === APP_DEFAULT_SORT_BY
+ ? ' (default)'
+ : ''}`,
+ )}
+ )}
Change default sort on grid/full homepages
{renderEnvVars(['NEXT_PUBLIC_DEFAULT_SORT'])}
+
+ Set environment variable to {'"none"'}, {'"toggle"'} (default),
+ or {'"menu"'}, to control sort UI on grid/full homepages:
+ {renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
+
+
+ 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',
+ ])}
+
+
+ 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):
+
+
+
+
+
-
- Set environment variable to {'"none"'}, {'"toggle"'} (default),
- or {'"menu"'}, to control sort UI on grid/full homepages:
- {renderEnvVars(['NEXT_PUBLIC_NAV_SORT_CONTROL'])}
-
>;
case 'Display':
return <>
@@ -768,17 +823,11 @@ export default function AdminAppConfigurationClient({
}
+ accessory={matteColor && renderColorDot(matteColor)}
/>
}
+ accessory={matteColorDark && renderColorDot(matteColorDark)}
/>
diff --git a/src/admin/config/index.tsx b/src/admin/config/index.tsx
index b98af542..7de371ce 100644
--- a/src/admin/config/index.tsx
+++ b/src/admin/config/index.tsx
@@ -27,7 +27,7 @@ const ADMIN_CONFIG_SECTIONS = [{
required: true,
icon: ,
}, {
- title: 'AI Text Generation',
+ title: 'AI Content Generation',
titleShort: 'AI',
required: false,
icon: ,
diff --git a/src/admin/confirm.ts b/src/admin/confirm.ts
index c5112f5e..59f6d3bc 100644
--- a/src/admin/confirm.ts
+++ b/src/admin/confirm.ts
@@ -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(' ');
};
diff --git a/src/admin/insights/AdminAppInsights.tsx b/src/admin/insights/AdminAppInsights.tsx
index b88b4fb9..18ae9653 100644
--- a/src/admin/insights/AdminAppInsights.tsx
+++ b/src/admin/insights/AdminAppInsights.tsx
@@ -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(),
diff --git a/src/admin/insights/index.ts b/src/admin/insights/index.ts
index 4267d40e..d4c02485 100644
--- a/src/admin/insights/index.ts
+++ b/src/admin/insights/index.ts
@@ -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,
diff --git a/src/app/config.ts b/src/app/config.ts
index ec8e56aa..0126fd35 100644
--- a/src/app/config.ts
+++ b/src/app/config.ts
@@ -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,
diff --git a/src/app/path.ts b/src/app/path.ts
index 57c7379d..5a5b34db 100644
--- a/src/app/path.ts
+++ b/src/app/path.ts
@@ -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) ||
diff --git a/src/camera/CameraHeader.tsx b/src/camera/CameraHeader.tsx
index 7b4d6c8c..4736b62c 100644
--- a/src/camera/CameraHeader.tsx
+++ b/src/camera/CameraHeader.tsx
@@ -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
/>
);
diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx
index 7d39f86b..c6645272 100644
--- a/src/cmdk/CommandKClient.tsx
+++ b/src/cmdk/CommandKClient.tsx
@@ -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,
diff --git a/src/components/EnvVar.tsx b/src/components/EnvVar.tsx
index b4ecf96b..451bba1c 100644
--- a/src/components/EnvVar.tsx
+++ b/src/components/EnvVar.tsx
@@ -12,7 +12,7 @@ export default function EnvVar({
className,
}: {
variable: string,
- value?: string,
+ value?: string | number,
accessory?: ReactNode,
includeCopyButton?: boolean,
trailingContent?: ReactNode,
diff --git a/src/components/FieldsetWithStatus.tsx b/src/components/FieldsetWithStatus.tsx
index c9c1e811..d7fa30a2 100644
--- a/src/components/FieldsetWithStatus.tsx
+++ b/src/components/FieldsetWithStatus.tsx
@@ -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})
}
+ {noteComplex}
{isModified && !error &&
);
diff --git a/src/focal/FocalLengthHeader.tsx b/src/focal/FocalLengthHeader.tsx
index 83736b33..1bdbcb6f 100644
--- a/src/focal/FocalLengthHeader.tsx
+++ b/src/focal/FocalLengthHeader.tsx
@@ -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
/>
);
diff --git a/src/i18n/locales/bd-bn.ts b/src/i18n/locales/bd-bn.ts
index fc0c5917..d1ac2b59 100644
--- a/src/i18n/locales/bd-bn.ts
+++ b/src/i18n/locales/bd-bn.ts
@@ -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: {
diff --git a/src/i18n/locales/en-us.ts b/src/i18n/locales/en-us.ts
index 406a7519..c92ec7be 100644
--- a/src/i18n/locales/en-us.ts
+++ b/src/i18n/locales/en-us.ts
@@ -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: {
diff --git a/src/i18n/locales/id-id.ts b/src/i18n/locales/id-id.ts
index 59cc150c..7fadeacb 100644
--- a/src/i18n/locales/id-id.ts
+++ b/src/i18n/locales/id-id.ts
@@ -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: {
diff --git a/src/i18n/locales/pt-br.ts b/src/i18n/locales/pt-br.ts
index 9b376984..0e77f221 100644
--- a/src/i18n/locales/pt-br.ts
+++ b/src/i18n/locales/pt-br.ts
@@ -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: {
diff --git a/src/i18n/locales/pt-pt.ts b/src/i18n/locales/pt-pt.ts
index 11f41421..60f31dfc 100644
--- a/src/i18n/locales/pt-pt.ts
+++ b/src/i18n/locales/pt-pt.ts
@@ -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: {
diff --git a/src/i18n/locales/tr-tr.ts b/src/i18n/locales/tr-tr.ts
index ba13dca0..9546cfce 100644
--- a/src/i18n/locales/tr-tr.ts
+++ b/src/i18n/locales/tr-tr.ts
@@ -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: {
diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts
index ba988f9d..c6be195b 100644
--- a/src/i18n/locales/zh-cn.ts
+++ b/src/i18n/locales/zh-cn.ts
@@ -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: {
diff --git a/src/lens/LensHeader.tsx b/src/lens/LensHeader.tsx
index 9f41cb75..b8b505f0 100644
--- a/src/lens/LensHeader.tsx
+++ b/src/lens/LensHeader.tsx
@@ -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
/>
);
diff --git a/src/lens/index.ts b/src/lens/index.ts
index bc6398d8..83f43ed7 100644
--- a/src/lens/index.ts
+++ b/src/lens/index.ts
@@ -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;
diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx
index 6919cbdf..90ac54e3 100644
--- a/src/photo/PhotoDetailPage.tsx
+++ b/src/photo/PhotoDetailPage.tsx
@@ -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}
/>}
/>
void
+ debugColor?: boolean
} & PhotoSetCategory) {
const ref = useRef(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({
)}>
}
+ {debugColor && photo.colorData &&
+
}
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();
});
diff --git a/src/photo/color/ColorDot.tsx b/src/photo/color/ColorDot.tsx
new file mode 100644
index 00000000..466b3e18
--- /dev/null
+++ b/src/photo/color/ColorDot.tsx
@@ -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) => (
+
+ {letter}
+ {shouldRound ? Math.round(value) : value.toFixed(2)}
+
+);
+
+export default function ColorDot({
+ color,
+ title,
+ className,
+ includeTooltip = true,
+}: {
+ color: Oklch | string
+ title?: string
+ className?: string
+ includeTooltip?: boolean
+}) {
+ const isColorHex = typeof color === 'string';
+ return (
+
+ {title &&
+
+ {title}
+
}
+ {isColorHex
+ ? {color}
+ : <>
+ {renderColor('L', color.l)}
+ {renderColor('C', color.c)}
+ {renderColor('H', color.h, true)}
+ >}
+ >}>
+
+
+ );
+}
diff --git a/src/photo/color/PhotoColors.tsx b/src/photo/color/PhotoColors.tsx
new file mode 100644
index 00000000..b70919ec
--- /dev/null
+++ b/src/photo/color/PhotoColors.tsx
@@ -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
+ ?
+ {colorData.ai &&
+ }
+
+ {colorData.colors.map((color, index) =>
+ ,
+ )}
+
+ : null;
+}
diff --git a/src/photo/color/SyncColorButton.tsx b/src/photo/color/SyncColorButton.tsx
new file mode 100644
index 00000000..27bd2a02
--- /dev/null
+++ b/src/photo/color/SyncColorButton.tsx
@@ -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 (
+ }
+ 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}
+ />
+ );
+}
diff --git a/src/photo/color/client.ts b/src/photo/color/client.ts
new file mode 100644
index 00000000..a55d9559
--- /dev/null
+++ b/src/photo/color/client.ts
@@ -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);
+ }
+ }
+};
diff --git a/src/photo/color/server.ts b/src/photo/color/server.ts
new file mode 100644
index 00000000..c35e2c15
--- /dev/null
+++ b/src/photo/color/server.ts
@@ -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 => {
+ 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
+) => {
+ 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
+) => {
+ 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}`);
+ }
+};
diff --git a/src/photo/color/sort.ts b/src/photo/color/sort.ts
new file mode 100644
index 00000000..02a6dca4
--- /dev/null
+++ b/src/photo/color/sort.ts
@@ -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);
+};
diff --git a/src/photo/db/index.ts b/src/photo/db/index.ts
index e3149217..d33ac80c 100644
--- a/src/photo/db/index.ts
+++ b/src/photo/db/index.ts
@@ -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';
}
};
diff --git a/src/photo/db/migration.ts b/src/photo/db/migration.ts
index d44b2956..7a2eeb18 100644
--- a/src/photo/db/migration.ts
+++ b/src/photo/db/migration.ts
@@ -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)
)),
- );
\ No newline at end of file
+ );
diff --git a/src/photo/db/query.ts b/src/photo/db/query.ts
index ec8beffa..a519eb54 100644
--- a/src/photo/db/query.ts
+++ b/src/photo/db/query.ts
@@ -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',
+ );
diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx
index f7e1879e..49e8ac33 100644
--- a/src/photo/form/PhotoForm.tsx
+++ b/src/photo/form/PhotoForm.tsx
@@ -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 }
+ />;
case 'visibility':
return !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,
diff --git a/src/photo/index.ts b/src/photo/index.ts
index 3d439019..80d8479d 100644
--- a/src/photo/index.ts
+++ b/src/photo/index.ts
@@ -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 {
+export interface Photo extends Omit {
focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string
@@ -110,7 +113,8 @@ export interface Photo extends Omit {
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,
diff --git a/src/photo/server.ts b/src/photo/server.ts
index 086fa867..eaac7425 100644
--- a/src/photo/server.ts
+++ b/src/photo/server.ts
@@ -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
@@ -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;
}
diff --git a/src/photo/sort/SortMenu.tsx b/src/photo/sort/SortMenu.tsx
index a9706b95..a96ee385 100644
--- a/src/photo/sort/SortMenu.tsx
+++ b/src/photo/sort/SortMenu.tsx
@@ -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({
,
});
+ 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 (
}
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"
diff --git a/src/photo/sort/index.ts b/src/photo/sort/index.ts
index 4babbf9a..c37b2ed4 100644
--- a/src/photo/sort/index.ts
+++ b/src/photo/sort/index.ts
@@ -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';
diff --git a/src/photo/sort/path.ts b/src/photo/sort/path.ts
index e286ffd4..36f269a9 100644
--- a/src/photo/sort/path.ts
+++ b/src/photo/sort/path.ts
@@ -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,
};
diff --git a/src/photo/sync.ts b/src/photo/sync.ts
deleted file mode 100644
index b5d6af04..00000000
--- a/src/photo/sync.ts
+++ /dev/null
@@ -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(', ');
-};
diff --git a/src/photo/update/UpdateTooltip.tsx b/src/photo/update/UpdateTooltip.tsx
new file mode 100644
index 00000000..4a09a4ee
--- /dev/null
+++ b/src/photo/update/UpdateTooltip.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/photo/update/index.ts b/src/photo/update/index.ts
new file mode 100644
index 00000000..a123875b
--- /dev/null
+++ b/src/photo/update/index.ts
@@ -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(', ');
+};
diff --git a/src/platforms/openai.ts b/src/platforms/openai.ts
index 7e20caad..866b2973 100644
--- a/src/platforms/openai.ts
+++ b/src/platforms/openai.ts
@@ -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 },
diff --git a/src/recents/RecentsHeader.tsx b/src/recents/RecentsHeader.tsx
index dcaf78de..ef51151f 100644
--- a/src/recents/RecentsHeader.tsx
+++ b/src/recents/RecentsHeader.tsx
@@ -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
/>
);
diff --git a/src/recipe/RecipeHeader.tsx b/src/recipe/RecipeHeader.tsx
index 438c0c8d..9af1e3d2 100644
--- a/src/recipe/RecipeHeader.tsx
+++ b/src/recipe/RecipeHeader.tsx
@@ -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
/>
);
diff --git a/src/tag/PrivateHeader.tsx b/src/tag/PrivateHeader.tsx
index cae38f69..4dc30e0d 100644
--- a/src/tag/PrivateHeader.tsx
+++ b/src/tag/PrivateHeader.tsx
@@ -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}
/>
);
}
diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx
index c75808d0..9382f252 100644
--- a/src/tag/TagHeader.tsx
+++ b/src/tag/TagHeader.tsx
@@ -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
/>
);
diff --git a/src/years/YearHeader.tsx b/src/years/YearHeader.tsx
index e2b8d52c..a6f28b82 100644
--- a/src/years/YearHeader.tsx
+++ b/src/years/YearHeader.tsx
@@ -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
/>
);