Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/app/api/news/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
18 changes: 11 additions & 7 deletions src/components/NewsList/NewsClient.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getData>>[];
Expand All @@ -31,7 +32,10 @@ const NewsClient = ({ news, canPost, locale }: NewsClientProps) => {
return (
<>
<div className={styles.title}>
<h1>{l.news.title}</h1>
<div className={styles.heading}>
<h1>{l.news.title}</h1>
<RssFeedButton locale={locale} />
</div>
<div className={styles.actions}>
<ViewToggle
locale={locale}
Expand Down
6 changes: 6 additions & 0 deletions src/components/NewsList/NewsList.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
gap: 1rem;
}

.heading {
display: flex;
align-items: center;
gap: 0.5rem;
}

.actions {
display: flex;
align-items: center;
Expand Down
55 changes: 55 additions & 0 deletions src/components/NewsList/RssFeedButton.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.button {
background-color: transparent;
border: 0;
display: flex;
align-items: center;
justify-content: center;
height: 2rem;
width: 2rem;
padding: 0.5rem;
margin-top: 0.25rem;
border-radius: 0.25rem;
color: inherit;
cursor: pointer;
transition: opacity 0.2s ease;
position: relative;

&:hover {
opacity: 0.8;
}

&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}

svg {
width: 100%;
height: 100%;
display: block;
}
}

// Tooltip inspired by GitHub, themed
.tooltip {
position: absolute;
top: 50%;
left: calc(100% + 8px);
transform: translateY(-50%);
background: var(--background-color, #24292f);
color: var(--news-view-toggle-selected-color, #fff);
padding: 6px 12px;
border-radius: 6px;
font-size: 0.85rem;
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
opacity: 0;
pointer-events: none;
user-select: none;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 10;
}

.tooltipVisible {
opacity: 1;
}
52 changes: 52 additions & 0 deletions src/components/NewsList/RssFeedButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';
import i18nService from '@/services/i18nService';
import { useRef, useState } from 'react';
import { FaRss } from 'react-icons/fa';
import styles from './RssFeedButton.module.scss';

interface RssFeedButtonProps {
locale: string;
}

export default function RssFeedButton({ locale }: RssFeedButtonProps) {
const l = i18nService.getLocale(locale);
const rssUrl = `/api/news?format=rss&locale=${locale}`;

const [tooltipVisible, setTooltipVisible] = useState(false);
const tooltipTimeout = useRef<NodeJS.Timeout | null>(null);

const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
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 (
<button
type="button"
aria-label={l.news.subscribe}
className={styles.button}
title={l.news.subscribe}
onClick={handleClick}
style={{ position: 'relative' }}
>
<FaRss />
<span
className={
styles.tooltip + (tooltipVisible ? ' ' + styles.tooltipVisible : '')
}
role="status"
aria-live="polite"
>
{l.editor.linkCopied}
</span>
</button>
);
}
1 change: 1 addition & 0 deletions src/dictionaries/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
1 change: 1 addition & 0 deletions src/dictionaries/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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!",
Expand Down
78 changes: 78 additions & 0 deletions src/services/rssService.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

/**
* 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 ` <item>
<title>${this.escapeXml(title)}</title>
<description>${this.escapeXml(content)}</description>
<link>${link}</link>
<pubDate>${pubDate}</pubDate>
<guid>${item.id}</guid>
${category ? `<category>${this.escapeXml(category)}</category>` : ''}
</item>`;
})
.join('\n');

// Build the complete RSS feed
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${this.escapeXml(i18n.news.title)}</title>
<link>${baseUrlWithoutSlash}</link>
<description>${this.escapeXml(i18n.news.title)}</description>
<language>${langTag}</language>
${itemsXml}
</channel>
</rss>`;

return rss;
}
}
Loading