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('-', ' '));