Add Locations to Albums (#334)

This commit is contained in:
Sam Becker 2025-10-15 09:37:16 -05:00 committed by GitHub
parent 7296ce2e06
commit a00e38b395
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 620 additions and 218 deletions

106
README.md
View File

@ -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) 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_ ### Content
> _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
- `NEXT_PUBLIC_META_TITLE` (seen in search results and browser tab) - `NEXT_PUBLIC_META_TITLE` (seen in search results and browser tab)
- `NEXT_PUBLIC_META_DESCRIPTION` (seen in search results) - `NEXT_PUBLIC_META_DESCRIPTION` (seen in search results)
- `NEXT_PUBLIC_NAV_TITLE` (seen in top-right navigation, defaults to domain when not configured) - `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: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`) - `NEXT_PUBLIC_PAGE_ABOUT` (seen in grid sidebar—accepts rich formatting tags: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
- `NEXT_PUBLIC_DOMAIN_SHARE` (seen in share modals where a shorter url may be desirable) - `NEXT_PUBLIC_DOMAIN_SHARE` (seen in share modals where a shorter url may be desirable)
#### Performance ### Performance
> ⚠️ Enabling may result in increased project usage. Static optimization [troubleshooting hints](#why-do-production-deployments-fail-when-static-optimization-is-enabled) in FAQ. > ⚠️ 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_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 - `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_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) - `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` - `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`. - 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: - 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_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 - `NEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1` to only show tags with 2 or more photos
#### Sorting ### Sorting
- `NEXT_PUBLIC_DEFAULT_SORT` - `NEXT_PUBLIC_DEFAULT_SORT`
- Sets default sort on grid/full homepages - Sets default sort on grid/full homepages
- Accepted values: - 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) - `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_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_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_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_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo - `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_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_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) - `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_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`) - `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_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_ALLOW_PUBLIC_DOWNLOADS = 1` enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)
- `NEXT_PUBLIC_SOCIAL_NETWORKS` - `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_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) - `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` - `PAGE_SCRIPT_URLS`
- comma-separated list of URLs to be added to the bottom of the body tag via "next/script" - comma-separated list of URLs to be added to the bottom of the body tag via "next/script"
- urls must begin with 'https' - urls must begin with 'https'

View File

@ -8,7 +8,7 @@
"test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'",
"analyze": "ANALYZE=true next build" "analyze": "ANALYZE=true next build"
}, },
"packageManager": "pnpm@10.17.1", "packageManager": "pnpm@10.18.2",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.40", "@ai-sdk/openai": "^2.0.40",
"@ai-sdk/rsc": "^1.0.59", "@ai-sdk/rsc": "^1.0.59",

View File

@ -11,6 +11,9 @@ import { ALBUM_FORM_META } from '@/album/form';
import { parameterize } from '@/utility/string'; import { parameterize } from '@/utility/string';
import { updateAlbumAction } from '@/album/actions'; import { updateAlbumAction } from '@/album/actions';
import clsx from 'clsx/lite'; 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({ export default function AdminAlbumForm({
album, album,
@ -23,20 +26,22 @@ export default function AdminAlbumForm({
const [albumForm, setAlbumForm] = useState<Album>(album); const [albumForm, setAlbumForm] = useState<Album>(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(() => { const isFormValid = useMemo(() => {
return ALBUM_FORM_META.every(({ key, required }) => { return ALBUM_FORM_META.every(({ key, required }) => {
return !required || Boolean(albumForm[key]); return !required || Boolean(albumForm[key]);
}); });
}, [albumForm]); }, [albumForm]);
const updateAlbum = useCallback((key: keyof Album, value: string) => {
setAlbumForm(form => ({
...form,
[key]: value,
...key === 'title' && { slug: parameterize(value) },
}));
}, []);
return ( return (
<form <form
action={updateAlbumAction} action={updateAlbumAction}
@ -50,11 +55,50 @@ export default function AdminAlbumForm({
type={type} type={type}
label={label ?? key} label={label ?? key}
value={albumForm[key] ? `${albumForm[key]}` : ''} value={albumForm[key] ? `${albumForm[key]}` : ''}
onChange={value => updateAlbum(key, value)} onChange={value => setAlbumForm(form => ({
...form,
[key]: value,
...key === 'title' && { slug: parameterize(value) },
}))
}
isModified={albumForm[key] !== album[key]} isModified={albumForm[key] !== album[key]}
readOnly={readOnly} readOnly={readOnly}
className={clsx(key === 'description' && '[&_textarea]:h-36')} className={clsx(key === 'description' && '[&_textarea]:h-36')}
/>))} />))}
<PlaceInput {...{
initialPlace,
setPlace,
setIsLoadingPlace,
className: 'relative z-1',
}} />
{(albumForm.location || isLoadingPlace) &&
<div className="space-y-4 w-full">
<FieldsetWithStatus
label="Location Display Name"
// eslint-disable-next-line max-len
value={albumForm.location?.nameFormatted ?? albumForm.location?.name ?? ''}
onChange={value => 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}
/>
<FieldsetWithStatus
id="location"
label="Location Data"
type="textarea"
value={JSON.stringify(albumForm.location)}
isModified={!deepEqual(albumForm.location, album.location)}
readOnly
/>
</div>}
{children} {children}
<div className="flex gap-3"> <div className="flex gap-3">
<Link <Link

View File

@ -5,6 +5,7 @@ import { testRedisConnection } from '@/platforms/redis';
import { testOpenAiConnection } from '@/platforms/openai'; import { testOpenAiConnection } from '@/platforms/openai';
import { testDatabaseConnection } from '@/platforms/postgres'; import { testDatabaseConnection } from '@/platforms/postgres';
import { testStorageConnection } from '@/platforms/storage'; import { testStorageConnection } from '@/platforms/storage';
import { testGooglePlacesConnection } from '@/platforms/google-places';
import { APP_CONFIGURATION } from '@/app/config'; import { APP_CONFIGURATION } from '@/app/config';
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache'; import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
import { import {
@ -99,6 +100,7 @@ export const testConnectionsAction = async () =>
hasDatabase, hasDatabase,
hasStorageProvider, hasStorageProvider,
hasRedisStorage, hasRedisStorage,
hasLocationServices,
isAiTextGenerationEnabled, isAiTextGenerationEnabled,
} = APP_CONFIGURATION; } = APP_CONFIGURATION;
@ -107,11 +109,13 @@ export const testConnectionsAction = async () =>
storageError, storageError,
redisError, redisError,
aiError, aiError,
locationError,
] = await Promise.all([ ] = await Promise.all([
scanForError(hasDatabase, testDatabaseConnection), scanForError(hasDatabase, testDatabaseConnection),
scanForError(hasStorageProvider, testStorageConnection), scanForError(hasStorageProvider, testStorageConnection),
scanForError(hasRedisStorage, testRedisConnection), scanForError(hasRedisStorage, testRedisConnection),
scanForError(isAiTextGenerationEnabled, testOpenAiConnection), scanForError(isAiTextGenerationEnabled, testOpenAiConnection),
scanForError(hasLocationServices, testGooglePlacesConnection),
]); ]);
return { return {
@ -119,5 +123,6 @@ export const testConnectionsAction = async () =>
storageError, storageError,
redisError, redisError,
aiError, aiError,
locationError,
}; };
}); });

View File

@ -70,11 +70,6 @@ export default function AdminAppConfigurationClient({
hasNavCaption, hasNavCaption,
pageAbout, pageAbout,
hasPageAbout, hasPageAbout,
// AI
hasOpenaiBaseUrl,
isAiTextGenerationEnabled,
aiTextAutoGeneratedFields,
hasAiTextAutoGeneratedFields,
// Performance // Performance
isStaticallyOptimized, isStaticallyOptimized,
arePhotosStaticallyOptimized, arePhotosStaticallyOptimized,
@ -85,6 +80,13 @@ export default function AdminAppConfigurationClient({
hasImageQuality, hasImageQuality,
imageQuality, imageQuality,
isBlurEnabled, isBlurEnabled,
// AI
hasOpenaiBaseUrl,
isAiTextGenerationEnabled,
aiTextAutoGeneratedFields,
hasAiTextAutoGeneratedFields,
// Location services
hasLocationServices,
// Categories // Categories
hasCategoryVisibility, hasCategoryVisibility,
categoryVisibility, categoryVisibility,
@ -141,6 +143,7 @@ export default function AdminAppConfigurationClient({
storageError, storageError,
redisError, redisError,
aiError, aiError,
locationError,
// Component props // Component props
simplifiedView, simplifiedView,
isAnalyzingConfiguration, isAnalyzingConfiguration,
@ -468,12 +471,28 @@ export default function AdminAppConfigurationClient({
</ChecklistRow> </ChecklistRow>
</>} </>}
</>; </>;
case 'AI Content Generation': case 'External Services':
return <> return <>
<ChecklistRow
title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection'
: 'Rate limiting'}
status={hasRedisStorage}
isPending={hasRedisStorage && isAnalyzingConfiguration}
showWarning={isAiTextGenerationEnabled || hasLocationServices}
optional
>
{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
</ChecklistRow>
<ChecklistRow <ChecklistRow
title={isAiTextGenerationEnabled && isAnalyzingConfiguration title={isAiTextGenerationEnabled && isAnalyzingConfiguration
? 'Testing OpenAI connection' ? 'Testing OpenAI connection'
: 'Add OpenAI secret key'} : 'OpenAI'}
status={isAiTextGenerationEnabled} status={isAiTextGenerationEnabled}
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration} isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
optional optional
@ -481,12 +500,31 @@ export default function AdminAppConfigurationClient({
{aiError && renderError({ {aiError && renderError({
connection: { provider: 'OpenAI', error: aiError}, connection: { provider: 'OpenAI', error: aiError},
})} })}
Store your OpenAI secret key in order to enable AI-generated Store OpenAI secret key in order to enable AI-generated
text descriptions and optionally leverage an invisible field text descriptions, including an invisible field called
called {'"Semantic Description"'} used to support CMD-K search {' '}
and improve accessibility: {'"Semantic Description"'}, which supports CMD-K search
and image accessibility:
{renderEnvVars(['OPENAI_SECRET_KEY'])} {renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
? 'Testing Google Places connection'
: 'Google Places'}
status={isAiTextGenerationEnabled}
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
optional
>
{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'])}
</ChecklistRow>
</>;
case 'AI Text':
return <>
<ChecklistRow <ChecklistRow
title={'Auto-generated text fields'} title={'Auto-generated text fields'}
status={hasAiTextAutoGeneratedFields} status={hasAiTextAutoGeneratedFields}
@ -513,21 +551,6 @@ export default function AdminAppConfigurationClient({
)}): )}):
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])} {renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
</ChecklistRow> </ChecklistRow>
<ChecklistRow
title={hasRedisStorage && isAnalyzingConfiguration
? 'Testing Redis connection'
: 'Enable rate limiting'}
status={hasRedisStorage}
isPending={hasRedisStorage && isAnalyzingConfiguration}
optional
>
{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
</ChecklistRow>
<ChecklistRow <ChecklistRow
title="Base URL override (experimental)" title="Base URL override (experimental)"
status={hasOpenaiBaseUrl} status={hasOpenaiBaseUrl}

View File

@ -1,5 +1,5 @@
import IconSort from '@/components/icons/IconSort'; import IconSort from '@/components/icons/IconSort';
import { BiData, BiHide, BiLockAlt, BiPencil } from 'react-icons/bi'; import { BiData, BiGlobe, BiHide, BiLockAlt, BiPencil } from 'react-icons/bi';
import { CgDebug } from 'react-icons/cg'; import { CgDebug } from 'react-icons/cg';
import { FaRegFolderClosed } from 'react-icons/fa6'; import { FaRegFolderClosed } from 'react-icons/fa6';
import { HiOutlineCog, HiSparkles } from 'react-icons/hi'; import { HiOutlineCog, HiSparkles } from 'react-icons/hi';
@ -28,7 +28,11 @@ const ADMIN_CONFIG_SECTIONS = [{
required: true, required: true,
icon: <BiPencil size={16} />, icon: <BiPencil size={16} />,
}, { }, {
title: 'AI Content Generation', title: 'External Services',
required: false,
icon: <BiGlobe size={16} className="translate-y-[1px]" />,
}, {
title: 'AI Text',
titleShort: 'AI', titleShort: 'AI',
required: false, required: false,
icon: <HiSparkles size={14} />, icon: <HiSparkles size={14} />,

View File

@ -140,7 +140,7 @@ export default function AdminAppInsightsClient({
noFork, noFork,
forkBehind, forkBehind,
noAi, noAi,
noAiRateLimiting, noRateLimiting,
noConfiguredDomain, noConfiguredDomain,
noConfiguredMetaTitle, noConfiguredMetaTitle,
noConfiguredMetaDescription, noConfiguredMetaDescription,
@ -318,17 +318,17 @@ export default function AdminAppInsightsClient({
</div> </div>
</div>} </div>}
/>} />}
{(noAiRateLimiting || debug) && <ScoreCardRow {(noRateLimiting || debug) && <ScoreCardRow
icon={renderWarningIconLarge} icon={renderWarningIconLarge}
content={isExpanded => renderHighlightText( content={isExpanded => renderHighlightText(
'Enable AI rate limiting', 'Enable rate limiting',
'yellow', 'yellow',
!isExpanded, !isExpanded,
)} )}
expandContent={<> expandContent={<>
Create Upstash Redis store from storage tab on Create Upstash Redis store from storage tab on
Vercel dashboard and link to this project to Vercel dashboard and link to this project to
prevent abuse by enabling rate limiting. prevent unexpected usage by enabling rate limiting.
</>} </>}
/>} />}
{(noConfiguredDomain || debug) && <ScoreCardRow {(noConfiguredDomain || debug) && <ScoreCardRow

View File

@ -28,7 +28,7 @@ type AdminAppInsightCode = typeof AdminAppInsightCode[number];
const _INSIGHTS_TEMPLATE = [ const _INSIGHTS_TEMPLATE = [
'deprecatedEnvVars', 'deprecatedEnvVars',
'noAi', 'noAi',
'noAiRateLimiting', 'noRateLimiting',
'noConfiguredDomain', 'noConfiguredDomain',
'noConfiguredMetaTitle', 'noConfiguredMetaTitle',
'noConfiguredMetaDescription', 'noConfiguredMetaDescription',
@ -87,6 +87,7 @@ export const getSignificantInsights = ({
}) => { }) => {
const { const {
isAiTextGenerationEnabled, isAiTextGenerationEnabled,
hasLocationServices,
hasRedisStorage, hasRedisStorage,
hasDomain, hasDomain,
} = APP_CONFIGURATION; } = APP_CONFIGURATION;
@ -94,7 +95,10 @@ export const getSignificantInsights = ({
return { return {
deprecatedEnvVars: HAS_DEPRECATED_ENV_VARS, deprecatedEnvVars: HAS_DEPRECATED_ENV_VARS,
forkBehind: Boolean(codeMeta?.isBehind), forkBehind: Boolean(codeMeta?.isBehind),
noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage, noRateLimiting: (
isAiTextGenerationEnabled ||
hasLocationServices
) && !hasRedisStorage,
noConfiguredDomain: !hasDomain, noConfiguredDomain: !hasDomain,
photosNeedSync: Boolean(photosCountNeedSync), photosNeedSync: Boolean(photosCountNeedSync),
}; };
@ -114,12 +118,12 @@ export const indicatorStatusForSignificantInsights = ({
const { const {
deprecatedEnvVars, deprecatedEnvVars,
forkBehind, forkBehind,
noAiRateLimiting, noRateLimiting,
noConfiguredDomain, noConfiguredDomain,
photosNeedSync, photosNeedSync,
} = insights; } = insights;
if (deprecatedEnvVars || noAiRateLimiting || noConfiguredDomain) { if (deprecatedEnvVars || noRateLimiting || noConfiguredDomain) {
return 'yellow'; return 'yellow';
} else if (forkBehind || photosNeedSync) { } else if (forkBehind || photosNeedSync) {
return 'blue'; return 'blue';

View File

@ -11,6 +11,7 @@ import PhotoAlbum from './PhotoAlbum';
import PhotoTag from '@/tag/PhotoTag'; import PhotoTag from '@/tag/PhotoTag';
import IconTag from '@/components/icons/IconTag'; import IconTag from '@/components/icons/IconTag';
import MaskedScroll from '@/components/MaskedScroll'; import MaskedScroll from '@/components/MaskedScroll';
import PlaceEntity from '@/place/PlaceEntity';
export default async function AlbumHeader({ export default async function AlbumHeader({
album, album,
@ -58,12 +59,20 @@ export default async function AlbumHeader({
<div className="text-medium mb-6 uppercase font-medium"> <div className="text-medium mb-6 uppercase font-medium">
{album.subhead} {album.subhead}
</div>} </div>}
{tags.length > 0 && {(album.location || tags.length > 0) &&
<MaskedScroll <MaskedScroll
className="whitespace-nowrap space-x-1.5" className="whitespace-nowrap space-x-1.5"
direction="horizontal" direction="horizontal"
> >
<IconTag className="inline-block text-dim translate-y-[-0.5px]" /> {album.location &&
<PlaceEntity
location={album.location}
className="translate-x-[-2px] mr-3!"
/>}
{tags.length > 0 && <>
<IconTag
className="inline-block text-dim translate-y-[-0.5px]"
/>
{tags.map(tag => ( {tags.map(tag => (
<PhotoTag <PhotoTag
key={tag} key={tag}
@ -75,6 +84,7 @@ export default async function AlbumHeader({
prefetch={false} prefetch={false}
/> />
))} ))}
</>}
</MaskedScroll>} </MaskedScroll>}
{album.description && {album.description &&
<div <div

View File

@ -14,25 +14,17 @@ export const ALBUM_FORM_META: {
{ key: 'slug', type: 'text', required: true, readOnly: true }, { key: 'slug', type: 'text', required: true, readOnly: true },
{ key: 'subhead', type: 'text' }, { key: 'subhead', type: 'text' },
{ key: 'description', type: 'textarea' }, { key: 'description', type: 'textarea' },
{ key: 'locationName', label: 'location name', type: 'hidden' },
{ key: 'latitude', type: 'hidden' },
{ key: 'longitude', type: 'hidden' },
]; ];
export const convertFormDataToAlbum = (formData: FormData): Album => { export const convertFormDataToAlbum = (formData: FormData): Album => {
const locationString = formData.get('location') as string | undefined;
return { return {
id: formData.get('id') as string, id: formData.get('id') as string,
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string, slug: formData.get('slug') as string,
subhead: formData.get('subhead') as string, subhead: formData.get('subhead') as string,
description: formData.get('description') as string, description: formData.get('description') as string,
locationName: formData.get('locationName') as string, ...locationString && { location: JSON.parse(locationString) },
latitude: formData.get('latitude')
? parseFloat(formData.get('latitude') as string)
: undefined,
longitude: formData.get('longitude')
? parseFloat(formData.get('longitude') as string)
: undefined,
}; };
}; };

View File

@ -7,6 +7,7 @@ import {
PhotoDateRangePostgres, PhotoDateRangePostgres,
photoQuantityText, photoQuantityText,
} from '@/photo'; } from '@/photo';
import { Place } from '@/place';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
export interface Album { export interface Album {
@ -15,9 +16,7 @@ export interface Album {
slug: string slug: string
subhead?: string subhead?: string
description?: string description?: string
locationName?: string location?: Place
latitude?: number
longitude?: number
} }
type AlbumWithMeta = { type AlbumWithMeta = {
@ -34,9 +33,7 @@ export const parseAlbumFromDb = (album: any): Album =>
export const albumHasMeta = (album: Album) => export const albumHasMeta = (album: Album) =>
album.subhead || album.subhead ||
album.description || album.description ||
album.locationName || album.location;
album.latitude ||
album.longitude;
export const titleForAlbum = ( export const titleForAlbum = (
album: Album, album: Album,

View File

@ -11,9 +11,7 @@ export const createAlbumsTable = () =>
slug VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL,
subhead TEXT, subhead TEXT,
description TEXT, description TEXT,
location_name VARCHAR(255), location JSONB,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_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<Album, 'id'>) =>
slug, slug,
subhead, subhead,
description, description,
location_name, location
latitude,
longitude
) VALUES ( ) VALUES (
${album.title}, ${album.title},
${album.slug}, ${album.slug},
${album.subhead}, ${album.subhead},
${album.description}, ${album.description},
${album.locationName}, ${album.location
${album.latitude}, ? JSON.stringify(album.location)
${album.longitude} : null}
) )
RETURNING id RETURNING id
`.then(({ rows }) => rows[0]?.id as string) `.then(({ rows }) => rows[0]?.id as string)
@ -59,9 +55,9 @@ export const updateAlbum = (album: Album) =>
slug=${album.slug}, slug=${album.slug},
subhead=${album.subhead}, subhead=${album.subhead},
description=${album.description}, description=${album.description},
location_name=${album.locationName}, location=${album.location
latitude=${album.latitude}, ? JSON.stringify(album.location)
longitude=${album.longitude}, : null},
updated_at=${(new Date()).toISOString()} updated_at=${(new Date()).toISOString()}
WHERE id=${album.id} WHERE id=${album.id}
`, 'updateAlbum'); `, 'updateAlbum');

View File

@ -221,14 +221,6 @@ export const CURRENT_STORAGE: StorageType =
: 'vercel-blob' : '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 // PERFORMANCE
export const STATICALLY_OPTIMIZED_PHOTOS = export const STATICALLY_OPTIMIZED_PHOTOS =
@ -260,6 +252,19 @@ export const IMAGE_QUALITY =
export const BLUR_ENABLED = export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1'; 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 // CATEGORIES
export const CATEGORY_VISIBILITY = parseOrderedCategoriesFromString( export const CATEGORY_VISIBILITY = parseOrderedCategoriesFromString(
@ -431,16 +436,6 @@ export const APP_CONFIGURATION = {
hasNavCaption: Boolean(NAV_CAPTION), hasNavCaption: Boolean(NAV_CAPTION),
pageAbout: PAGE_ABOUT, pageAbout: PAGE_ABOUT,
hasPageAbout: Boolean(process.env.NEXT_PUBLIC_SITE_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 // Performance
isStaticallyOptimized: HAS_STATIC_OPTIMIZATION, isStaticallyOptimized: HAS_STATIC_OPTIMIZATION,
arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS, arePhotosStaticallyOptimized: STATICALLY_OPTIMIZED_PHOTOS,
@ -452,6 +447,18 @@ export const APP_CONFIGURATION = {
hasImageQuality: Boolean(process.env.NEXT_PUBLIC_IMAGE_QUALITY), hasImageQuality: Boolean(process.env.NEXT_PUBLIC_IMAGE_QUALITY),
imageQuality: IMAGE_QUALITY, imageQuality: IMAGE_QUALITY,
isBlurEnabled: BLUR_ENABLED, 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 // Categories
hasCategoryVisibility: hasCategoryVisibility:
Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY), Boolean(process.env.NEXT_PUBLIC_CATEGORY_VISIBILITY),

View File

@ -33,6 +33,11 @@ export default function FieldsetWithStatus({
tagOptionsLimitValidationMessage, tagOptionsLimitValidationMessage,
tagOptionsShouldParameterize, tagOptionsShouldParameterize,
tagOptionsDefaultIcon, tagOptionsDefaultIcon,
tagOptionsDefaultIconSelected,
tagOptionsLabelOverride,
tagOptionsAllowNewValues,
tagOptionsAccessory,
tagOptionsOnInputTextChange,
placeholder, placeholder,
loading, loading,
required, required,
@ -65,6 +70,11 @@ export default function FieldsetWithStatus({
tagOptionsLimitValidationMessage?: string tagOptionsLimitValidationMessage?: string
tagOptionsShouldParameterize?: boolean tagOptionsShouldParameterize?: boolean
tagOptionsDefaultIcon?: ReactNode tagOptionsDefaultIcon?: ReactNode
tagOptionsDefaultIconSelected?: ReactNode
tagOptionsLabelOverride?: (value: string) => string
tagOptionsAllowNewValues?: boolean
tagOptionsAccessory?: ReactNode
tagOptionsOnInputTextChange?: (value: string) => void
placeholder?: string placeholder?: string
loading?: boolean loading?: boolean
required?: boolean required?: boolean
@ -161,7 +171,7 @@ export default function FieldsetWithStatus({
{note && !error && {note && !error &&
<ResponsiveText <ResponsiveText
className="text-gray-400 dark:text-gray-600" className="text-gray-400 dark:text-gray-600"
shortText={`(${noteShort})`} shortText={`(${noteShort ?? note})`}
> >
({note}) ({note})
</ResponsiveText>} </ResponsiveText>}
@ -206,14 +216,19 @@ export default function FieldsetWithStatus({
name={id} name={id}
value={value} value={value}
options={tagOptions} options={tagOptions}
labelForValueOverride={tagOptionsLabelOverride}
defaultIcon={tagOptionsDefaultIcon} defaultIcon={tagOptionsDefaultIcon}
defaultIconSelected={tagOptionsDefaultIconSelected}
accessory={tagOptionsAccessory}
onChange={onChange} onChange={onChange}
onInputTextChange={tagOptionsOnInputTextChange}
showMenuOnDelete={tagOptionsLimit === 1} showMenuOnDelete={tagOptionsLimit === 1}
className={clsx(Boolean(error) && 'error')} className={clsx(Boolean(error) && 'error')}
readOnly={readOnly} readOnly={readOnly}
placeholder={placeholder} placeholder={placeholder}
limit={tagOptionsLimit} limit={tagOptionsLimit}
limitValidationMessage={tagOptionsLimitValidationMessage} limitValidationMessage={tagOptionsLimitValidationMessage}
allowNewValues={tagOptionsAllowNewValues}
shouldParameterize={tagOptionsShouldParameterize} shouldParameterize={tagOptionsShouldParameterize}
/> />
: type === 'textarea' : type === 'textarea'

View File

@ -0,0 +1,20 @@
import clsx from 'clsx/lite';
export default function QuotedContent({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return <div className={clsx(
'relative pl-4',
'before:content-[""] before:absolute',
'before:left-0 before:top-1 before:bottom-1',
'before:w-0.5 before:rounded-full',
'before:bg-medium',
className,
)}>
{children}
</div>;
}

View File

@ -22,28 +22,38 @@ export default function TagInput({
name, name,
value = '', value = '',
options = [], options = [],
labelForValueOverride,
defaultIcon, defaultIcon,
defaultIconSelected,
accessory,
onChange, onChange,
onInputTextChange,
showMenuOnDelete, showMenuOnDelete,
className, className,
readOnly, readOnly,
placeholder, placeholder,
limit, limit,
limitValidationMessage, limitValidationMessage,
allowNewValues = true,
shouldParameterize, shouldParameterize,
}: { }: {
id?: string id?: string
name: string name: string
value?: string value?: string
options?: AnnotatedTag[] options?: AnnotatedTag[]
labelForValueOverride?: (value: string) => string
defaultIcon?: ReactNode defaultIcon?: ReactNode
defaultIconSelected?: ReactNode
accessory?: ReactNode
onChange?: (value: string) => void onChange?: (value: string) => void
onInputTextChange?: (value: string) => void
showMenuOnDelete?: boolean showMenuOnDelete?: boolean
className?: string className?: string
readOnly?: boolean readOnly?: boolean
placeholder?: string placeholder?: string
limit?: number limit?: number
limitValidationMessage?: string limitValidationMessage?: string
allowNewValues?: boolean
shouldParameterize?: boolean shouldParameterize?: boolean
}) { }) {
const behaveAsDropdown = limit === 1; const behaveAsDropdown = limit === 1;
@ -94,22 +104,25 @@ export default function TagInput({
const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit const optionsFiltered = useMemo<AnnotatedTag[]>(() => hasReachedLimit
? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }] ? [{ value: limitValidationMessage ?? `Limit reached (${limit})` }]
: (isInputTextUnique : (isInputTextUnique && allowNewValues
? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }] ? [{ value: `${CREATE_LABEL} "${inputTextFormatted}"` }]
: [] : []
).concat(options ).concat(options
.filter(({ value }) => .filter(({ value, label }) =>{
!selectedOptions.includes(value) && // Make value and key searchable
( const key = `${value}-${label}`;
return !selectedOptions.includes(key) && (
!inputTextFormatted || !inputTextFormatted ||
(shouldParameterize (shouldParameterize
? value.includes(inputTextFormatted) ? key.includes(inputTextFormatted)
: (parameterize(value)).includes(parameterize(inputTextFormatted))) : (parameterize(key)).includes(parameterize(inputTextFormatted)))
))) );
}))
, [ , [
hasReachedLimit, hasReachedLimit,
inputTextFormatted, inputTextFormatted,
isInputTextUnique, isInputTextUnique,
allowNewValues,
limit, limit,
limitValidationMessage, limitValidationMessage,
options, options,
@ -290,7 +303,7 @@ export default function TagInput({
<span className="truncate"> <span className="truncate">
{option?.label ?? value} {option?.label ?? value}
</span> </span>
{icon && <span className="text-medium"> {icon && <span className="text-medium shrink-0">
{icon} {icon}
</span>} </span>}
</>; </>;
@ -304,9 +317,12 @@ export default function TagInput({
onBlur={e => { onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget)) { if (!e.currentTarget.contains(e.relatedTarget)) {
// Capture text on blur if limit not yet reached // Capture text on blur if limit not yet reached
if (inputText && !hasReachedLimit) { if (inputText && !hasReachedLimit && allowNewValues) {
addOptions([inputText]); 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(''); setInputText('');
} }
hideMenu(); hideMenu();
@ -352,11 +368,12 @@ export default function TagInput({
'px-1.5 py-0.5', 'px-1.5 py-0.5',
'bg-gray-200/60 dark:bg-gray-800', 'bg-gray-200/60 dark:bg-gray-800',
'active:bg-gray-200 dark:active:bg-gray-900', 'active:bg-gray-200 dark:active:bg-gray-900',
'rounded-xs', 'rounded-sm',
)} )}
onClick={() => removeOption(option)} onClick={() => removeOption(option)}
> >
{renderTag(option)} {defaultIconSelected}
{renderTag(labelForValueOverride?.(option) || option)}
</span>)} </span>)}
<input <input
id={id} id={id}
@ -371,9 +388,13 @@ export default function TagInput({
)} )}
size={10} size={10}
value={inputText} value={inputText}
onChange={e => setInputText(e.target.value)} onChange={e => {
setInputText(e.target.value);
onInputTextChange?.(e.target.value);
}}
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off"
readOnly={readOnly} readOnly={readOnly}
placeholder={selectedOptions.length === 0 ? placeholder : undefined} placeholder={selectedOptions.length === 0 ? placeholder : undefined}
onFocus={() => setSelectedOptionIndex(undefined)} onFocus={() => setSelectedOptionIndex(undefined)}
@ -387,6 +408,7 @@ export default function TagInput({
role="combobox" role="combobox"
/> />
<input type="hidden" name={name} value={value} /> <input type="hidden" name={name} value={value} />
{accessory}
</div> </div>
<div className="relative"> <div className="relative">
{shouldShowMenu && optionsFiltered.length > 0 && {shouldShowMenu && optionsFiltered.length > 0 &&
@ -444,7 +466,7 @@ export default function TagInput({
</span> </span>
{annotation && {annotation &&
<span <span
className="whitespace-nowrap text-dim text-sm" className="truncate text-dim text-sm"
aria-label={annotationAria} aria-label={annotationAria}
> >
<span aria-hidden={Boolean(annotationAria)}> <span aria-hidden={Boolean(annotationAria)}>

View File

@ -42,6 +42,7 @@ export default function EntityLink({
badgeType = 'small', badgeType = 'small',
contrast = 'medium', contrast = 'medium',
path = '', // Make link optional for debugging purposes path = '', // Make link optional for debugging purposes
pathTarget,
hoverCount = 0, hoverCount = 0,
hoverType = 'auto', hoverType = 'auto',
hoverQueryOptions, hoverQueryOptions,
@ -62,6 +63,7 @@ export default function EntityLink({
labelSmall?: ReactNode labelSmall?: ReactNode
iconWide?: boolean iconWide?: boolean
path?: string path?: string
pathTarget?: ComponentProps<typeof LinkWithStatus>['target']
prefetch?: boolean prefetch?: boolean
title?: string title?: string
action?: ReactNode action?: ReactNode
@ -125,6 +127,7 @@ export default function EntityLink({
)} )}
isLoading={isLoading} isLoading={isLoading}
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
target={pathTarget}
> >
<LabeledIcon {...{ <LabeledIcon {...{
icon: badged && hasBadgeIcon && !useForHover icon: badged && hasBadgeIcon && !useForHover

View File

@ -0,0 +1,6 @@
import { IconBaseProps } from 'react-icons';
import { FaMapPin } from 'react-icons/fa6';
export default function IconYear(props: IconBaseProps) {
return <FaMapPin {...props} />;
}

View File

@ -1,7 +1,8 @@
import { sql } from '@/platforms/postgres'; import { query, sql } from '@/platforms/postgres';
interface Migration { interface Migration {
label: string label: string
table?: 'photos' | 'albums'
fields: string[] fields: string[]
run: () => ReturnType<typeof sql> run: () => ReturnType<typeof sql>
} }
@ -86,13 +87,27 @@ export const MIGRATIONS: Migration[] = [{
ADD COLUMN IF NOT EXISTS color_data JSONB, ADD COLUMN IF NOT EXISTS color_data JSONB,
ADD COLUMN IF NOT EXISTS color_sort SMALLINT 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) => export const migrationForError = (e: any) =>
MIGRATIONS.find(migration => MIGRATIONS.find(({ fields, table = 'photos' }) =>
migration.fields.some(field =>( fields.some(field =>(
// eslint-disable-next-line max-len // 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) new RegExp(`column "${field}" does not exist`, 'i').test(e.message)
)), )),
); );

View File

@ -2,7 +2,7 @@ import { Photo } from '@/photo';
import { MakeModelTextLength, parameterize } from '@/utility/string'; import { MakeModelTextLength, parameterize } from '@/utility/string';
import { formatAppleLensText, isLensApple } from '../platforms/apple'; import { formatAppleLensText, isLensApple } from '../platforms/apple';
import { MISSING_FIELD } from '@/app/path'; import { MISSING_FIELD } from '@/app/path';
import { formatGoogleLensText, isLensGoogle } from '../platforms/google'; import { formatGoogleLensText, isLensGoogle } from '../platforms/google-pixel';
import { CategoryQueryMeta } from '@/category'; import { CategoryQueryMeta } from '@/category';
const LENS_PLACEHOLDER: Lens = { make: 'Lens', model: 'Model' }; const LENS_PLACEHOLDER: Lens = { make: 'Lens', model: 'Model' };

View File

@ -40,6 +40,8 @@ export default function PhotoLightbox({
'flex flex-col items-center justify-center', 'flex flex-col items-center justify-center',
'gap-0.5', 'gap-0.5',
'text-[1.1rem] lg:text-[1.25rem]', 'text-[1.1rem] lg:text-[1.25rem]',
// Optically adjust for leading '+' character
'translate-x-[-1px]',
)} )}
> >
+{countNotShown} +{countNotShown}

View File

@ -516,7 +516,10 @@ export const renamePhotoRecipeGloballyAction = async (formData: FormData) =>
export const deleteUploadsAction = async (urls: string[]) => export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
await Promise.all(urls.map(url => deleteFile(url))); await Promise.all(urls.map(url => deleteFile(url)));
if (urls.length > 1) {
// Only refresh state when deleting multiple uploads
revalidateAdminPaths(); revalidateAdminPaths();
}
}); });
// Accessed from admin photo edit page // Accessed from admin photo edit page

25
src/place/PlaceEntity.tsx Normal file
View File

@ -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 (
<EntityLink
{...props}
icon={<IconPlace
className="text-[13px] translate-x-[2px]"
/>}
label={location.nameFormatted || location.name}
path={location.link}
pathTarget="_blank"
badged
/>
);
}

90
src/place/PlaceInput.tsx Normal file
View File

@ -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<Record<string, PlaceAutocomplete>>(initialPlace
? { [initialPlace.id]: initialPlace }
: {});
const [placeId, setPlaceId] = useState(initialPlace?.id ?? '');
const [isLoadingPlaces, setIsLoadingPlaces] = useState(false);
const [inputText, setInputText] = useState('');
const [placeOptions, setPlaceOptions] =
useState<ComponentProps<typeof FieldsetWithStatus>['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 (
<FieldsetWithStatus
id="place-input"
label="Location"
className={className}
isModified={placeId !== initialPlace?.id}
tagOptions={placeOptions}
value={placeId}
onChange={id => {
setPlaceId(id);
if (id) {
setIsLoadingPlace?.(true);
getPlaceDetailsAction(id)
.then(setPlace)
.finally(() => setIsLoadingPlace?.(false));
} else {
setPlace?.(undefined);
}
}}
tagOptionsLabelOverride={(placeId) => places.current[placeId]?.text}
tagOptionsDefaultIconSelected={<IconPlace
size={11}
className="text-main translate-x-0.5"
/>}
tagOptionsOnInputTextChange={text => {
setInputText(text);
// Clear autocomplete immediately when there's no input text
if (!text) {
setPlaceOptions([]);
}
}}
tagOptionsLimit={1}
tagOptionsAllowNewValues={false}
tagOptionsAccessory={isLoadingPlaces &&
<Spinner size={16} className="mr-1 shrink-0" />}
tagOptionsShouldParameterize={false}
/>
);
}

15
src/place/actions.ts Normal file
View File

@ -0,0 +1,15 @@
'use server';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import {
getPlaceAutocomplete,
getPlaceDetails,
} from '@/platforms/google-places';
export const getPlaceAutoCompleteAction =
async (...args: Parameters<typeof getPlaceAutocomplete>) =>
runAuthenticatedAdminServerAction(() => getPlaceAutocomplete(...args));
export const getPlaceDetailsAction =
async (...args: Parameters<typeof getPlaceDetails>) =>
runAuthenticatedAdminServerAction(() => getPlaceDetails(...args));

25
src/place/index.ts Normal file
View File

@ -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;

View File

@ -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<PlaceAutocomplete[]> => {
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<Place> => {
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');
};

View File

@ -1,53 +1,29 @@
import { generateText, streamText, generateObject } from 'ai'; import { generateText, streamText, generateObject } from 'ai';
import { createStreamableValue } from '@ai-sdk/rsc'; import { createStreamableValue } from '@ai-sdk/rsc';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
import { Ratelimit } from '@upstash/ratelimit'; import { OPENAI_BASE_URL, OPENAI_SECRET_KEY } from '@/app/config';
import { AI_CONTENT_GENERATION_ENABLED, OPENAI_BASE_URL } from '@/app/config';
import { removeBase64Prefix } from '@/utility/image'; import { removeBase64Prefix } from '@/utility/image';
import { cleanUpAiTextResponse } from '@/photo/ai'; import { cleanUpAiTextResponse } from '@/photo/ai';
import { redis } from '@/platforms/redis'; import {
checkRateLimitAndThrow as _checkRateLimitAndThrow,
} from '@/platforms/rate-limit';
import { z } from 'zod'; 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 MODEL = 'gpt-4o';
const openai = AI_CONTENT_GENERATION_ENABLED const openai = OPENAI_SECRET_KEY
? createOpenAI({ ? createOpenAI({
apiKey: process.env.OPENAI_SECRET_KEY, apiKey: OPENAI_SECRET_KEY,
...OPENAI_BASE_URL && { baseURL: OPENAI_BASE_URL }, ...OPENAI_BASE_URL && { baseURL: OPENAI_BASE_URL },
}) })
: undefined; : 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 = ( const getImageTextArgs = (
imageBase64: string, imageBase64: string,
query: string, query: string,

View File

@ -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<typeof Ratelimit.slidingWindow>[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);
}
}
};