diff --git a/src/app/api/news/route.ts b/src/app/api/news/route.ts index 1121bb40..7c0eb7e6 100644 --- a/src/app/api/news/route.ts +++ b/src/app/api/news/route.ts @@ -1,5 +1,6 @@ import ApiService from '@/services/apiService'; import NewsService from '@/services/newsService'; +import RssFeedService from '@/services/rssService'; import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; @@ -27,5 +28,20 @@ export async function GET( ); const news = await NewsService.getPage(page, pageSize); + + const responseFormat = search.get('format'); + if (responseFormat === 'rss') { + const locale = search.get('locale') ?? 'sv'; + const rssXml = RssFeedService.generateFeed(news, locale); + + return new NextResponse(rssXml, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Content-Disposition': 'inline; filename="news.xml"' + } + }); + } + return NextResponse.json(news); } diff --git a/src/components/NewsList/NewsClient.tsx b/src/components/NewsList/NewsClient.tsx index 678a4f39..d47f3e84 100644 --- a/src/components/NewsList/NewsClient.tsx +++ b/src/components/NewsList/NewsClient.tsx @@ -1,16 +1,17 @@ 'use client'; +import { getData } from '@/actions/newsList'; +import i18nService from '@/services/i18nService'; import React, { useState } from 'react'; -import NewsPost from './NewsPost/NewsPost'; -import NewsCard from './NewsCard/NewsCard'; +import ActionLink from '../ActionButton/ActionLink'; import Divider from '../Divider/Divider'; import InfiniteScroller from './InfiniteScroller'; -import ViewToggle from './ViewToggle'; -import ActionLink from '../ActionButton/ActionLink'; -import { getData } from '@/actions/newsList'; -import i18nService from '@/services/i18nService'; +import NewsCard from './NewsCard/NewsCard'; import styles from './NewsList.module.scss'; import clientStyles from './NewsListClient.module.scss'; +import NewsPost from './NewsPost/NewsPost'; +import RssFeedButton from './RssFeedButton'; +import ViewToggle from './ViewToggle'; interface NewsClientProps { news: Awaited>[]; @@ -31,7 +32,10 @@ const NewsClient = ({ news, canPost, locale }: NewsClientProps) => { return ( <>
-

{l.news.title}

+
+

{l.news.title}

+ +
(null); + + const handleClick = async (e: React.MouseEvent) => { + e.preventDefault(); + try { + await navigator.clipboard.writeText(window.location.origin + rssUrl); + setTooltipVisible(true); + if (tooltipTimeout.current) clearTimeout(tooltipTimeout.current); + tooltipTimeout.current = setTimeout(() => setTooltipVisible(false), 1500); + } catch { + // Fallback: open in new tab + window.open(rssUrl, '_blank', 'noopener,noreferrer'); + } + }; + + return ( + + ); +} diff --git a/src/dictionaries/en.json b/src/dictionaries/en.json index abdc1093..33f49ded 100644 --- a/src/dictionaries/en.json +++ b/src/dictionaries/en.json @@ -40,6 +40,7 @@ "for": "for", "by": "by", "unknown": "unknown author", + "subscribe": "Subscribe to news feed", "confirmDelete": "Are you sure you want to delete this post? This action cannot be undone.", "deleting": "Deleting...", "deleted": "Deleted!", diff --git a/src/dictionaries/sv.json b/src/dictionaries/sv.json index d691c516..41690486 100644 --- a/src/dictionaries/sv.json +++ b/src/dictionaries/sv.json @@ -40,6 +40,7 @@ "for": "för", "by": "av", "unknown": "okänd användare", + "subscribe": "Prenumerera på nyheter", "confirmDelete": "Vill du radera nyheten? Detta går inte att ångra!", "deleting": "Raderar...", "deleted": "Raderad!", diff --git a/src/services/rssService.ts b/src/services/rssService.ts new file mode 100644 index 00000000..c4afd31d --- /dev/null +++ b/src/services/rssService.ts @@ -0,0 +1,78 @@ +import i18nService from './i18nService'; + +interface NewsItem { + id: number; + titleEn: string; + titleSv: string; + contentEn: string; + contentSv: string; + createdAt: Date | string; + writtenFor: { + prettyName: string; + } | null; +} + +export default class RssFeedService { + /** + * Escapes XML special characters + */ + private static escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Generates an RSS 2.0 feed for the given news items + * @param newsItems Array of news posts + * @param locale 'sv' or 'en' + * @param baseUrl Base URL for the site (e.g., https://chalmers.it) + * @returns RSS 2.0 XML string + */ + static generateFeed(newsItems: NewsItem[], locale: string = 'sv'): string { + const i18n = i18nService.getLocale(locale); + const isSv = locale === 'sv'; + const langTag = isSv ? 'sv-SE' : 'en-US'; + const baseUrl = process.env.BASE_URL || 'https://chalmers.it'; + const baseUrlWithoutSlash = baseUrl.endsWith('/') + ? baseUrl.slice(0, -1) + : baseUrl; + + // Build the items XML + const itemsXml = newsItems + .map((item) => { + const title = isSv ? item.titleSv : item.titleEn; + const content = isSv ? item.contentSv : item.contentEn; + const pubDate = new Date(item.createdAt).toUTCString(); + const link = `${baseUrlWithoutSlash}/post/${item.id}`; + const category = item.writtenFor?.prettyName || ''; + + return ` + ${this.escapeXml(title)} + ${this.escapeXml(content)} + ${link} + ${pubDate} + ${item.id} + ${category ? `${this.escapeXml(category)}` : ''} + `; + }) + .join('\n'); + + // Build the complete RSS feed + const rss = ` + + + ${this.escapeXml(i18n.news.title)} + ${baseUrlWithoutSlash} + ${this.escapeXml(i18n.news.title)} + ${langTag} +${itemsXml} + +`; + + return rss; + } +}