From 559f61fa683c31a2a5cec4ec7c5db5cdfdcc65e4 Mon Sep 17 00:00:00 2001 From: Tadej Novak Date: Sun, 8 Jun 2025 10:56:18 +0200 Subject: [PATCH 01/13] Add public feed configuration --- README.md | 1 + src/admin/AdminAppConfigurationClient.tsx | 11 +++++++++++ src/app/config.ts | 3 +++ 3 files changed, 15 insertions(+) diff --git a/README.md b/README.md index df5ee718..12645122 100644 --- a/README.md +++ b/README.md @@ -158,6 +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_FEED = 1` enables public feed available at `/feed.json` and `/rss.xml` - `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` - `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/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index da7e2f68..10b9fffe 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -104,6 +104,7 @@ export default function AdminAppConfigurationClient({ isGeoPrivacyEnabled, arePublicDownloadsEnabled, isPublicApiEnabled, + isPublicFeedEnabled, isPriorityOrderEnabled, isOgTextBottomAligned, // Internal @@ -733,6 +734,16 @@ export default function AdminAppConfigurationClient({ public photo downloads for all visitors: {renderEnvVars(['NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS'])} + + Set environment variable to {'"1"'} to enable + a public feed available at /feed.json + and /rss.xml: + {renderEnvVars(['NEXT_PUBLIC_PUBLIC_FEED'])} + Date: Sun, 8 Jun 2025 11:11:22 +0200 Subject: [PATCH 02/13] Add RSS feed to the layout --- app/layout.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index b1202329..dc8a19ae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,6 +10,7 @@ import { META_TITLE, HTML_LANG, NAV_CAPTION, + PUBLIC_FEED_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', }], + ...PUBLIC_FEED_ENABLED && { + alternates: { + types: { + 'application/rss+xml': '/rss.xml', + }, + }, + }, }; export default function RootLayout({ From 28b1c92edb395ef235752d5f9d1322d7da974013 Mon Sep 17 00:00:00 2001 From: Tadej Novak Date: Sun, 8 Jun 2025 11:12:56 +0200 Subject: [PATCH 03/13] Add RSS date format --- src/utility/date.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utility/date.ts b/src/utility/date.ts index 81d76cc6..1210fc96 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -19,12 +19,14 @@ const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000'; const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss'; +const DATE_STRING_FORMAT_RSS = 'EEE, dd MMM yyyy HH:mm:ss xx'; + 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, @@ -47,6 +49,9 @@ export const formatDate = ({ : DATE_STRING_FORMAT_SHORT_PLACEHOLDER; switch (length) { + case 'rss': + formatString = DATE_STRING_FORMAT_RSS; + break; case 'tiny': formatString = DATE_STRING_FORMAT_TINY; placeholderString = DATE_STRING_FORMAT_TINY_PLACEHOLDER; From 3c4adc2f9e5e64189ce6292508969cf15be364d3 Mon Sep 17 00:00:00 2001 From: Tadej Novak Date: Sun, 8 Jun 2025 13:26:35 +0200 Subject: [PATCH 04/13] Add rss.xml and feed.json endpoints --- app/feed.json/route.ts | 28 ++++++++++++++ app/rss.xml/route.ts | 48 +++++++++++++++++++++++ src/app/feed.ts | 87 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 app/feed.json/route.ts create mode 100644 app/rss.xml/route.ts create mode 100644 src/app/feed.ts diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts new file mode 100644 index 00000000..8b8f64f8 --- /dev/null +++ b/app/feed.json/route.ts @@ -0,0 +1,28 @@ +import { getPhotosCached } from '@/photo/cache'; +import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; +import { formatPhotoForFeed } from '@/app/feed'; +import { + BASE_URL, + PUBLIC_FEED_ENABLED, + META_TITLE, +} from '@/app/config'; + +export const dynamic = 'force-static'; + +export async function GET() { + if (PUBLIC_FEED_ENABLED) { + const photos = await getPhotosCached({ + limit: INFINITE_SCROLL_FEED_INITIAL, + sortBy: 'createdAt', + }); + return Response.json({ + meta: { + title: META_TITLE, + url: BASE_URL, + }, + photos: photos.map(formatPhotoForFeed), + }); + } else { + return new Response('Feed disabled', { status: 404 }); + } +} diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts new file mode 100644 index 00000000..bffa4e6b --- /dev/null +++ b/app/rss.xml/route.ts @@ -0,0 +1,48 @@ +import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; +import { getPhotosCached } from '@/photo/cache'; +import { + BASE_URL, + META_DESCRIPTION, + META_TITLE, + PUBLIC_FEED_ENABLED, +} from '@/app/config'; +import { feedPhotoToXml, formatPhotoForFeed } from '@/app/feed'; + +export const dynamic = 'force-static'; + +export async function GET() { + if (PUBLIC_FEED_ENABLED) { + const photos = await getPhotosCached({ + limit: INFINITE_SCROLL_FEED_INITIAL, + sortBy: 'createdAt', + }); + const items = photos.map(formatPhotoForFeed).map(feedPhotoToXml); + + return new Response( + ` + + + + ${META_TITLE} + + ${BASE_URL} + ${META_DESCRIPTION} + + ${items.join('\n\n ')} + + + + `, + { + headers: { + 'Content-Type': 'text/xml', + }, + }, + ); + } else { + return new Response('RSS feed access disabled', { status: 404 }); + } +} diff --git a/src/app/feed.ts b/src/app/feed.ts new file mode 100644 index 00000000..5ccbe104 --- /dev/null +++ b/src/app/feed.ts @@ -0,0 +1,87 @@ +import { Photo } from '@/photo'; +import { absolutePathForPhoto } from './paths'; +import { getNextImageUrlForRequest } from '@/platforms/next-image'; +import { formatDate } from '@/utility/date'; + +export const FEED_PHOTO_WIDTH_CONTENT = 1080; +export const FEED_PHOTO_WIDTH_THUMB = 640; + +export interface PublicFeed { + meta: { + title: string + url: string + } + photos: PublicFeedPhoto[] +} + +interface PublicFeedMedia { + url: string + width: number + height: number +} + +interface PublicFeedPhoto { + id: string + title?: string + description?: string + link: string + publicationDate: Date + media: Record< + 'content' | 'thumb', + PublicFeedMedia + > +} + +export const formatPhotoForFeed = (photo: Photo): PublicFeedPhoto => ({ + id: photo.id, + title: photo.title, + description: photo.caption, + link: absolutePathForPhoto({ photo }), + publicationDate: photo.createdAt, + media: { + content: { + url: getNextImageUrlForRequest({ + imageUrl: photo.url, + size: FEED_PHOTO_WIDTH_CONTENT, + }), + width: FEED_PHOTO_WIDTH_CONTENT, + height: Math.round(FEED_PHOTO_WIDTH_CONTENT / photo.aspectRatio), + }, + thumb: { + url: getNextImageUrlForRequest({ + imageUrl: photo.url, + size: FEED_PHOTO_WIDTH_THUMB, + }), + width: FEED_PHOTO_WIDTH_THUMB, + height: Math.round(FEED_PHOTO_WIDTH_THUMB / photo.aspectRatio), + }, + }, +}); + +export const feedPhotoToXml = (photo: PublicFeedPhoto): string => { + const formattedDate = formatDate({ + date: photo.publicationDate, + length: 'rss', + }); + const description = photo.description ? + ` + + ` : ''; + + return ` + ${photo.title} + ${photo.link} + ${formattedDate} + ${photo.link} + ${description} + + + + `; +}; From 0236461d9912a6c8bcbcdd1ff4f6a821f5bdf2b9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 15:59:39 -0500 Subject: [PATCH 05/13] Refine date formatting --- src/utility/date.ts | 52 +++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/utility/date.ts b/src/utility/date.ts index 1210fc96..14baaac8 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -5,21 +5,22 @@ 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_STRING_FORMAT_RSS = 'EEE, dd MMM yyyy HH:mm:ss xx'; +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'; @@ -42,31 +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_STRING_FORMAT_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; } @@ -82,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, }); @@ -135,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 From ec698b61de447b120f9236d2b10958dbc4abb912 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 16:31:45 -0500 Subject: [PATCH 06/13] Begin combining feed and api --- app/feed.json/route.ts | 4 +- src/app/feed.ts | 140 ++++++++++++++++++++++++++--------------- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts index 8b8f64f8..3422017e 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -1,11 +1,11 @@ import { getPhotosCached } from '@/photo/cache'; import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; -import { formatPhotoForFeed } from '@/app/feed'; import { BASE_URL, PUBLIC_FEED_ENABLED, META_TITLE, } from '@/app/config'; +import { formatPhotoForFeedJson } from '@/app/feed'; export const dynamic = 'force-static'; @@ -20,7 +20,7 @@ export async function GET() { title: META_TITLE, url: BASE_URL, }, - photos: photos.map(formatPhotoForFeed), + photos: photos.map(formatPhotoForFeedJson), }); } else { return new Response('Feed disabled', { status: 404 }); diff --git a/src/app/feed.ts b/src/app/feed.ts index 5ccbe104..ba16e94f 100644 --- a/src/app/feed.ts +++ b/src/app/feed.ts @@ -1,17 +1,31 @@ -import { Photo } from '@/photo'; +import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo'; import { absolutePathForPhoto } from './paths'; -import { getNextImageUrlForRequest } from '@/platforms/next-image'; -import { formatDate } from '@/utility/date'; +import { + getNextImageUrlForRequest, + NextImageSize, +} from '@/platforms/next-image'; +import { formatDate, formatDateFromPostgresString } from '@/utility/date'; -export const FEED_PHOTO_WIDTH_CONTENT = 1080; -export const FEED_PHOTO_WIDTH_THUMB = 640; +export const API_PHOTO_REQUEST_LIMIT = 40; -export interface PublicFeed { +export const FEED_PHOTO_WIDTH_SMALL = 200; +export const FEED_PHOTO_WIDTH_MEDIUM = 640; +export const FEED_PHOTO_WIDTH_LARGE = 1200; + +export interface PublicFeedJson { meta: { title: string url: string } - photos: PublicFeedPhoto[] + photos: PublicFeedPhotoJson[] +} + +export interface PublicFeedRss { + meta: { + title: string + url: string + } + photos: PublicFeedPhotoRss[] } interface PublicFeedMedia { @@ -20,7 +34,21 @@ interface PublicFeedMedia { height: number } -interface PublicFeedPhoto { +interface PublicFeedPhotoJson { + id: string + title?: string + url: string + make?: string + model?: string + tags?: string[] + takenAtNaive: string + src: Record< + 'small' | 'medium' | 'large', + PublicFeedMedia + > +} + +interface PublicFeedPhotoRss { id: string title?: string description?: string @@ -32,56 +60,66 @@ interface PublicFeedPhoto { > } -export const formatPhotoForFeed = (photo: Photo): PublicFeedPhoto => ({ +const generateFeedMedia = ( + photo: Photo, + size: NextImageSize, +): PublicFeedMedia => ({ + url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), + width: size, + height: Math.round(size / photo.aspectRatio), +}); + +const getCoreFeedFields = (photo: Photo) => ({ id: photo.id, - title: photo.title, - description: photo.caption, - link: absolutePathForPhoto({ photo }), - publicationDate: photo.createdAt, - media: { - content: { - url: getNextImageUrlForRequest({ - imageUrl: photo.url, - size: FEED_PHOTO_WIDTH_CONTENT, - }), - width: FEED_PHOTO_WIDTH_CONTENT, - height: Math.round(FEED_PHOTO_WIDTH_CONTENT / photo.aspectRatio), - }, - thumb: { - url: getNextImageUrlForRequest({ - imageUrl: photo.url, - size: FEED_PHOTO_WIDTH_THUMB, - }), - width: FEED_PHOTO_WIDTH_THUMB, - height: Math.round(FEED_PHOTO_WIDTH_THUMB / photo.aspectRatio), - }, + title: titleForPhoto(photo), + description: descriptionForPhoto(photo), +}); + +export const formatPhotoForFeedJson = (photo: Photo): PublicFeedPhotoJson => ({ + ...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 feedPhotoToXml = (photo: PublicFeedPhoto): string => { +export const formatPhotoForFeedRss = (photo: Photo): PublicFeedPhotoRss => ({ + ...getCoreFeedFields(photo), + link: absolutePathForPhoto({ photo }), + publicationDate: photo.createdAt, + media: { + content: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE), + thumb: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM), + }, +}); + +export const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { const formattedDate = formatDate({ date: photo.publicationDate, length: 'rss', }); - const description = photo.description ? - ` + return ` + ${photo.title} + ${photo.link} + ${formattedDate} + ${photo.link} + - ` : ''; - - return ` - ${photo.title} - ${photo.link} - ${formattedDate} - ${photo.link} - ${description} - - - - `; + + + + + `; }; From 534348b7a81d7f019d98827ad760143639785e31 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 16:53:32 -0500 Subject: [PATCH 07/13] Update imports --- app/rss.xml/route.ts | 4 ++-- src/app/feed.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index bffa4e6b..3499818a 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -6,7 +6,7 @@ import { META_TITLE, PUBLIC_FEED_ENABLED, } from '@/app/config'; -import { feedPhotoToXml, formatPhotoForFeed } from '@/app/feed'; +import { feedPhotoToXml, formatPhotoForFeedRss } from '@/app/feed'; export const dynamic = 'force-static'; @@ -16,7 +16,7 @@ export async function GET() { limit: INFINITE_SCROLL_FEED_INITIAL, sortBy: 'createdAt', }); - const items = photos.map(formatPhotoForFeed).map(feedPhotoToXml); + const items = photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); return new Response( ` diff --git a/src/app/feed.ts b/src/app/feed.ts index ba16e94f..7d4dc159 100644 --- a/src/app/feed.ts +++ b/src/app/feed.ts @@ -117,9 +117,9 @@ export const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { medium="image" width="${photo.media.content.width}" height="${photo.media.content.height}"> - + `; }; From 4dc914993103934f07c7cb1ac2dedda8a31153ad Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 18:54:50 -0500 Subject: [PATCH 08/13] Remove public api endpoint --- README.md | 3 +- app/api/route.ts | 24 ------------- app/feed.json/route.ts | 9 +++-- app/layout.tsx | 4 +-- app/rss.xml/route.ts | 11 +++--- src/admin/AdminAppConfigurationClient.tsx | 35 +++++++++--------- src/app/api.ts | 43 ----------------------- src/app/config.ts | 9 ++--- src/app/feed.ts | 2 +- src/app/paths.ts | 10 ++++++ 10 files changed, 44 insertions(+), 106 deletions(-) delete mode 100644 app/api/route.ts delete mode 100644 src/app/api.ts diff --git a/README.md b/README.md index 12645122..fff0f43d 100644 --- a/README.md +++ b/README.md @@ -158,8 +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_FEED = 1` enables public feed available at `/feed.json` and `/rss.xml` -- `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 index 3422017e..3078cdfd 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -1,18 +1,17 @@ import { getPhotosCached } from '@/photo/cache'; -import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; import { BASE_URL, - PUBLIC_FEED_ENABLED, + SITE_FEEDS_ENABLED, META_TITLE, } from '@/app/config'; -import { formatPhotoForFeedJson } from '@/app/feed'; +import { FEED_PHOTO_REQUEST_LIMIT, formatPhotoForFeedJson } from '@/app/feed'; export const dynamic = 'force-static'; export async function GET() { - if (PUBLIC_FEED_ENABLED) { + if (SITE_FEEDS_ENABLED) { const photos = await getPhotosCached({ - limit: INFINITE_SCROLL_FEED_INITIAL, + limit: FEED_PHOTO_REQUEST_LIMIT, sortBy: 'createdAt', }); return Response.json({ diff --git a/app/layout.tsx b/app/layout.tsx index dc8a19ae..3eeaa1e2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,7 +10,7 @@ import { META_TITLE, HTML_LANG, NAV_CAPTION, - PUBLIC_FEED_ENABLED, + SITE_FEEDS_ENABLED, } from '@/app/config'; import AppStateProvider from '@/state/AppStateProvider'; import ToasterWithThemes from '@/toast/ToasterWithThemes'; @@ -66,7 +66,7 @@ export const metadata: Metadata = { type: 'image/png', sizes: '180x180', }], - ...PUBLIC_FEED_ENABLED && { + ...SITE_FEEDS_ENABLED && { alternates: { types: { 'application/rss+xml': '/rss.xml', diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index 3499818a..79573fca 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -4,14 +4,15 @@ import { BASE_URL, META_DESCRIPTION, META_TITLE, - PUBLIC_FEED_ENABLED, + SITE_FEEDS_ENABLED, } from '@/app/config'; import { feedPhotoToXml, formatPhotoForFeedRss } from '@/app/feed'; +import { ABSOLUTE_PATH_FOR_FEED_JSON } from '@/app/paths'; export const dynamic = 'force-static'; export async function GET() { - if (PUBLIC_FEED_ENABLED) { + if (SITE_FEEDS_ENABLED) { const photos = await getPhotosCached({ limit: INFINITE_SCROLL_FEED_INITIAL, sortBy: 'createdAt', @@ -26,13 +27,11 @@ export async function GET() { ${META_TITLE} - ${BASE_URL} ${META_DESCRIPTION} - - ${items.join('\n\n ')} - + ${items.join('\n')} `, diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index 10b9fffe..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,8 +105,7 @@ export default function AdminAppConfigurationClient({ // Settings isGeoPrivacyEnabled, arePublicDownloadsEnabled, - isPublicApiEnabled, - isPublicFeedEnabled, + areSiteFeedsEnabled, isPriorityOrderEnabled, isOgTextBottomAligned, // Internal @@ -178,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 feed available at /feed.json - and /rss.xml: - {renderEnvVars(['NEXT_PUBLIC_PUBLIC_FEED'])} - - - 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 40f7b97e..fb7c6b2a 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -307,10 +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_FEED_ENABLED = - process.env.NEXT_PUBLIC_PUBLIC_FEED === '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 = @@ -419,8 +417,7 @@ export const APP_CONFIGURATION = { // Settings isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED, arePublicDownloadsEnabled: ALLOW_PUBLIC_DOWNLOADS, - isPublicApiEnabled: PUBLIC_API_ENABLED, - isPublicFeedEnabled: PUBLIC_FEED_ENABLED, + areSiteFeedsEnabled: SITE_FEEDS_ENABLED, isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED, isOgTextBottomAligned: OG_TEXT_BOTTOM_ALIGNMENT, // Internal diff --git a/src/app/feed.ts b/src/app/feed.ts index 7d4dc159..12d4eab5 100644 --- a/src/app/feed.ts +++ b/src/app/feed.ts @@ -6,7 +6,7 @@ import { } from '@/platforms/next-image'; import { formatDate, formatDateFromPostgresString } from '@/utility/date'; -export const API_PHOTO_REQUEST_LIMIT = 40; +export const FEED_PHOTO_REQUEST_LIMIT = 40; export const FEED_PHOTO_WIDTH_SMALL = 200; export const FEED_PHOTO_WIDTH_MEDIUM = 640; 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`; From 099fcdec8bd17e09b8f74f9d62155416bcb4e2e3 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 19:04:42 -0500 Subject: [PATCH 09/13] Refine feed formatting --- app/rss.xml/route.ts | 48 +++++++++++++++++++-------------------- src/app/feed.ts | 54 +++++++++++++++++++------------------------- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index 79573fca..100065d3 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -1,4 +1,3 @@ -import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo'; import { getPhotosCached } from '@/photo/cache'; import { BASE_URL, @@ -6,40 +5,39 @@ import { META_TITLE, SITE_FEEDS_ENABLED, } from '@/app/config'; -import { feedPhotoToXml, formatPhotoForFeedRss } from '@/app/feed'; -import { ABSOLUTE_PATH_FOR_FEED_JSON } from '@/app/paths'; +import { createRssItems, FEED_PHOTO_REQUEST_LIMIT } from '@/app/feed'; +import { ABSOLUTE_PATH_FOR_RSS_XML } from '@/app/paths'; export const dynamic = 'force-static'; export async function GET() { if (SITE_FEEDS_ENABLED) { const photos = await getPhotosCached({ - limit: INFINITE_SCROLL_FEED_INITIAL, + limit: FEED_PHOTO_REQUEST_LIMIT, sortBy: 'createdAt', }); - const items = photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); - return new Response( - ` - + const items = createRssItems(photos); - - ${META_TITLE} - - ${BASE_URL} - ${META_DESCRIPTION} - ${items.join('\n')} - - - `, - { - headers: { - 'Content-Type': 'text/xml', - }, - }, + return new Response(` + + + + ${META_TITLE} + + ${BASE_URL} + ${META_DESCRIPTION} + ${items.join('\n')} + + + `, + { headers: { 'Content-Type': 'text/xml' } }, ); } else { return new Response('RSS feed access disabled', { status: 404 }); diff --git a/src/app/feed.ts b/src/app/feed.ts index 12d4eab5..cf9ddd9f 100644 --- a/src/app/feed.ts +++ b/src/app/feed.ts @@ -12,22 +12,6 @@ export const FEED_PHOTO_WIDTH_SMALL = 200; export const FEED_PHOTO_WIDTH_MEDIUM = 640; export const FEED_PHOTO_WIDTH_LARGE = 1200; -export interface PublicFeedJson { - meta: { - title: string - url: string - } - photos: PublicFeedPhotoJson[] -} - -export interface PublicFeedRss { - meta: { - title: string - url: string - } - photos: PublicFeedPhotoRss[] -} - interface PublicFeedMedia { url: string width: number @@ -42,10 +26,7 @@ interface PublicFeedPhotoJson { model?: string tags?: string[] takenAtNaive: string - src: Record< - 'small' | 'medium' | 'large', - PublicFeedMedia - > + src: Record<'small' | 'medium' | 'large', PublicFeedMedia> } interface PublicFeedPhotoRss { @@ -54,10 +35,15 @@ interface PublicFeedPhotoRss { description?: string link: string publicationDate: Date - media: Record< - 'content' | 'thumb', - PublicFeedMedia - > + media: Record<'content' | 'thumb', PublicFeedMedia> +} + +export interface PublicFeedJson { + meta: { + title: string + url: string + } + photos: PublicFeedPhotoJson[] } const generateFeedMedia = ( @@ -89,7 +75,7 @@ export const formatPhotoForFeedJson = (photo: Photo): PublicFeedPhotoJson => ({ }, }); -export const formatPhotoForFeedRss = (photo: Photo): PublicFeedPhotoRss => ({ +const formatPhotoForFeedRss = (photo: Photo): PublicFeedPhotoRss => ({ ...getCoreFeedFields(photo), link: absolutePathForPhoto({ photo }), publicationDate: photo.createdAt, @@ -99,7 +85,7 @@ export const formatPhotoForFeedRss = (photo: Photo): PublicFeedPhotoRss => ({ }, }); -export const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { +const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { const formattedDate = formatDate({ date: photo.publicationDate, length: 'rss', @@ -112,14 +98,20 @@ export const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { - - + height="${photo.media.content.height}" + > + `; }; + +export const createRssItems = (photos: Photo[]) => + photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); From 9eb04f6015dd39a33b5cc3b14c78726e4e04a3f3 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 20:35:38 -0500 Subject: [PATCH 10/13] Finalize feed behavior --- .vscode/settings.json | 1 + app/feed.json/route.ts | 3 ++- app/rss.xml/route.ts | 18 +++++++++------- src/app/feed.ts | 49 +++++++++++++++++++++--------------------- src/utility/string.ts | 8 +++++++ 5 files changed, 45 insertions(+), 34 deletions(-) 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/app/feed.json/route.ts b/app/feed.json/route.ts index 3078cdfd..19f83e2f 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -6,7 +6,8 @@ import { } from '@/app/config'; import { FEED_PHOTO_REQUEST_LIMIT, formatPhotoForFeedJson } from '@/app/feed'; -export const dynamic = 'force-static'; +// Cache for 24 hours +export const revalidate = 86_400; export async function GET() { if (SITE_FEEDS_ENABLED) { diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index 100065d3..4f67951e 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -8,7 +8,8 @@ import { import { createRssItems, FEED_PHOTO_REQUEST_LIMIT } from '@/app/feed'; import { ABSOLUTE_PATH_FOR_RSS_XML } from '@/app/paths'; -export const dynamic = 'force-static'; +// Cache for 24 hours +export const revalidate = 86_400; export async function GET() { if (SITE_FEEDS_ENABLED) { @@ -19,18 +20,19 @@ export async function GET() { const items = createRssItems(photos); - return new Response(` - - + ${META_TITLE} - + ${BASE_URL} ${META_DESCRIPTION} ${items.join('\n')} @@ -40,6 +42,6 @@ export async function GET() { { headers: { 'Content-Type': 'text/xml' } }, ); } else { - return new Response('RSS feed access disabled', { status: 404 }); + return new Response('Feed disabled', { status: 404 }); } } diff --git a/src/app/feed.ts b/src/app/feed.ts index cf9ddd9f..3395c7b0 100644 --- a/src/app/feed.ts +++ b/src/app/feed.ts @@ -5,6 +5,7 @@ import { NextImageSize, } from '@/platforms/next-image'; import { formatDate, formatDateFromPostgresString } from '@/utility/date'; +import { formatStringForXml } from '@/utility/string'; export const FEED_PHOTO_REQUEST_LIMIT = 40; @@ -12,44 +13,44 @@ export const FEED_PHOTO_WIDTH_SMALL = 200; export const FEED_PHOTO_WIDTH_MEDIUM = 640; export const FEED_PHOTO_WIDTH_LARGE = 1200; -interface PublicFeedMedia { +interface FeedMedia { url: string width: number height: number } -interface PublicFeedPhotoJson { +interface FeedPhotoJson { id: string - title?: string + title: string url: string make?: string model?: string tags?: string[] takenAtNaive: string - src: Record<'small' | 'medium' | 'large', PublicFeedMedia> + src: Record<'small' | 'medium' | 'large', FeedMedia> } -interface PublicFeedPhotoRss { +interface FeedPhotoRss { id: string - title?: string + title: string description?: string link: string - publicationDate: Date - media: Record<'content' | 'thumb', PublicFeedMedia> + pubDate: Date + media: Record<'content' | 'thumb', FeedMedia> } -export interface PublicFeedJson { +export interface FeedJson { meta: { title: string url: string } - photos: PublicFeedPhotoJson[] + photos: FeedPhotoJson[] } const generateFeedMedia = ( photo: Photo, size: NextImageSize, -): PublicFeedMedia => ({ +): FeedMedia => ({ url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), width: size, height: Math.round(size / photo.aspectRatio), @@ -58,10 +59,10 @@ const generateFeedMedia = ( const getCoreFeedFields = (photo: Photo) => ({ id: photo.id, title: titleForPhoto(photo), - description: descriptionForPhoto(photo), + description: descriptionForPhoto(photo, true), }); -export const formatPhotoForFeedJson = (photo: Photo): PublicFeedPhotoJson => ({ +export const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({ ...getCoreFeedFields(photo), url: absolutePathForPhoto({ photo }), ...photo.make && { make: photo.make }, @@ -75,37 +76,35 @@ export const formatPhotoForFeedJson = (photo: Photo): PublicFeedPhotoJson => ({ }, }); -const formatPhotoForFeedRss = (photo: Photo): PublicFeedPhotoRss => ({ +const formatPhotoForFeedRss = (photo: Photo): FeedPhotoRss => ({ ...getCoreFeedFields(photo), link: absolutePathForPhoto({ photo }), - publicationDate: photo.createdAt, + pubDate: photo.createdAt, media: { content: generateFeedMedia(photo, FEED_PHOTO_WIDTH_LARGE), thumb: generateFeedMedia(photo, FEED_PHOTO_WIDTH_MEDIUM), }, }); -const feedPhotoToXml = (photo: PublicFeedPhotoRss): string => { - const formattedDate = formatDate({ - date: photo.publicationDate, - length: 'rss', - }); +const feedPhotoToXml = (photo: FeedPhotoRss): string => { + const formattedDate = formatDate({ date: photo.pubDate, length: 'rss' }); return ` ${photo.title} ${photo.link} ${formattedDate} ${photo.link} - - - - ` + : ''} + 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('-', ' ')); From 4d904517a58f906c6dc6b3e0fda35b934de9f88d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 21:07:49 -0500 Subject: [PATCH 11/13] Create feed module --- app/feed.json/route.ts | 3 +- app/rss.xml/route.ts | 3 +- src/app/feed.ts | 116 ----------------------------------------- src/feed/index.ts | 32 ++++++++++++ src/feed/json.ts | 36 +++++++++++++ src/feed/rss.ts | 59 +++++++++++++++++++++ 6 files changed, 131 insertions(+), 118 deletions(-) delete mode 100644 src/app/feed.ts create mode 100644 src/feed/index.ts create mode 100644 src/feed/json.ts create mode 100644 src/feed/rss.ts diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts index 19f83e2f..156ddb42 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -4,7 +4,8 @@ import { SITE_FEEDS_ENABLED, META_TITLE, } from '@/app/config'; -import { FEED_PHOTO_REQUEST_LIMIT, formatPhotoForFeedJson } from '@/app/feed'; +import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; +import { formatPhotoForFeedJson } from '@/feed/json'; // Cache for 24 hours export const revalidate = 86_400; diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index 4f67951e..25a0a952 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -5,7 +5,8 @@ import { META_TITLE, SITE_FEEDS_ENABLED, } from '@/app/config'; -import { createRssItems, FEED_PHOTO_REQUEST_LIMIT } from '@/app/feed'; +import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; +import { createRssItems } from '@/feed/rss'; import { ABSOLUTE_PATH_FOR_RSS_XML } from '@/app/paths'; // Cache for 24 hours diff --git a/src/app/feed.ts b/src/app/feed.ts deleted file mode 100644 index 3395c7b0..00000000 --- a/src/app/feed.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo'; -import { absolutePathForPhoto } from './paths'; -import { - getNextImageUrlForRequest, - NextImageSize, -} from '@/platforms/next-image'; -import { formatDate, formatDateFromPostgresString } from '@/utility/date'; -import { formatStringForXml } from '@/utility/string'; - -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; - -interface FeedMedia { - url: string - width: number - height: number -} - -interface FeedPhotoJson { - id: string - title: string - url: string - make?: string - model?: string - tags?: string[] - takenAtNaive: string - src: Record<'small' | 'medium' | 'large', FeedMedia> -} - -interface FeedPhotoRss { - id: string - title: string - description?: string - link: string - pubDate: Date - media: Record<'content' | 'thumb', FeedMedia> -} - -export interface FeedJson { - meta: { - title: string - url: string - } - photos: FeedPhotoJson[] -} - -const generateFeedMedia = ( - photo: Photo, - size: NextImageSize, -): FeedMedia => ({ - url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), - width: size, - height: Math.round(size / photo.aspectRatio), -}); - -const getCoreFeedFields = (photo: Photo) => ({ - id: photo.id, - title: titleForPhoto(photo), - description: descriptionForPhoto(photo, true), -}); - -export 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), - }, -}); - -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 => { - const formattedDate = formatDate({ date: photo.pubDate, length: 'rss' }); - return ` - ${photo.title} - ${photo.link} - ${formattedDate} - ${photo.link} - ${photo.description - ? `` - : ''} - - - - `; -}; - -export const createRssItems = (photos: Photo[]) => - photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); 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..9a1fe608 --- /dev/null +++ b/src/feed/json.ts @@ -0,0 +1,36 @@ +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'; + +interface FeedPhotoJson { + id: string + title: string + url: string + make?: string + model?: string + tags?: string[] + takenAtNaive: string + src: Record<'small' | 'medium' | 'large', FeedMedia> +} + +export 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), + }, +}); diff --git a/src/feed/rss.ts b/src/feed/rss.ts new file mode 100644 index 00000000..e34e9d1e --- /dev/null +++ b/src/feed/rss.ts @@ -0,0 +1,59 @@ +import { Photo } from '@/photo'; +import { + FEED_PHOTO_WIDTH_LARGE, + FEED_PHOTO_WIDTH_MEDIUM, + FeedMedia, + generateFeedMedia, + getCoreFeedFields, +} from '.'; +import { absolutePathForPhoto } from '@/app/paths'; +import { formatDate } from '@/utility/date'; +import { formatStringForXml } from '@/utility/string'; + +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 => { + const formattedDate = formatDate({ date: photo.pubDate, length: 'rss' }); + return ` + ${photo.title} + ${photo.link} + ${formattedDate} + ${photo.link} + ${photo.description + ? `` + : ''} + + + + `; +}; + +export const createRssItems = (photos: Photo[]) => + photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); From bdfc122beb31be761c05ae5d1623d35559195dfb Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 21:16:26 -0500 Subject: [PATCH 12/13] Refactor json/xml code --- app/feed.json/route.ts | 16 +++------------- app/rss.xml/route.ts | 35 +++++------------------------------ src/feed/json.ts | 12 +++++++++++- src/feed/rss.ts | 29 ++++++++++++++++++++++++----- 4 files changed, 43 insertions(+), 49 deletions(-) diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts index 156ddb42..ccf72a08 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -1,11 +1,7 @@ import { getPhotosCached } from '@/photo/cache'; -import { - BASE_URL, - SITE_FEEDS_ENABLED, - META_TITLE, -} from '@/app/config'; +import { SITE_FEEDS_ENABLED } from '@/app/config'; import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; -import { formatPhotoForFeedJson } from '@/feed/json'; +import { formatFeedJson } from '@/feed/json'; // Cache for 24 hours export const revalidate = 86_400; @@ -16,13 +12,7 @@ export async function GET() { limit: FEED_PHOTO_REQUEST_LIMIT, sortBy: 'createdAt', }); - return Response.json({ - meta: { - title: META_TITLE, - url: BASE_URL, - }, - photos: photos.map(formatPhotoForFeedJson), - }); + return Response.json(formatFeedJson(photos)); } else { return new Response('Feed disabled', { status: 404 }); } diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index 25a0a952..d559818a 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -1,13 +1,7 @@ import { getPhotosCached } from '@/photo/cache'; -import { - BASE_URL, - META_DESCRIPTION, - META_TITLE, - SITE_FEEDS_ENABLED, -} from '@/app/config'; +import { SITE_FEEDS_ENABLED } from '@/app/config'; import { FEED_PHOTO_REQUEST_LIMIT } from '@/feed'; -import { createRssItems } from '@/feed/rss'; -import { ABSOLUTE_PATH_FOR_RSS_XML } from '@/app/paths'; +import { formatFeedRss } from '@/feed/rss'; // Cache for 24 hours export const revalidate = 86_400; @@ -19,28 +13,9 @@ export async function GET() { sortBy: 'createdAt', }); - const items = createRssItems(photos); - - return new Response(` - - - ${META_TITLE} - - ${BASE_URL} - ${META_DESCRIPTION} - ${items.join('\n')} - - - `, - { headers: { 'Content-Type': 'text/xml' } }, + return new Response( + formatFeedRss(photos), + { headers: { 'Content-Type': 'text/xml' } }, ); } else { return new Response('Feed disabled', { status: 404 }); diff --git a/src/feed/json.ts b/src/feed/json.ts index 9a1fe608..750442f9 100644 --- a/src/feed/json.ts +++ b/src/feed/json.ts @@ -9,6 +9,8 @@ import { } 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 @@ -21,7 +23,7 @@ interface FeedPhotoJson { src: Record<'small' | 'medium' | 'large', FeedMedia> } -export const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({ +const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({ ...getCoreFeedFields(photo), url: absolutePathForPhoto({ photo }), ...photo.make && { make: photo.make }, @@ -34,3 +36,11 @@ export const formatPhotoForFeedJson = (photo: Photo): FeedPhotoJson => ({ 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 index e34e9d1e..56e53e42 100644 --- a/src/feed/rss.ts +++ b/src/feed/rss.ts @@ -6,9 +6,10 @@ import { generateFeedMedia, getCoreFeedFields, } from '.'; -import { absolutePathForPhoto } from '@/app/paths'; +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 @@ -30,11 +31,12 @@ const formatPhotoForFeedRss = (photo: Photo): FeedPhotoRss => ({ }); const feedPhotoToXml = (photo: FeedPhotoRss): string => { - const formattedDate = formatDate({ date: photo.pubDate, length: 'rss' }); return ` ${photo.title} ${photo.link} - ${formattedDate} + + ${formatDate({ date: photo.pubDate, length: 'rss' })} + ${photo.link} ${photo.description ? `` @@ -55,5 +57,22 @@ const feedPhotoToXml = (photo: FeedPhotoRss): string => { `; }; -export const createRssItems = (photos: Photo[]) => - photos.map(formatPhotoForFeedRss).map(feedPhotoToXml); +export const formatFeedRss = (photos: Photo[]) => + ` + + + ${META_TITLE} + + ${BASE_URL} + ${META_DESCRIPTION} + ${photos.map(formatPhotoForFeedRss).map(feedPhotoToXml).join('\n')} + + `; From 8b2d9d947ed5ef8828fa1fb4756fd99fe1d2a0fb Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 12 Jun 2025 21:34:06 -0500 Subject: [PATCH 13/13] Update feeds error language --- app/feed.json/route.ts | 2 +- app/rss.xml/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts index ccf72a08..0c4eb1ea 100644 --- a/app/feed.json/route.ts +++ b/app/feed.json/route.ts @@ -14,6 +14,6 @@ export async function GET() { }); return Response.json(formatFeedJson(photos)); } else { - return new Response('Feed disabled', { status: 404 }); + return new Response('Feeds disabled', { status: 404 }); } } diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index d559818a..fb2e862a 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -18,6 +18,6 @@ export async function GET() { { headers: { 'Content-Type': 'text/xml' } }, ); } else { - return new Response('Feed disabled', { status: 404 }); + return new Response('Feeds disabled', { status: 404 }); } }