diff --git a/.vscode/settings.json b/.vscode/settings.json index 082da5fb..e91d08d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "ABCDEFGHIJKLMNOP", "Acros", "affordance", + "apos", "ARROWLEFT", "ARROWRIGHT", "Astia", diff --git a/README.md b/README.md index df5ee718..fff0f43d 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Application behavior can be changed by configuring the following environment var #### 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_PUBLIC_API = 1` enables public API available at `/api` +- `NEXT_PUBLIC_SITE_FEEDS = 1` enables feeds at `/feed.json` and `/rss.xml` - `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) diff --git a/app/api/route.ts b/app/api/route.ts deleted file mode 100644 index fe566c70..00000000 --- a/app/api/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getPhotosCached } from '@/photo/cache'; -import { API_PHOTO_REQUEST_LIMIT, formatPhotoForApi } from '@/app/api'; -import { - BASE_URL, - PUBLIC_API_ENABLED, - META_TITLE, -} from '@/app/config'; - -export const dynamic = 'force-dynamic'; - -export async function GET() { - if (PUBLIC_API_ENABLED) { - const photos = await getPhotosCached({ limit: API_PHOTO_REQUEST_LIMIT }); - return Response.json({ - meta: { - title: META_TITLE, - url: BASE_URL, - }, - photos: photos.map(formatPhotoForApi), - }); - } else { - return new Response('API access disabled', { status: 404 }); - } -} diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts new file mode 100644 index 00000000..0c4eb1ea --- /dev/null +++ b/app/feed.json/route.ts @@ -0,0 +1,19 @@ +import { getPhotosCached } from '@/photo/cache'; +import { SITE_FEEDS_ENABLED } from '@/app/config'; +import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; +import { formatFeedJson } from '@/feed/json'; + +// Cache for 24 hours +export const revalidate = 86_400; + +export async function GET() { + if (SITE_FEEDS_ENABLED) { + const photos = await getPhotosCached({ + limit: FEED_PHOTO_REQUEST_LIMIT, + sortBy: 'createdAt', + }); + return Response.json(formatFeedJson(photos)); + } else { + return new Response('Feeds disabled', { status: 404 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index b1202329..3eeaa1e2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,6 +10,7 @@ import { META_TITLE, HTML_LANG, NAV_CAPTION, + SITE_FEEDS_ENABLED, } from '@/app/config'; import AppStateProvider from '@/state/AppStateProvider'; import ToasterWithThemes from '@/toast/ToasterWithThemes'; @@ -65,6 +66,13 @@ export const metadata: Metadata = { type: 'image/png', sizes: '180x180', }], + ...SITE_FEEDS_ENABLED && { + alternates: { + types: { + 'application/rss+xml': '/rss.xml', + }, + }, + }, }; export default function RootLayout({ diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts new file mode 100644 index 00000000..fb2e862a --- /dev/null +++ b/app/rss.xml/route.ts @@ -0,0 +1,23 @@ +import { getPhotosCached } from '@/photo/cache'; +import { SITE_FEEDS_ENABLED } from '@/app/config'; +import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; +import { formatFeedRss } from '@/feed/rss'; + +// Cache for 24 hours +export const revalidate = 86_400; + +export async function GET() { + if (SITE_FEEDS_ENABLED) { + const photos = await getPhotosCached({ + limit: FEED_PHOTO_REQUEST_LIMIT, + sortBy: 'createdAt', + }); + + return new Response( + formatFeedRss(photos), + { headers: { 'Content-Type': 'text/xml' } }, + ); + } else { + return new Response('Feeds disabled', { status: 404 }); + } +} diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index da7e2f68..9e5fd412 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -31,6 +31,8 @@ import ScoreCardContainer from '@/components/ScoreCardContainer'; import { DEFAULT_CATEGORY_KEYS, getHiddenCategories } from '@/category'; import { AI_AUTO_GENERATED_FIELDS_ALL } from '@/photo/ai'; import clsx from 'clsx/lite'; +import Link from 'next/link'; +import { PATH_FEED_JSON, PATH_RSS_XML } from '@/app/paths'; export default function AdminAppConfigurationClient({ // Storage @@ -103,7 +105,7 @@ export default function AdminAppConfigurationClient({ // Settings isGeoPrivacyEnabled, arePublicDownloadsEnabled, - isPublicApiEnabled, + areSiteFeedsEnabled, isPriorityOrderEnabled, isOgTextBottomAligned, // Internal @@ -177,6 +179,15 @@ export default function AdminAppConfigurationClient({ {message} ; + const renderLink = (href: string, children?: ReactNode) => + + {children || href} + ; + return ( - Set environment variable to {'"1"'} to enable - a public API available at /api: - {renderEnvVars(['NEXT_PUBLIC_PUBLIC_API'])} + Set environment variable to {'"1"'} to enable feeds at + {' '} + {renderLink(PATH_FEED_JSON)} and {renderLink(PATH_RSS_XML)}: + {renderEnvVars(['NEXT_PUBLIC_SITE_FEEDS'])} -} - -export const formatPhotoForApi = (photo: Photo): PublicApiPhoto => ({ - id: photo.id, - title: photo.title, - url: absolutePathForPhoto({ photo }), - ...photo.make && { make: photo.make }, - ...photo.model && { model: photo.model }, - ...photo.tags.length > 0 && { tags: photo.tags }, - takenAtNaive: formatDateFromPostgresString(photo.takenAtNaive), - src: { - small: getNextImageUrlForRequest({ imageUrl: photo.url, size: 200 }), - medium: getNextImageUrlForRequest({ imageUrl: photo.url, size: 640 }), - large: getNextImageUrlForRequest({ imageUrl: photo.url, size: 1200 }), - }, -}); diff --git a/src/app/config.ts b/src/app/config.ts index a0838a52..fb7c6b2a 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -307,8 +307,8 @@ export const GEO_PRIVACY_ENABLED = process.env.NEXT_PUBLIC_GEO_PRIVACY === '1'; export const ALLOW_PUBLIC_DOWNLOADS = process.env.NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS === '1'; -export const PUBLIC_API_ENABLED = - process.env.NEXT_PUBLIC_PUBLIC_API === '1'; +export const SITE_FEEDS_ENABLED = + process.env.NEXT_PUBLIC_SITE_FEEDS === '1'; export const PRIORITY_ORDER_ENABLED = process.env.NEXT_PUBLIC_IGNORE_PRIORITY_ORDER !== '1'; export const OG_TEXT_BOTTOM_ALIGNMENT = @@ -417,7 +417,7 @@ export const APP_CONFIGURATION = { // Settings isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED, arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS, - isPublicApiEnabled: PUBLIC_API_ENABLED, + areSiteFeedsEnabled: SITE_FEEDS_ENABLED, isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED, isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT, // Internal diff --git a/src/app/paths.ts b/src/app/paths.ts index 5a06f14d..3829471c 100644 --- a/src/app/paths.ts +++ b/src/app/paths.ts @@ -15,6 +15,10 @@ export const PATH_API = '/api'; export const PATH_SIGN_IN = '/sign-in'; export const PATH_OG = '/og'; +// Feeds +export const PATH_FEED_JSON = '/feed.json'; +export const PATH_RSS_XML = '/rss.xml'; + export const PATH_GRID_INFERRED = GRID_HOMEPAGE_ENABLED ? PATH_ROOT : PATH_GRID; @@ -167,6 +171,12 @@ export const pathForRecipe = (recipe: string) => `${PREFIX_RECIPE}/${recipe}`; // Absolute paths +export const ABSOLUTE_PATH_FOR_FEED_JSON = + `${getBaseUrl()}${PATH_FEED_JSON}`; + +export const ABSOLUTE_PATH_FOR_RSS_XML = + `${getBaseUrl()}${PATH_RSS_XML}`; + export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${getBaseUrl()}/home-image`; diff --git a/src/feed/index.ts b/src/feed/index.ts new file mode 100644 index 00000000..97c4ae1e --- /dev/null +++ b/src/feed/index.ts @@ -0,0 +1,32 @@ +import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo'; +import { + getNextImageUrlForRequest, + NextImageSize, +} from '@/platforms/next-image'; + +export const FEED_PHOTO_REQUEST_LIMIT = 40; + +export const FEED_PHOTO_WIDTH_SMALL = 200; +export const FEED_PHOTO_WIDTH_MEDIUM = 640; +export const FEED_PHOTO_WIDTH_LARGE = 1200; + +export interface FeedMedia { + url: string + width: number + height: number +} + +export const generateFeedMedia = ( + photo: Photo, + size: NextImageSize, +): FeedMedia => ({ + url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), + width: size, + height: Math.round(size / photo.aspectRatio), +}); + +export const getCoreFeedFields = (photo: Photo) => ({ + id: photo.id, + title: titleForPhoto(photo), + description: descriptionForPhoto(photo, true), +}); diff --git a/src/feed/json.ts b/src/feed/json.ts new file mode 100644 index 00000000..750442f9 --- /dev/null +++ b/src/feed/json.ts @@ -0,0 +1,46 @@ +import { absolutePathForPhoto } from '@/app/paths'; +import { + FEED_PHOTO_WIDTH_LARGE, + FEED_PHOTO_WIDTH_MEDIUM, + FEED_PHOTO_WIDTH_SMALL, + FeedMedia, + generateFeedMedia, + getCoreFeedFields, +} from '.'; +import { formatDateFromPostgresString } from '@/utility/date'; +import { Photo } from '@/photo'; +import { BASE_URL } from '@/app/config'; +import { META_TITLE } from '@/app/config'; + +interface FeedPhotoJson { + id: string + title: string + url: string + make?: string + model?: string + tags?: string[] + takenAtNaive: string + src: Record<'small' | 'medium' | 'large', FeedMedia> +} + +const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({ + ...getCoreFeedFields(photo), + url: absolutePathForPhoto({ photo }), + ...photo.make && { make: photo.make }, + ...photo.model && { model: photo.model }, + ...photo.tags.length > 0 && { tags: photo.tags }, + takenAtNaive: formatDateFromPostgresString(photo.takenAtNaive), + src: { + small: generateFeedMedia(photo, FEED_PHOTO_WIDTH_SMALL), + medium: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM), + large: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE), + }, +}); + +export const formatFeedJson = (photos: Photo[]) => ({ + meta: { + title: META_TITLE, + url: BASE_URL, + }, + photos: photos.map(formatPhotoForFeedJson), +}); diff --git a/src/feed/rss.ts b/src/feed/rss.ts new file mode 100644 index 00000000..56e53e42 --- /dev/null +++ b/src/feed/rss.ts @@ -0,0 +1,78 @@ +import { Photo } from '@/photo'; +import { + FEED_PHOTO_WIDTH_LARGE, + FEED_PHOTO_WIDTH_MEDIUM, + FeedMedia, + generateFeedMedia, + getCoreFeedFields, +} from '.'; +import { ABSOLUTE_PATH_FOR_RSS_XML, absolutePathForPhoto } from '@/app/paths'; +import { formatDate } from '@/utility/date'; +import { formatStringForXml } from '@/utility/string'; +import { BASE_URL, META_DESCRIPTION, META_TITLE } from '@/app/config'; + +interface FeedPhotoRss { + id: string + title: string + description?: string + link: string + pubDate: Date + media: Record<'content' | 'thumb', FeedMedia> +} + +const formatPhotoForFeedRss = (photo: Photo): FeedPhotoRss => ({ + ...getCoreFeedFields(photo), + link: absolutePathForPhoto({ photo }), + pubDate: photo.createdAt, + media: { + content: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE), + thumb: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM), + }, +}); + +const feedPhotoToXml = (photo: FeedPhotoRss): string => { + return ` + ${photo.title} + ${photo.link} + + ${formatDate({ date: photo.pubDate, length: 'rss' })} + + ${photo.link} + ${photo.description + ? `` + : ''} + + + + `; +}; + +export const formatFeedRss = (photos: Photo[]) => + ` + + + ${META_TITLE} + + ${BASE_URL} + ${META_DESCRIPTION} + ${photos.map(formatPhotoForFeedRss).map(feedPhotoToXml).join('\n')} + + `; diff --git a/src/utility/date.ts b/src/utility/date.ts index 81d76cc6..14baaac8 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -5,26 +5,29 @@ import { setDefaultDateFnLocale } from '@/i18n'; setDefaultDateFnLocale(); -const DATE_STRING_FORMAT_TINY = 'dd MMM yy'; -const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00'; +const DATE_FORMAT_TINY = 'dd MMM yy'; +const DATE_FORMAT_TINY_PLACEHOLDER = '00 000 00'; -const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy'; -const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000'; +const DATE_FORMAT_SHORT = 'dd MMM yyyy'; +const DATE_FORMAT_SHORT_PLACEHOLDER = '00 000 0000'; -const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma'; -const DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000'; +const DATE_FORMAT_MEDIUM = 'dd MMM yy h:mma'; +const DATE_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000'; -const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma'; -const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000'; +const DATE_FORMAT_LONG = 'dd MMM yyyy h:mma'; +const DATE_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000'; -const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss'; +const DATE_FORMAT_RSS = 'EEE, dd LLL yyyy HH:mm:ss xx'; +const DATE_FORMAT_RSS_PLACEHOLDER = '000, 00 000 0000 00:00:00 00'; + +const DATE_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss'; export const VALIDATION_EXAMPLE_POSTGRES = '2025-01-03T21:00:44.000Z'; export const VALIDATION_EXAMPLE_POSTGRES_NAIVE = '2025-01-03 16:00:44'; type AmbiguousTimestamp = number | string; -type Length = 'tiny' | 'short' | 'medium' | 'long'; +type Length = 'tiny' | 'short' | 'medium' | 'long' | 'rss'; export const formatDate = ({ date, @@ -40,28 +43,32 @@ export const formatDate = ({ showPlaceholder?: boolean, }) => { let formatString = !hideTime - ? DATE_STRING_FORMAT_LONG - : DATE_STRING_FORMAT_SHORT; + ? DATE_FORMAT_LONG + : DATE_FORMAT_SHORT; let placeholderString = !hideTime - ? DATE_STRING_FORMAT_LONG_PLACEHOLDER - : DATE_STRING_FORMAT_SHORT_PLACEHOLDER; + ? DATE_FORMAT_LONG_PLACEHOLDER + : DATE_FORMAT_SHORT_PLACEHOLDER; switch (length) { + case 'rss': + formatString = DATE_FORMAT_RSS; + placeholderString = DATE_FORMAT_RSS_PLACEHOLDER; + break; case 'tiny': - formatString = DATE_STRING_FORMAT_TINY; - placeholderString = DATE_STRING_FORMAT_TINY_PLACEHOLDER; + formatString = DATE_FORMAT_TINY; + placeholderString = DATE_FORMAT_TINY_PLACEHOLDER; break; case 'short': - formatString = DATE_STRING_FORMAT_SHORT; - placeholderString = DATE_STRING_FORMAT_SHORT_PLACEHOLDER; + formatString = DATE_FORMAT_SHORT; + placeholderString = DATE_FORMAT_SHORT_PLACEHOLDER; break; case 'medium': formatString = !hideTime - ? DATE_STRING_FORMAT_MEDIUM - : DATE_STRING_FORMAT_SHORT; + ? DATE_FORMAT_MEDIUM + : DATE_FORMAT_SHORT; placeholderString = !hideTime - ? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER - : DATE_STRING_FORMAT_SHORT_PLACEHOLDER; + ? DATE_FORMAT_MEDIUM_PLACEHOLDER + : DATE_FORMAT_SHORT_PLACEHOLDER; break; } @@ -77,7 +84,7 @@ export const formatDateFromPostgresString = ( length?: Length, ) => formatDate({ - date: parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()), + date: parse(date, DATE_FORMAT_POSTGRES, new Date()), length, }); @@ -130,7 +137,7 @@ export const generateLocalPostgresString = () => formatDateForPostgres(new Date()); export const generateLocalNaivePostgresString = () => - format(new Date(), DATE_STRING_FORMAT_POSTGRES); + format(new Date(), DATE_FORMAT_POSTGRES); // Form validation to prevent Postgres runtime errors diff --git a/src/utility/string.ts b/src/utility/string.ts index 71b3b633..46dd4527 100644 --- a/src/utility/string.ts +++ b/src/utility/string.ts @@ -35,6 +35,14 @@ export const parameterize = ( ) .toLocaleLowerCase(); +export const formatStringForXml = (string: string) => + string + .replace(/&/g, '&') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(//g, '>'); + export const deparameterize = (string: string) => capitalizeWords(string.replaceAll('-', ' '));