Skip to content
2 changes: 1 addition & 1 deletion frontend/.env.local.example
Original file line number Diff line number Diff line change
@@ -1 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_API_URL=http://localhost:8000/api
19 changes: 19 additions & 0 deletions frontend/src/app/news/[id]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Link from "next/link";

export default function NewsNotFound() {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-3">Article Not Found</h1>
<p className="text-gray-600 mb-6">
The article you are looking for may have been removed or is not
available yet.
</p>
<Link
href="/news"
className="inline-block px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
>
Back to All News
</Link>
</div>
);
}
10 changes: 6 additions & 4 deletions frontend/src/app/news/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getNewsById } from "@/lib/api";
import { format } from "date-fns";
import Image from "next/image";
import { notFound } from "next/navigation";

export default async function NewsDetailPage({
Expand Down Expand Up @@ -29,15 +30,16 @@ export default async function NewsDetailPage({
)}
</div>
{article.image_url && (
<div className="relative w-full h-64 mb-4">
<img
<div className="relative w-full h-80 mb-4">
<Image
src={article.image_url}
alt={article.title}
className="object-cover w-full h-full rounded-md"
fill
className="object-cover rounded-md"
/>
</div>
)}
<div className="prose max-w-none">
<div className="prose max-w-none whitespace-pre-line">
<p>{article.body}</p>
</div>
</div>
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/app/news/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Card } from "@/components/ui/card";
import type { NewsArticle } from "@/lib/api";
import { getNews } from "@/lib/api";
import type { NewsArticle } from "@/lib/mock/news";
import { format } from "date-fns";
import Image from "next/image";
import Link from "next/link";
Expand All @@ -16,8 +16,9 @@ export default async function NewsPage({
Array.isArray(pageParam) ? pageParam[0] : pageParam || "1",
10,
);
const safePage = Number.isFinite(page) && page > 0 ? page : 1;
const pageSize = 10;
const newsData: NewsArticle[] = await getNews(page, pageSize);
const newsData: NewsArticle[] = await getNews(safePage, pageSize);
const hasNextPage = newsData.length === pageSize;

return (
Expand Down Expand Up @@ -47,9 +48,9 @@ export default async function NewsPage({
</div>

<div className="flex justify-between items-center mt-8">
{page > 1 ? (
{safePage > 1 ? (
<Link
href={`/news?page=${page - 1}`}
href={`/news?page=${safePage - 1}`}
className="px-4 py-2 bg-gray-100 rounded-md hover:bg-gray-200"
>
Previous
Expand All @@ -60,11 +61,11 @@ export default async function NewsPage({
</div>
)}

<span className="font-medium">Page {page}</span>
<span className="font-medium">Page {safePage}</span>

{hasNextPage ? (
<Link
href={`/news?page=${page + 1}`}
href={`/news?page=${safePage + 1}`}
className="px-4 py-2 bg-gray-100 rounded-md hover:bg-gray-200"
>
Next
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/home/RecentNews.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Card } from "@/components/ui/card";
import type { NewsArticle } from "@/lib/api";
import { getNews } from "@/lib/api";
import Link from "next/link";
import Image from "next/image";
import { format } from "date-fns";
import Image from "next/image";
import Link from "next/link";

export default async function RecentNews() {
const newsData = await getNews(1, 3);
Expand All @@ -11,7 +12,7 @@ export default async function RecentNews() {
<div className="recent-news">
<h2 className="text-2xl font-bold mb-4">Recent News</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{newsData.map((article: any) => (
{newsData.map((article: NewsArticle) => (
<Link href={`/news/${article.id}`} key={article.id}>
<Card className="p-4 h-full transition-shadow hover:shadow-lg flex flex-col">
<div className="relative w-full h-48 mb-4">
Expand Down Expand Up @@ -43,4 +44,4 @@ export default async function RecentNews() {
</div>
</div>
);
}
}
109 changes: 96 additions & 13 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,119 @@
*/

export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";

export async function fetchAPI(endpoint: string, options?: RequestInit) {
class ApiError extends Error {
status: number;

constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}

interface NewsApiArticle {
id: number | string;
title: string;
summary?: string;
description?: string;
body: string;
author_name?: string | null;
author?: string | null;
image_url: string | null;
date_published: string;
date_last_edited?: string | null;
date_edited?: string | null;
}

interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
}

export interface NewsArticle {
id: string;
title: string;
description: string;
body: string;
author: string | null;
image_url: string | null;
date_published: string;
date_edited: string | null;
}

function mapNewsArticle(article: NewsApiArticle): NewsArticle {
return {
id: String(article.id),
title: article.title,
description: article.summary ?? article.description ?? "",
body: article.body,
author: article.author_name ?? article.author ?? null,
image_url: article.image_url,
date_published: article.date_published,
date_edited: article.date_last_edited ?? article.date_edited ?? null,
};
}

function sortMostRecentFirst(articles: NewsArticle[]): NewsArticle[] {
return [...articles].sort(
(a, b) =>
new Date(b.date_published).getTime() -
new Date(a.date_published).getTime(),
);
}

export async function fetchAPI<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, options);

if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
throw new ApiError(response.status, `API error: ${response.statusText}`);
}

return response.json();
return response.json() as Promise<T>;
}

export async function getNews(page: number = 1, limit: number = 10) {
export async function getNews(
page: number = 1,
limit: number = 10,
): Promise<NewsArticle[]> {
try {
const data = await fetchAPI(`/news?page=${page}&limit=${limit}`);
return data;
} catch {
const data = await fetchAPI<PaginatedResponse<NewsApiArticle>>(
`/news?page=${page}&limit=${limit}`,
);
return sortMostRecentFirst(data.items.map(mapNewsArticle));
} catch (error) {
if (error instanceof ApiError && error.status < 500) {
throw error;
}

console.warn("[API] Backend unavailable — using mock news data");
const { mockNews } = await import("@/lib/mock/news");
const start = (page - 1) * limit;
return mockNews.slice(start, start + limit);
return sortMostRecentFirst(mockNews).slice(start, start + limit);
}
}

export async function getNewsById(id: string) {
export async function getNewsById(id: string): Promise<NewsArticle | null> {
try {
const data = await fetchAPI(`/news/${id}`);
return data;
} catch {
const data = await fetchAPI<NewsApiArticle>(`/news/${id}`);
return mapNewsArticle(data);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}

if (error instanceof ApiError && error.status < 500) {
throw error;
}

console.warn("[API] Backend unavailable — using mock news article");
const { mockNews } = await import("@/lib/mock/news");
return mockNews.find((article) => article.id === id) ?? null;
Expand Down