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:
parent
8ba32c5549
commit
59f5c74269
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -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",
|
||||
|
||||
12
README.md
12
README.md
@ -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.
|
||||
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
46
package.json
46
package.json
@ -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
3840
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
@ -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 =>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />,
|
||||
|
||||
@ -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(' ');
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -12,7 +12,7 @@ export default function EnvVar({
|
||||
className,
|
||||
}: {
|
||||
variable: string,
|
||||
value?: string,
|
||||
value?: string | number,
|
||||
accessory?: ReactNode,
|
||||
includeCopyButton?: boolean,
|
||||
trailingContent?: ReactNode,
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
49
src/photo/color/ColorDot.tsx
Normal file
49
src/photo/color/ColorDot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/photo/color/PhotoColors.tsx
Normal file
40
src/photo/color/PhotoColors.tsx
Normal 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;
|
||||
}
|
||||
37
src/photo/color/SyncColorButton.tsx
Normal file
37
src/photo/color/SyncColorButton.tsx
Normal 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
43
src/photo/color/client.ts
Normal 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
134
src/photo/color/server.ts
Normal 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
51
src/photo/color/sort.ts
Normal 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);
|
||||
};
|
||||
@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(', ');
|
||||
};
|
||||
21
src/photo/update/UpdateTooltip.tsx
Normal file
21
src/photo/update/UpdateTooltip.tsx
Normal 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
142
src/photo/update/index.ts
Normal 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(', ');
|
||||
};
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user