From a00e38b395f0c330412a26ef9986cf3c050b694b Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Wed, 15 Oct 2025 09:37:16 -0500 Subject: [PATCH] Add Locations to Albums (#334) --- README.md | 106 +++++++++--------- package.json | 2 +- src/admin/AdminAlbumForm.tsx | 66 +++++++++-- src/admin/actions.ts | 5 + .../config/AdminAppConfigurationClient.tsx | 75 ++++++++----- src/admin/config/index.tsx | 8 +- src/admin/insights/AdminAppInsightsClient.tsx | 8 +- src/admin/insights/index.ts | 12 +- src/album/AlbumHeader.tsx | 34 ++++-- src/album/form.ts | 12 +- src/album/index.ts | 9 +- src/album/query.ts | 20 ++-- src/app/config.ts | 43 ++++--- src/components/FieldsetWithStatus.tsx | 17 ++- src/components/QuotedContent.tsx | 20 ++++ src/components/TagInput.tsx | 56 ++++++--- src/components/entity/EntityLink.tsx | 3 + src/components/icons/IconPlace.tsx | 6 + src/db/migration.ts | 23 +++- src/lens/index.ts | 2 +- src/photo/PhotoLightbox.tsx | 2 + src/photo/actions.ts | 5 +- src/place/PlaceEntity.tsx | 25 +++++ src/place/PlaceInput.tsx | 90 +++++++++++++++ src/place/actions.ts | 15 +++ src/place/index.ts | 25 +++++ src/platforms/{google.ts => google-pixel.ts} | 0 src/platforms/google-places.ts | 68 +++++++++++ src/platforms/openai.ts | 48 ++------ src/platforms/rate-limit.ts | 33 ++++++ 30 files changed, 620 insertions(+), 218 deletions(-) create mode 100644 src/components/QuotedContent.tsx create mode 100644 src/components/icons/IconPlace.tsx create mode 100644 src/place/PlaceEntity.tsx create mode 100644 src/place/PlaceInput.tsx create mode 100644 src/place/actions.ts create mode 100644 src/place/index.ts rename src/platforms/{google.ts => google-pixel.ts} (100%) create mode 100644 src/platforms/google-places.ts create mode 100644 src/platforms/rate-limit.ts diff --git a/README.md b/README.md index 4efaa010..0b673c79 100644 --- a/README.md +++ b/README.md @@ -63,51 +63,10 @@ If you don't plan to change the code, or don't mind making your updates public, See FAQ for [limitations of local development](#can-i-work-locally-without-access-to-an-image-storage-provider) -🎨  Further customization +🎨  Customization - -### AI text generation -_⚠️ READ BEFORE PROCEEDING_ - -> _Usage of this feature will result in fees from OpenAI. When enabling AI text generation, follow all recommended mitigations in order to avoid unexpected charges and attacks. Make sure your OpenAI secret key environment variable is not prefixed with NEXT_PUBLIC._ - -1. Setup OpenAI - - If you don't already have one, create an [OpenAI](https://openai.com) account and fund it (see [this thread](https://github.com/sambecker/exif-photo-blog/issues/110) if you're having issues) - - Generate an API key and store in environment variable `OPENAI_SECRET_KEY` (make sure to enable Responses API write access if customizing permissions) - - Setup usage limits to avoid unexpected charges (_recommended_) -2. Add rate limiting (_recommended_) - - As an additional precaution, create an Upstash Redis store from the storage tab of the Vercel dashboard and link it to your project (if you are required to add an environment variable prefix, use `EXIF`) in order to enable rate limiting—no further configuration necessary -3. Configure auto-generated fields (optional) - - Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title,semantic` - - Accepted values: - - `all` - - `title` (default) - - `caption` - - `tags` (default) - - `semantic` (default) - - `none` - -#### Alternate AI providers (experimental) - -Set `OPENAI_BASE_URL` in order to use an alternate OpenAI-compatible provider - -### Web Analytics - -1. Open project on Vercel -2. Click "Analytics" tab -3. Follow "Enable Web Analytics" instructions (`@vercel/analytics` already included) - -### Speed Insights - -1. Open project on Vercel -2. Click "Speed Insights" tab -3. Follow "Enable Speed Insights" instructions (`@vercel/speed-insights` already included) - -### Optional configuration - -Application behavior can be changed by configuring the following environment variables: - -#### Content +### Content - `NEXT_PUBLIC_META_TITLE` (seen in search results and browser tab) - `NEXT_PUBLIC_META_DESCRIPTION` (seen in search results) - `NEXT_PUBLIC_NAV_TITLE` (seen in top-right navigation, defaults to domain when not configured) @@ -115,8 +74,8 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_PAGE_ABOUT` (seen in grid sidebar—accepts rich formatting tags: ``, ``, ``, ``, ``, `
`) - `NEXT_PUBLIC_DOMAIN_SHARE` (seen in share modals where a shorter url may be desirable) -#### Performance -> ⚠️ Enabling may result in increased project usage. Static optimization [troubleshooting hints](#why-do-production-deployments-fail-when-static-optimization-is-enabled) in FAQ. +### Performance +> ⚠️ Enabling may result in increased project usage. See FAQ for static optimization [troubleshooting hints](#why-do-production-deployments-fail-when-static-optimization-is-enabled). - `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1` enables static optimization for photo pages (`p/[photoId]`), i.e., renders pages at build time - `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1` enables static optimization for OG images, i.e., renders images at build time @@ -126,7 +85,42 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_IMAGE_QUALITY = 1-100` controls the quality of large photos - `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage) -#### Categories +### AI text generation + +To auto-generate text descriptions of photo: + +1. Setup OpenAI + - Create [OpenAI](https://openai.com) account and fund it ([see thread](https://github.com/sambecker/exif-photo-blog/issues/110) if you're having issues) + - Setup usage limits to avoid unexpected charges (_recommended_) + - Set `OPENAI_BASE_URL` in order to use alternate OpenAI-compatible providers (experimental) +2. Generate API key and store in environment variable `OPENAI_SECRET_KEY` (enable Responses API write access if customizing permissions) +3. Add [rate limiting](#rate-limiting) (_recommended_) +4. Configure auto-generated fields (optional) + - Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g., `AI_TEXT_AUTO_GENERATED_FIELDS = title,semantic` + - Accepted values: + - `all` + - `title` (default) + - `caption` + - `tags` (default) + - `semantic` (default) + - `none` + +### Location services + +To add location meta to entities like albums: + +1. Setup Google Places API + - [Create Google Cloud project](https://console.cloud.google.com/projectcreate) if necessary + - Select [Create credentials](https://console.cloud.google.com/apis/credentials) and choose "API key" + - Choose "Restrict key" and select "Places API (new)" +2. Store API key in `GOOGLE_PLACES_API_KEY` +3. Add [rate limiting](#rate-limiting) (_recommended_) + +### Rate limiting + +Create Upstash Redis store from storage tab of Vercel dashboard and link to your project (if required, add environment variable prefix `EXIF`) in order to enable rate limiting—no further configuration necessary. + +### Categories - `NEXT_PUBLIC_CATEGORY_VISIBILITY` - Comma-separated value controlling which photo sets appear in grid sidebar and CMD-K menu, and in what order. For example, you could move cameras above tags, and hide film simulations, by updating to `cameras,tags,lenses,recipes`. - Accepted values: @@ -143,7 +137,7 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1` always shows expanded sidebar content - `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos -#### Sorting +### Sorting - `NEXT_PUBLIC_DEFAULT_SORT` - Sets default sort on grid/full homepages - Accepted values: @@ -164,23 +158,23 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1` takes priority field into account when sorting photos (⚠️ enabling may have performance consequences) -#### Display +### Display - `NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1` hides keyboard shortcut hints in areas like the main nav, and previous/next photo links - `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) - `NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1` hides fullscreen photo zoom controls - `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta - `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo -#### Grid +### Grid - `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage - `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint) - `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio) -#### Design +### Design - `NEXT_PUBLIC_DEFAULT_THEME = light | dark` sets preferred initial theme (defaults to `system` when not configured) - `NEXT_PUBLIC_MATTE_PHOTOS = 1` constrains the size of each photo, and displays a surrounding border, potentially useful for photos with tall aspect ratios (colors can be customized via `NEXT_PUBLIC_MATTE_COLOR` + `NEXT_PUBLIC_MATTE_COLOR_DARK`) -#### Settings +### Settings - `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information) - `NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage) - `NEXT_PUBLIC_SOCIAL_NETWORKS` @@ -195,7 +189,15 @@ Application behavior can be changed by configuring the following environment var - `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml` - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) -#### Scripts & Analytics +### Scripts & Analytics +- Web Analytics + 1. Open project on Vercel + 2. Click "Analytics" tab + 3. Follow "Enable Web Analytics" instructions (`@vercel/analytics` already included) +- Speed Insights + 1. Open project on Vercel + 2. Click "Speed Insights" tab + 3. Follow "Enable Speed Insights" instructions (`@vercel/speed-insights` already included) - `PAGE_SCRIPT_URLS` - comma-separated list of URLs to be added to the bottom of the body tag via "next/script" - urls must begin with 'https' diff --git a/package.json b/package.json index 6718978b..348e52ea 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "analyze": "ANALYZE=true next build" }, - "packageManager": "pnpm@10.17.1", + "packageManager": "pnpm@10.18.2", "dependencies": { "@ai-sdk/openai": "^2.0.40", "@ai-sdk/rsc": "^1.0.59", diff --git a/src/admin/AdminAlbumForm.tsx b/src/admin/AdminAlbumForm.tsx index 0722b161..b14732a6 100644 --- a/src/admin/AdminAlbumForm.tsx +++ b/src/admin/AdminAlbumForm.tsx @@ -4,13 +4,16 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { PATH_ADMIN_ALBUMS } from '@/app/path'; import FieldsetWithStatus from '@/components/FieldsetWithStatus'; -import { ReactNode, useCallback, useMemo, useState} from 'react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; import { useAppState } from '@/app/AppState'; import { Album } from '@/album'; import { ALBUM_FORM_META } from '@/album/form'; import { parameterize } from '@/utility/string'; import { updateAlbumAction } from '@/album/actions'; import clsx from 'clsx/lite'; +import PlaceInput from '@/place/PlaceInput'; +import { convertPlaceToAutocomplete, Place } from '@/place'; +import deepEqual from 'fast-deep-equal/es6/react'; export default function AdminAlbumForm({ album, @@ -23,25 +26,27 @@ export default function AdminAlbumForm({ const [albumForm, setAlbumForm] = useState(album); + const initialPlace = useMemo(() => + convertPlaceToAutocomplete(album.location), + [album.location]); + const [isLoadingPlace, setIsLoadingPlace] = useState(false); + const setPlace = useCallback((place?: Place) => + setAlbumForm(form => ({ + ...form, + location: place, + })), []); + const isFormValid = useMemo(() => { return ALBUM_FORM_META.every(({ key, required }) => { return !required || Boolean(albumForm[key]); }); }, [albumForm]); - const updateAlbum = useCallback((key: keyof Album, value: string) => { - setAlbumForm(form => ({ - ...form, - [key]: value, - ...key === 'title' && { slug: parameterize(value) }, - })); - }, []); - return (
+ > {ALBUM_FORM_META .map(({ key, label, type, readOnly }) => ( updateAlbum(key, value)} + onChange={value => setAlbumForm(form => ({ + ...form, + [key]: value, + ...key === 'title' && { slug: parameterize(value) }, + })) + } isModified={albumForm[key] !== album[key]} readOnly={readOnly} className={clsx(key === 'description' && '[&_textarea]:h-36')} />))} + + {(albumForm.location || isLoadingPlace) && +
+ setAlbumForm(form => ({ + ...form, + ...form.location && { + location: { ...form.location, nameFormatted: value }, + }, + }))} + isModified={ + // eslint-disable-next-line max-len + (albumForm.location?.nameFormatted ?? albumForm.location?.name) !== + (album.location?.nameFormatted ?? album.location?.name) + } + readOnly={isLoadingPlace} + /> + +
} {children}
hasDatabase, hasStorageProvider, hasRedisStorage, + hasLocationServices, isAiTextGenerationEnabled, } = APP_CONFIGURATION; @@ -107,11 +109,13 @@ export const testConnectionsAction = async () => storageError, redisError, aiError, + locationError, ] = await Promise.all([ scanForError(hasDatabase, testDatabaseConnection), scanForError(hasStorageProvider, testStorageConnection), scanForError(hasRedisStorage, testRedisConnection), scanForError(isAiTextGenerationEnabled, testOpenAiConnection), + scanForError(hasLocationServices, testGooglePlacesConnection), ]); return { @@ -119,5 +123,6 @@ export const testConnectionsAction = async () => storageError, redisError, aiError, + locationError, }; }); diff --git a/src/admin/config/AdminAppConfigurationClient.tsx b/src/admin/config/AdminAppConfigurationClient.tsx index a5230a28..b9992089 100644 --- a/src/admin/config/AdminAppConfigurationClient.tsx +++ b/src/admin/config/AdminAppConfigurationClient.tsx @@ -70,11 +70,6 @@ export default function AdminAppConfigurationClient({ hasNavCaption, pageAbout, hasPageAbout, - // AI - hasOpenaiBaseUrl, - isAiTextGenerationEnabled, - aiTextAutoGeneratedFields, - hasAiTextAutoGeneratedFields, // Performance isStaticallyOptimized, arePhotosStaticallyOptimized, @@ -85,6 +80,13 @@ export default function AdminAppConfigurationClient({ hasImageQuality, imageQuality, isBlurEnabled, + // AI + hasOpenaiBaseUrl, + isAiTextGenerationEnabled, + aiTextAutoGeneratedFields, + hasAiTextAutoGeneratedFields, + // Location services + hasLocationServices, // Categories hasCategoryVisibility, categoryVisibility, @@ -141,6 +143,7 @@ export default function AdminAppConfigurationClient({ storageError, redisError, aiError, + locationError, // Component props simplifiedView, isAnalyzingConfiguration, @@ -468,12 +471,28 @@ export default function AdminAppConfigurationClient({ } ; - case 'AI Content Generation': + case 'External Services': return <> + + {redisError && renderError({ + connection: { provider: 'Redis', error: redisError}, + })} + Create Upstash Redis store from storage tab + on Vercel dashboard and connect to this project + to enable rate limiting on external services + + + {locationError && renderError({ + connection: { provider: 'Google Places', error: locationError}, + })} + Store Google Places API key in order to add location meta + to entities like albums: + {renderEnvVars(['GOOGLE_PLACES_API_KEY'])} + + ; + case 'AI Text': + return <> - - {redisError && renderError({ - connection: { provider: 'Redis', error: redisError}, - })} - Create Upstash Redis store from storage tab - on Vercel dashboard and connect to this project - to enable rate limiting - , }, { - title: 'AI Content Generation', + title: 'External Services', + required: false, + icon: , +}, { + title: 'AI Text', titleShort: 'AI', required: false, icon: , diff --git a/src/admin/insights/AdminAppInsightsClient.tsx b/src/admin/insights/AdminAppInsightsClient.tsx index cc0066df..bb23db73 100644 --- a/src/admin/insights/AdminAppInsightsClient.tsx +++ b/src/admin/insights/AdminAppInsightsClient.tsx @@ -140,7 +140,7 @@ export default function AdminAppInsightsClient({ noFork, forkBehind, noAi, - noAiRateLimiting, + noRateLimiting, noConfiguredDomain, noConfiguredMetaTitle, noConfiguredMetaDescription, @@ -318,17 +318,17 @@ export default function AdminAppInsightsClient({
} />} - {(noAiRateLimiting || debug) && renderHighlightText( - 'Enable AI rate limiting', + 'Enable rate limiting', 'yellow', !isExpanded, )} expandContent={<> Create Upstash Redis store from storage tab on Vercel dashboard and link to this project to - prevent abuse by enabling rate limiting. + prevent unexpected usage by enabling rate limiting. } />} {(noConfiguredDomain || debug) && { const { isAiTextGenerationEnabled, + hasLocationServices, hasRedisStorage, hasDomain, } = APP_CONFIGURATION; @@ -94,7 +95,10 @@ export const getSignificantInsights = ({ return { deprecatedEnvVars: HAS_DEPRECATED_ENV_VARS, forkBehind: Boolean(codeMeta?.isBehind), - noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage, + noRateLimiting: ( + isAiTextGenerationEnabled || + hasLocationServices + ) && !hasRedisStorage, noConfiguredDomain: !hasDomain, photosNeedSync: Boolean(photosCountNeedSync), }; @@ -114,12 +118,12 @@ export const indicatorStatusForSignificantInsights = ({ const { deprecatedEnvVars, forkBehind, - noAiRateLimiting, + noRateLimiting, noConfiguredDomain, photosNeedSync, } = insights; - if (deprecatedEnvVars || noAiRateLimiting || noConfiguredDomain) { + if (deprecatedEnvVars || noRateLimiting || noConfiguredDomain) { return 'yellow'; } else if (forkBehind || photosNeedSync) { return 'blue'; diff --git a/src/album/AlbumHeader.tsx b/src/album/AlbumHeader.tsx index 64acefc2..9f9e968e 100644 --- a/src/album/AlbumHeader.tsx +++ b/src/album/AlbumHeader.tsx @@ -11,6 +11,7 @@ import PhotoAlbum from './PhotoAlbum'; import PhotoTag from '@/tag/PhotoTag'; import IconTag from '@/components/icons/IconTag'; import MaskedScroll from '@/components/MaskedScroll'; +import PlaceEntity from '@/place/PlaceEntity'; export default async function AlbumHeader({ album, @@ -58,23 +59,32 @@ export default async function AlbumHeader({
{album.subhead}
} - {tags.length > 0 && + {(album.location || tags.length > 0) && - - {tags.map(tag => ( - } + {tags.length > 0 && <> + - ))} + {tags.map(tag => ( + + ))} + } } {album.description &&
{ + const locationString = formData.get('location') as string | undefined; return { id: formData.get('id') as string, title: formData.get('title') as string, slug: formData.get('slug') as string, subhead: formData.get('subhead') as string, description: formData.get('description') as string, - locationName: formData.get('locationName') as string, - latitude: formData.get('latitude') - ? parseFloat(formData.get('latitude') as string) - : undefined, - longitude: formData.get('longitude') - ? parseFloat(formData.get('longitude') as string) - : undefined, + ...locationString && { location: JSON.parse(locationString) }, }; }; diff --git a/src/album/index.ts b/src/album/index.ts index 2c1c9917..72dc6805 100644 --- a/src/album/index.ts +++ b/src/album/index.ts @@ -7,6 +7,7 @@ import { PhotoDateRangePostgres, photoQuantityText, } from '@/photo'; +import { Place } from '@/place'; import camelcaseKeys from 'camelcase-keys'; export interface Album { @@ -15,9 +16,7 @@ export interface Album { slug: string subhead?: string description?: string - locationName?: string - latitude?: number - longitude?: number + location?: Place } type AlbumWithMeta = { @@ -34,9 +33,7 @@ export const parseAlbumFromDb = (album: any): Album => export const albumHasMeta = (album: Album) => album.subhead || album.description || - album.locationName || - album.latitude || - album.longitude; + album.location; export const titleForAlbum = ( album: Album, diff --git a/src/album/query.ts b/src/album/query.ts index 3252303a..ad8fbf52 100644 --- a/src/album/query.ts +++ b/src/album/query.ts @@ -11,9 +11,7 @@ export const createAlbumsTable = () => slug VARCHAR(255) UNIQUE NOT NULL, subhead TEXT, description TEXT, - location_name VARCHAR(255), - latitude DOUBLE PRECISION, - longitude DOUBLE PRECISION, + location JSONB, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ) @@ -36,17 +34,15 @@ export const insertAlbum = (album: Omit) => slug, subhead, description, - location_name, - latitude, - longitude + location ) VALUES ( ${album.title}, ${album.slug}, ${album.subhead}, ${album.description}, - ${album.locationName}, - ${album.latitude}, - ${album.longitude} + ${album.location + ? JSON.stringify(album.location) + : null} ) RETURNING id `.then(({ rows }) => rows[0]?.id as string) @@ -59,9 +55,9 @@ export const updateAlbum = (album: Album) => slug=${album.slug}, subhead=${album.subhead}, description=${album.description}, - location_name=${album.locationName}, - latitude=${album.latitude}, - longitude=${album.longitude}, + location=${album.location + ? JSON.stringify(album.location) + : null}, updated_at=${(new Date()).toISOString()} WHERE id=${album.id} `, 'updateAlbum'); diff --git a/src/app/config.ts b/src/app/config.ts index 1a495f91..6ffc3f08 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -221,14 +221,6 @@ export const CURRENT_STORAGE: StorageType = : 'vercel-blob' ); -// AI - -export const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL; -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); - // PERFORMANCE export const STATICALLY_OPTIMIZED_PHOTOS = @@ -260,6 +252,19 @@ export const IMAGE_QUALITY = export const BLUR_ENABLED = process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1'; +// AI + +export const OPENAI_SECRET_KEY = process.env.OPENAI_SECRET_KEY; +export const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL; +export const AI_CONTENT_GENERATION_ENABLED = Boolean(OPENAI_SECRET_KEY); +export const AI_TEXT_AUTO_GENERATED_FIELDS = parseAiAutoGeneratedFieldsString( + process.env.AI_TEXT_AUTO_GENERATED_FIELDS); + +// LOCATION SERVICES + +export const GOOGLE_PLACES_API_KEY = process.env.GOOGLE_PLACES_API_KEY; +export const HAS_LOCATION_SERVICES = Boolean(GOOGLE_PLACES_API_KEY); + // CATEGORIES export const CATEGORY_VISIBILITY = parseOrderedCategoriesFromString( @@ -431,16 +436,6 @@ export const APP_CONFIGURATION = { hasNavCaption: Boolean(NAV_CAPTION), pageAbout: PAGE_ABOUT, hasPageAbout: Boolean(process.env.NEXT_PUBLIC_SITE_ABOUT), - // AI - hasOpenaiBaseUrl: Boolean(OPENAI_BASE_URL), - isAiTextGenerationEnabled: AI_CONTENT_GENERATION_ENABLED, - aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS - ? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0 - ? ['none'] - : AI_TEXT_AUTO_GENERATED_FIELDS - : AI_AUTO_GENERATED_FIELDS_DEFAULT, - hasAiTextAutoGeneratedFields: - Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS), // Performance isStaticallyOptimized: HAS_STATIC_OPTIMIZATION, arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS, @@ -452,6 +447,18 @@ export const APP_CONFIGURATION = { hasImageQuality: Boolean(process.env.NEXT_PUBLIC_IMAGE_QUALITY), imageQuality: IMAGE_QUALITY, isBlurEnabled: BLUR_ENABLED, + // AI + hasOpenaiBaseUrl: Boolean(OPENAI_BASE_URL), + isAiTextGenerationEnabled: AI_CONTENT_GENERATION_ENABLED, + aiTextAutoGeneratedFields: process.env.AI_TEXT_AUTO_GENERATED_FIELDS + ? AI_TEXT_AUTO_GENERATED_FIELDS.length === 0 + ? ['none'] + : AI_TEXT_AUTO_GENERATED_FIELDS + : AI_AUTO_GENERATED_FIELDS_DEFAULT, + hasAiTextAutoGeneratedFields: + Boolean(process.env.AI_TEXT_AUTO_GENERATED_FIELDS), + // Location services + hasLocationServices: HAS_LOCATION_SERVICES, // Categories hasCategoryVisibility: Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY), diff --git a/src/components/FieldsetWithStatus.tsx b/src/components/FieldsetWithStatus.tsx index fb28b46a..7e0cc779 100644 --- a/src/components/FieldsetWithStatus.tsx +++ b/src/components/FieldsetWithStatus.tsx @@ -33,6 +33,11 @@ export default function FieldsetWithStatus({ tagOptionsLimitValidationMessage, tagOptionsShouldParameterize, tagOptionsDefaultIcon, + tagOptionsDefaultIconSelected, + tagOptionsLabelOverride, + tagOptionsAllowNewValues, + tagOptionsAccessory, + tagOptionsOnInputTextChange, placeholder, loading, required, @@ -65,6 +70,11 @@ export default function FieldsetWithStatus({ tagOptionsLimitValidationMessage?: string tagOptionsShouldParameterize?: boolean tagOptionsDefaultIcon?: ReactNode + tagOptionsDefaultIconSelected?: ReactNode + tagOptionsLabelOverride?: (value: string) => string + tagOptionsAllowNewValues?: boolean + tagOptionsAccessory?: ReactNode + tagOptionsOnInputTextChange?: (value: string) => void placeholder?: string loading?: boolean required?: boolean @@ -161,7 +171,7 @@ export default function FieldsetWithStatus({ {note && !error && ({note}) } @@ -206,14 +216,19 @@ export default function FieldsetWithStatus({ name={id} value={value} options={tagOptions} + labelForValueOverride={tagOptionsLabelOverride} defaultIcon={tagOptionsDefaultIcon} + defaultIconSelected={tagOptionsDefaultIconSelected} + accessory={tagOptionsAccessory} onChange={onChange} + onInputTextChange={tagOptionsOnInputTextChange} showMenuOnDelete={tagOptionsLimit === 1} className={clsx(Boolean(error) && 'error')} readOnly={readOnly} placeholder={placeholder} limit={tagOptionsLimit} limitValidationMessage={tagOptionsLimitValidationMessage} + allowNewValues={tagOptionsAllowNewValues} shouldParameterize={tagOptionsShouldParameterize} /> : type === 'textarea' diff --git a/src/components/QuotedContent.tsx b/src/components/QuotedContent.tsx new file mode 100644 index 00000000..f9c61a8a --- /dev/null +++ b/src/components/QuotedContent.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx/lite'; + +export default function QuotedContent({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { + return
+ {children} +
; +} diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 121c8cd5..4e0ecfce 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -22,28 +22,38 @@ export default function TagInput({ name, value = '', options = [], + labelForValueOverride, defaultIcon, + defaultIconSelected, + accessory, onChange, + onInputTextChange, showMenuOnDelete, className, readOnly, placeholder, limit, limitValidationMessage, + allowNewValues = true, shouldParameterize, }: { id?: string name: string value?: string options?: AnnotatedTag[] + labelForValueOverride?: (value: string) => string defaultIcon?: ReactNode + defaultIconSelected?: ReactNode + accessory?: ReactNode onChange?: (value: string) => void + onInputTextChange?: (value: string) => void showMenuOnDelete?: boolean className?: string readOnly?: boolean placeholder?: string limit?: number limitValidationMessage?: string + allowNewValues?: boolean shouldParameterize?: boolean }) { const behaveAsDropdown = limit === 1; @@ -94,22 +104,25 @@ export default function TagInput({ const optionsFiltered = useMemo(() => hasReachedLimit ? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }] - : (isInputTextUnique + : (isInputTextUnique && allowNewValues ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] : [] ).concat(options - .filter(({ value }) => - !selectedOptions.includes(value) && - ( + .filter(({ value, label }) =>{ + // Make value and key searchable + const key = `${value}-${label}`; + return !selectedOptions.includes(key) && ( !inputTextFormatted || (shouldParameterize - ? value.includes(inputTextFormatted) - : (parameterize(value)).includes(parameterize(inputTextFormatted))) - ))) + ? key.includes(inputTextFormatted) + : (parameterize(key)).includes(parameterize(inputTextFormatted))) + ); + })) , [ hasReachedLimit, inputTextFormatted, isInputTextUnique, + allowNewValues, limit, limitValidationMessage, options, @@ -211,11 +224,11 @@ export default function TagInput({ } switch (e.key) { case 'Enter': - // Only trap focus if there are options to select - // otherwise allow form to submit + // Only trap focus if there are options to select + // otherwise allow form to submit if ( shouldShowMenu && - optionsFiltered.length > 0 + optionsFiltered.length > 0 ) { e.stopImmediatePropagation(); e.preventDefault(); @@ -290,7 +303,7 @@ export default function TagInput({ {option?.label ?? value} - {icon && + {icon && {icon} } ; @@ -304,9 +317,12 @@ export default function TagInput({ onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) { // Capture text on blur if limit not yet reached - if (inputText && !hasReachedLimit) { + if (inputText && !hasReachedLimit && allowNewValues) { addOptions([inputText]); - } else { + } else if (allowNewValues) { + // Only clear text when there's the possibility of + // explicity adding arbitrary values, i.e., when it's not + // used as autocomplete setInputText(''); } hideMenu(); @@ -352,11 +368,12 @@ export default function TagInput({ 'px-1.5 py-0.5', 'bg-gray-200/60 dark:bg-gray-800', 'active:bg-gray-200 dark:active:bg-gray-900', - 'rounded-xs', + 'rounded-sm', )} onClick={() => removeOption(option)} > - {renderTag(option)} + {defaultIconSelected} + {renderTag(labelForValueOverride?.(option) || option)} )} setInputText(e.target.value)} + onChange={e => { + setInputText(e.target.value); + onInputTextChange?.(e.target.value); + }} autoComplete="off" autoCapitalize="off" + autoCorrect="off" readOnly={readOnly} placeholder={selectedOptions.length === 0 ? placeholder : undefined} onFocus={() => setSelectedOptionIndex(undefined)} @@ -387,6 +408,7 @@ export default function TagInput({ role="combobox" /> + {accessory}
{shouldShowMenu && optionsFiltered.length > 0 && @@ -444,7 +466,7 @@ export default function TagInput({ {annotation && diff --git a/src/components/entity/EntityLink.tsx b/src/components/entity/EntityLink.tsx index 3ff19578..fe91b2c3 100644 --- a/src/components/entity/EntityLink.tsx +++ b/src/components/entity/EntityLink.tsx @@ -42,6 +42,7 @@ export default function EntityLink({ badgeType = 'small', contrast = 'medium', path = '', // Make link optional for debugging purposes + pathTarget, hoverCount = 0, hoverType = 'auto', hoverQueryOptions, @@ -62,6 +63,7 @@ export default function EntityLink({ labelSmall?: ReactNode iconWide?: boolean path?: string + pathTarget?: ComponentProps['target'] prefetch?: boolean title?: string action?: ReactNode @@ -125,6 +127,7 @@ export default function EntityLink({ )} isLoading={isLoading} setIsLoading={setIsLoading} + target={pathTarget} > ; +} diff --git a/src/db/migration.ts b/src/db/migration.ts index 7a2eeb18..2ee044a2 100644 --- a/src/db/migration.ts +++ b/src/db/migration.ts @@ -1,7 +1,8 @@ -import { sql } from '@/platforms/postgres'; +import { query, sql } from '@/platforms/postgres'; interface Migration { label: string + table?: 'photos' | 'albums' fields: string[] run: () => ReturnType } @@ -86,13 +87,27 @@ export const MIGRATIONS: Migration[] = [{ ADD COLUMN IF NOT EXISTS color_data JSONB, ADD COLUMN IF NOT EXISTS color_sort SMALLINT `, +}, { + label: '08: Location', + table: 'albums', + fields: ['location'], + // `query()` seemingly required to execute + // ADD and DROP column alteration in same migration + run: () => query(` + ALTER TABLE albums + ADD COLUMN IF NOT EXISTS location JSONB; + ALTER TABLE albums + DROP COLUMN IF EXISTS location_name, + DROP COLUMN IF EXISTS latitude, + DROP COLUMN IF EXISTS longitude; + `), }]; export const migrationForError = (e: any) => - MIGRATIONS.find(migration => - migration.fields.some(field =>( + MIGRATIONS.find(({ fields, table = 'photos' }) => + fields.some(field =>( // eslint-disable-next-line max-len - new RegExp(`column "${field}" of relation "photos" does not exist`, 'i').test(e.message) || + new RegExp(`column "${field}" of relation "${table}" does not exist`, 'i').test(e.message) || new RegExp(`column "${field}" does not exist`, 'i').test(e.message) )), ); diff --git a/src/lens/index.ts b/src/lens/index.ts index 470a9d72..c8808f3a 100644 --- a/src/lens/index.ts +++ b/src/lens/index.ts @@ -2,7 +2,7 @@ import { Photo } from '@/photo'; import { MakeModelTextLength, parameterize } from '@/utility/string'; import { formatAppleLensText, isLensApple } from '../platforms/apple'; import { MISSING_FIELD } from '@/app/path'; -import { formatGoogleLensText, isLensGoogle } from '../platforms/google'; +import { formatGoogleLensText, isLensGoogle } from '../platforms/google-pixel'; import { CategoryQueryMeta } from '@/category'; const LENS_PLACEHOLDER: Lens = { make: 'Lens', model: 'Model' }; diff --git a/src/photo/PhotoLightbox.tsx b/src/photo/PhotoLightbox.tsx index 9ef5f7d7..92566c01 100644 --- a/src/photo/PhotoLightbox.tsx +++ b/src/photo/PhotoLightbox.tsx @@ -40,6 +40,8 @@ export default function PhotoLightbox({ 'flex flex-col items-center justify-center', 'gap-0.5', 'text-[1.1rem] lg:text-[1.25rem]', + // Optically adjust for leading '+' character + 'translate-x-[-1px]', )} > +{countNotShown} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 521a2846..7542abcd 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -516,7 +516,10 @@ export const renamePhotoRecipeGloballyAction = async (formData: FormData) => export const deleteUploadsAction = async (urls: string[]) => runAuthenticatedAdminServerAction(async () => { await Promise.all(urls.map(url => deleteFile(url))); - revalidateAdminPaths(); + if (urls.length > 1) { + // Only refresh state when deleting multiple uploads + revalidateAdminPaths(); + } }); // Accessed from admin photo edit page diff --git a/src/place/PlaceEntity.tsx b/src/place/PlaceEntity.tsx new file mode 100644 index 00000000..165b8b9f --- /dev/null +++ b/src/place/PlaceEntity.tsx @@ -0,0 +1,25 @@ +import IconPlace from '@/components/icons/IconPlace'; +import { Place } from '.'; +import EntityLink, { + EntityLinkExternalProps, +} from '@/components/entity/EntityLink'; + +export default function PlaceEntity({ + location, + ...props +}: { + location: Place +} & EntityLinkExternalProps) { + return ( + } + label={location.nameFormatted || location.name} + path={location.link} + pathTarget="_blank" + badged + /> + ); +} \ No newline at end of file diff --git a/src/place/PlaceInput.tsx b/src/place/PlaceInput.tsx new file mode 100644 index 00000000..cf451a7b --- /dev/null +++ b/src/place/PlaceInput.tsx @@ -0,0 +1,90 @@ +import FieldsetWithStatus from '@/components/FieldsetWithStatus'; +import { ComponentProps, useEffect, useRef, useState } from 'react'; +import { getPlaceAutoCompleteAction, getPlaceDetailsAction } from './actions'; +import { useDebounce } from 'use-debounce'; +import { Place, PlaceAutocomplete } from '.'; +import Spinner from '@/components/Spinner'; +import IconPlace from '@/components/icons/IconPlace'; + +export default function PlaceInput({ + initialPlace, + setPlace, + setIsLoadingPlace, + className, +}: { + initialPlace?: PlaceAutocomplete + setPlace?: (place?: Place) => void + setIsLoadingPlace?: (isLoading: boolean) => void + className?: string +}) { + const places = useRef>(initialPlace + ? { [initialPlace.id]: initialPlace } + : {}); + + const [placeId, setPlaceId] = useState(initialPlace?.id ?? ''); + const [isLoadingPlaces, setIsLoadingPlaces] = useState(false); + const [inputText, setInputText] = useState(''); + const [placeOptions, setPlaceOptions] = + useState['tagOptions']>([]); + + const [inputTextDebounced] = useDebounce(inputText, 500); + + useEffect(() => { + if (inputTextDebounced) { + setIsLoadingPlaces(true); + getPlaceAutoCompleteAction(inputTextDebounced) + .then(options => { + options.forEach(option => { + places.current[option.id] = option; + }); + setPlaceOptions(options.map(option => ({ + value: option.id, + label: option.text, + annotation: option.secondary, + }))); + }) + .finally(() => { + setIsLoadingPlaces(false); + }); + } + }, [inputTextDebounced]); + + return ( + { + setPlaceId(id); + if (id) { + setIsLoadingPlace?.(true); + getPlaceDetailsAction(id) + .then(setPlace) + .finally(() => setIsLoadingPlace?.(false)); + } else { + setPlace?.(undefined); + } + }} + tagOptionsLabelOverride={(placeId) => places.current[placeId]?.text} + tagOptionsDefaultIconSelected={} + tagOptionsOnInputTextChange={text => { + setInputText(text); + // Clear autocomplete immediately when there's no input text + if (!text) { + setPlaceOptions([]); + } + }} + tagOptionsLimit={1} + tagOptionsAllowNewValues={false} + tagOptionsAccessory={isLoadingPlaces && + } + tagOptionsShouldParameterize={false} + /> + ); +} diff --git a/src/place/actions.ts b/src/place/actions.ts new file mode 100644 index 00000000..0ff75b7b --- /dev/null +++ b/src/place/actions.ts @@ -0,0 +1,15 @@ +'use server'; + +import { runAuthenticatedAdminServerAction } from '@/auth/server'; +import { + getPlaceAutocomplete, + getPlaceDetails, +} from '@/platforms/google-places'; + +export const getPlaceAutoCompleteAction = + async (...args: Parameters) => + runAuthenticatedAdminServerAction(() => getPlaceAutocomplete(...args)); + +export const getPlaceDetailsAction = + async (...args: Parameters) => + runAuthenticatedAdminServerAction(() => getPlaceDetails(...args)); diff --git a/src/place/index.ts b/src/place/index.ts new file mode 100644 index 00000000..26379884 --- /dev/null +++ b/src/place/index.ts @@ -0,0 +1,25 @@ +export interface PlaceAutocomplete { + id: string + text: string + secondary?: string +} + +export interface Place { + id: string + name: string + nameFormatted: string + link: string + location?: Location + viewport?: { low: Location, high: Location } +} + +type Location = { + latitude: number + longitude: number +} + +export const convertPlaceToAutocomplete = ( + place?: Place, +): PlaceAutocomplete | undefined => place + ? { id: place.id, text: place.name } + : undefined; diff --git a/src/platforms/google.ts b/src/platforms/google-pixel.ts similarity index 100% rename from src/platforms/google.ts rename to src/platforms/google-pixel.ts diff --git a/src/platforms/google-places.ts b/src/platforms/google-places.ts new file mode 100644 index 00000000..7f636262 --- /dev/null +++ b/src/platforms/google-places.ts @@ -0,0 +1,68 @@ +import { GOOGLE_PLACES_API_KEY } from '@/app/config'; +import { Place, PlaceAutocomplete } from '@/place'; +import { + checkRateLimitAndThrow as _checkRateLimitAndThrow, +} from '@/platforms/rate-limit'; + +const URL_BASE = 'https://places.googleapis.com/v1/places'; + +const checkRateLimitAndThrow = () => + _checkRateLimitAndThrow({ identifier: 'google-places-query' }); + +const headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': GOOGLE_PLACES_API_KEY ?? '', +}; + +export const getPlaceAutocomplete = async ( + input: string, +): Promise => { + await checkRateLimitAndThrow(); + return fetch( + `${URL_BASE}:autocomplete`, { + method: 'POST', + body: JSON.stringify({ input }), + headers, + }, + ) + .then(response => response.json()) + .then(json => (json?.suggestions ?? []).map(({ placePrediction }: any) => ({ + id: placePrediction?.placeId, + text: placePrediction?.structuredFormat?.mainText?.text, + secondary: placePrediction?.structuredFormat?.secondaryText?.text, + }))); +}; + +const FIELDS = [ + 'id', + 'displayName', + 'location', + 'viewport', + 'googleMapsUri', +]; + +export const getPlaceDetails = async (id: string): Promise => { + await checkRateLimitAndThrow(); + return fetch( + `${URL_BASE}/${id}?fields=${FIELDS.join(',')}`, { + headers, + }, + ) + .then(response => response.json()) + .then(json => ({ + id: json?.id, + name: json?.displayName?.text, + nameFormatted: json?.displayName?.text, + link: json?.googleMapsUri, + ...json?.location && + { location: json.location as Location }, + ...json?.viewport && + { viewport: json?.viewport as { low: Location, high: Location } }, + })); +}; + +export const testGooglePlacesConnection = async () => { + await checkRateLimitAndThrow(); + + return getPlaceAutocomplete('Test'); +}; diff --git a/src/platforms/openai.ts b/src/platforms/openai.ts index 26357104..cd8865d2 100644 --- a/src/platforms/openai.ts +++ b/src/platforms/openai.ts @@ -1,53 +1,29 @@ import { generateText, streamText, generateObject } from 'ai'; import { createStreamableValue } from '@ai-sdk/rsc'; import { createOpenAI } from '@ai-sdk/openai'; -import { Ratelimit } from '@upstash/ratelimit'; -import { AI_CONTENT_GENERATION_ENABLED, OPENAI_BASE_URL } from '@/app/config'; +import { OPENAI_BASE_URL, OPENAI_SECRET_KEY } from '@/app/config'; import { removeBase64Prefix } from '@/utility/image'; import { cleanUpAiTextResponse } from '@/photo/ai'; -import { redis } from '@/platforms/redis'; +import { + checkRateLimitAndThrow as _checkRateLimitAndThrow, +} from '@/platforms/rate-limit'; import { z } from 'zod'; -const RATE_LIMIT_IDENTIFIER = 'openai-image-query'; +const checkRateLimitAndThrow = (isBatch?: boolean) => + _checkRateLimitAndThrow({ + identifier: 'openai-image-query', + ...isBatch && { tokens: 1200, duration: '1d' }, + }); + const MODEL = 'gpt-4o'; -const openai = AI_CONTENT_GENERATION_ENABLED +const openai = OPENAI_SECRET_KEY ? createOpenAI({ - apiKey: process.env.OPENAI_SECRET_KEY, + apiKey: OPENAI_SECRET_KEY, ...OPENAI_BASE_URL && { baseURL: OPENAI_BASE_URL }, }) : undefined; -const ratelimit = redis - ? { - basic: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(100, '1h'), - }), - batch: new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(1200, '1d'), - }), - } - : undefined; - -const checkRateLimitAndThrow = async (isBatch?: boolean) => { - if (ratelimit) { - let success = false; - try { - const limiter = isBatch ? ratelimit.batch : ratelimit.basic; - success = (await limiter.limit(RATE_LIMIT_IDENTIFIER)).success; - } catch (e: any) { - console.error('Failed to rate limit OpenAI', e); - throw new Error('Failed to rate limit OpenAI'); - } - if (!success) { - console.error('OpenAI rate limit exceeded'); - throw new Error('OpenAI rate limit exceeded'); - } - } -}; - const getImageTextArgs = ( imageBase64: string, query: string, diff --git a/src/platforms/rate-limit.ts b/src/platforms/rate-limit.ts new file mode 100644 index 00000000..7a93da27 --- /dev/null +++ b/src/platforms/rate-limit.ts @@ -0,0 +1,33 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { redis } from './redis'; + +export const checkRateLimitAndThrow = async ({ + identifier, + tokens = 100, + duration = '1h', +}: { + identifier: string + tokens?: number + duration?: Parameters[1] +}) => { + if (redis) { + const limiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(tokens, duration), + }); + let success = false; + try { + success = (await limiter.limit(identifier)).success; + } catch (e: any) { + const message = + `Failed to connect to redis rate limiting store ('${identifier}')`; + console.error(message, e); + throw new Error(message); + } + if (!success) { + const message = `'${identifier}' rate limit exceeded`; + console.error(message); + throw new Error(message); + } + } +};