Add Locations to Albums (#334)
This commit is contained in:
parent
7296ce2e06
commit
a00e38b395
106
README.md
106
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: `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`)
|
||||
- `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'
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>(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 (
|
||||
<form
|
||||
action={updateAlbumAction}
|
||||
className="max-w-[38rem] space-y-4"
|
||||
>
|
||||
>
|
||||
{ALBUM_FORM_META
|
||||
.map(({ key, label, type, readOnly }) => (
|
||||
<FieldsetWithStatus
|
||||
@ -50,11 +55,50 @@ export default function AdminAlbumForm({
|
||||
type={type}
|
||||
label={label ?? 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]}
|
||||
readOnly={readOnly}
|
||||
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}
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
|
||||
@ -5,6 +5,7 @@ import { testRedisConnection } from '@/platforms/redis';
|
||||
import { testOpenAiConnection } from '@/platforms/openai';
|
||||
import { testDatabaseConnection } from '@/platforms/postgres';
|
||||
import { testStorageConnection } from '@/platforms/storage';
|
||||
import { testGooglePlacesConnection } from '@/platforms/google-places';
|
||||
import { APP_CONFIGURATION } from '@/app/config';
|
||||
import { getStorageUploadUrlsNoStore } from '@/platforms/storage/cache';
|
||||
import {
|
||||
@ -99,6 +100,7 @@ export const testConnectionsAction = async () =>
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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({
|
||||
</ChecklistRow>
|
||||
</>}
|
||||
</>;
|
||||
case 'AI Content Generation':
|
||||
case 'External Services':
|
||||
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
|
||||
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
|
||||
? 'Testing OpenAI connection'
|
||||
: 'Add OpenAI secret key'}
|
||||
: 'OpenAI'}
|
||||
status={isAiTextGenerationEnabled}
|
||||
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
|
||||
optional
|
||||
@ -481,12 +500,31 @@ export default function AdminAppConfigurationClient({
|
||||
{aiError && renderError({
|
||||
connection: { provider: 'OpenAI', error: aiError},
|
||||
})}
|
||||
Store your OpenAI secret key in order to enable AI-generated
|
||||
text descriptions and optionally leverage an invisible field
|
||||
called {'"Semantic Description"'} used to support CMD-K search
|
||||
and improve accessibility:
|
||||
Store OpenAI secret key in order to enable AI-generated
|
||||
text descriptions, including an invisible field called
|
||||
{' '}
|
||||
{'"Semantic Description"'}, which supports CMD-K search
|
||||
and image accessibility:
|
||||
{renderEnvVars(['OPENAI_SECRET_KEY'])}
|
||||
</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
|
||||
title={'Auto-generated text fields'}
|
||||
status={hasAiTextAutoGeneratedFields}
|
||||
@ -513,21 +551,6 @@ export default function AdminAppConfigurationClient({
|
||||
)}):
|
||||
{renderEnvVars(['AI_TEXT_AUTO_GENERATED_FIELDS'])}
|
||||
</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
|
||||
title="Base URL override (experimental)"
|
||||
status={hasOpenaiBaseUrl}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { FaRegFolderClosed } from 'react-icons/fa6';
|
||||
import { HiOutlineCog, HiSparkles } from 'react-icons/hi';
|
||||
@ -28,7 +28,11 @@ const ADMIN_CONFIG_SECTIONS = [{
|
||||
required: true,
|
||||
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',
|
||||
required: false,
|
||||
icon: <HiSparkles size={14} />,
|
||||
|
||||
@ -140,7 +140,7 @@ export default function AdminAppInsightsClient({
|
||||
noFork,
|
||||
forkBehind,
|
||||
noAi,
|
||||
noAiRateLimiting,
|
||||
noRateLimiting,
|
||||
noConfiguredDomain,
|
||||
noConfiguredMetaTitle,
|
||||
noConfiguredMetaDescription,
|
||||
@ -318,17 +318,17 @@ export default function AdminAppInsightsClient({
|
||||
</div>
|
||||
</div>}
|
||||
/>}
|
||||
{(noAiRateLimiting || debug) && <ScoreCardRow
|
||||
{(noRateLimiting || debug) && <ScoreCardRow
|
||||
icon={renderWarningIconLarge}
|
||||
content={isExpanded => 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) && <ScoreCardRow
|
||||
|
||||
@ -28,7 +28,7 @@ type AdminAppInsightCode = typeof AdminAppInsightCode[number];
|
||||
const _INSIGHTS_TEMPLATE = [
|
||||
'deprecatedEnvVars',
|
||||
'noAi',
|
||||
'noAiRateLimiting',
|
||||
'noRateLimiting',
|
||||
'noConfiguredDomain',
|
||||
'noConfiguredMetaTitle',
|
||||
'noConfiguredMetaDescription',
|
||||
@ -87,6 +87,7 @@ export const getSignificantInsights = ({
|
||||
}) => {
|
||||
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';
|
||||
|
||||
@ -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({
|
||||
<div className="text-medium mb-6 uppercase font-medium">
|
||||
{album.subhead}
|
||||
</div>}
|
||||
{tags.length > 0 &&
|
||||
{(album.location || tags.length > 0) &&
|
||||
<MaskedScroll
|
||||
className="whitespace-nowrap space-x-1.5"
|
||||
direction="horizontal"
|
||||
>
|
||||
<IconTag className="inline-block text-dim translate-y-[-0.5px]" />
|
||||
{tags.map(tag => (
|
||||
<PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
badged
|
||||
type="text-only"
|
||||
contrast="low"
|
||||
hoverType={SHOW_CATEGORY_IMAGE_HOVERS ? 'image' : 'none'}
|
||||
prefetch={false}
|
||||
{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 => (
|
||||
<PhotoTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
badged
|
||||
type="text-only"
|
||||
contrast="low"
|
||||
hoverType={SHOW_CATEGORY_IMAGE_HOVERS ? 'image' : 'none'}
|
||||
prefetch={false}
|
||||
/>
|
||||
))}
|
||||
</>}
|
||||
</MaskedScroll>}
|
||||
{album.description &&
|
||||
<div
|
||||
|
||||
@ -14,25 +14,17 @@ export const ALBUM_FORM_META: {
|
||||
{ key: 'slug', type: 'text', required: true, readOnly: true },
|
||||
{ key: 'subhead', type: 'text' },
|
||||
{ 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 => {
|
||||
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) },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<Album, 'id'>) =>
|
||||
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');
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 &&
|
||||
<ResponsiveText
|
||||
className="text-gray-400 dark:text-gray-600"
|
||||
shortText={`(${noteShort})`}
|
||||
shortText={`(${noteShort ?? note})`}
|
||||
>
|
||||
({note})
|
||||
</ResponsiveText>}
|
||||
@ -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'
|
||||
|
||||
20
src/components/QuotedContent.tsx
Normal file
20
src/components/QuotedContent.tsx
Normal 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>;
|
||||
}
|
||||
@ -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<AnnotatedTag[]>(() => 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({
|
||||
<span className="truncate">
|
||||
{option?.label ?? value}
|
||||
</span>
|
||||
{icon && <span className="text-medium">
|
||||
{icon && <span className="text-medium shrink-0">
|
||||
{icon}
|
||||
</span>}
|
||||
</>;
|
||||
@ -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)}
|
||||
</span>)}
|
||||
<input
|
||||
id={id}
|
||||
@ -371,9 +388,13 @@ export default function TagInput({
|
||||
)}
|
||||
size={10}
|
||||
value={inputText}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<input type="hidden" name={name} value={value} />
|
||||
{accessory}
|
||||
</div>
|
||||
<div className="relative">
|
||||
{shouldShowMenu && optionsFiltered.length > 0 &&
|
||||
@ -444,7 +466,7 @@ export default function TagInput({
|
||||
</span>
|
||||
{annotation &&
|
||||
<span
|
||||
className="whitespace-nowrap text-dim text-sm"
|
||||
className="truncate text-dim text-sm"
|
||||
aria-label={annotationAria}
|
||||
>
|
||||
<span aria-hidden={Boolean(annotationAria)}>
|
||||
|
||||
@ -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<typeof LinkWithStatus>['target']
|
||||
prefetch?: boolean
|
||||
title?: string
|
||||
action?: ReactNode
|
||||
@ -125,6 +127,7 @@ export default function EntityLink({
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
target={pathTarget}
|
||||
>
|
||||
<LabeledIcon {...{
|
||||
icon: badged && hasBadgeIcon && !useForHover
|
||||
|
||||
6
src/components/icons/IconPlace.tsx
Normal file
6
src/components/icons/IconPlace.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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<typeof sql>
|
||||
}
|
||||
@ -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)
|
||||
)),
|
||||
);
|
||||
|
||||
@ -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' };
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
25
src/place/PlaceEntity.tsx
Normal file
25
src/place/PlaceEntity.tsx
Normal 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
90
src/place/PlaceInput.tsx
Normal 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
15
src/place/actions.ts
Normal 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
25
src/place/index.ts
Normal 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;
|
||||
68
src/platforms/google-places.ts
Normal file
68
src/platforms/google-places.ts
Normal 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');
|
||||
};
|
||||
@ -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,
|
||||
|
||||
33
src/platforms/rate-limit.ts
Normal file
33
src/platforms/rate-limit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user