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
2 changes: 2 additions & 0 deletions client/cyber_lens/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Footer from "./components/Footer";
import Home from "./pages/Home";
import History from "./pages/History";
import News from "./pages/News";
import NewsDetail from "./pages/NewsDetail";

import Login from "./pages/Login";
import Signup from "./pages/Signup";
Expand All @@ -36,6 +37,7 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/news" element={<News />} />
<Route path="/news/:id" element={<NewsDetail />} />
</Route>

<Route path="/login" element={<Login />} />
Expand Down
149 changes: 93 additions & 56 deletions client/cyber_lens/src/pages/News.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,70 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { httpJson } from "../utils/httpClient";

type NewsItem = {
title: string;
summary: string;
category?: "Threat" | "Vulnerability" | "Advisory";
source: string;
published_at: string;
id: string;
};

type PaginatedResponse<T> = {
items: T[];
total: number;
};

export default function News() {
const ITEMS_PER_PAGE = 9;

const [currentPage, setCurrentPage] = useState(1);
const [query, setQuery] = useState("");
// const [_, setLoading] = useState(false);

const newsItems: NewsItem[] = [
{
title: "New Ransomware Campaign Targets Indian Enterprises",
summary:
"Security researchers report a surge in ransomware operations targeting mid-size Indian enterprises through phishing and exposed services.",
category: "Threat",
},
{
title: "Zero-Day Vulnerability Found in Popular Web Framework",
summary:
"A critical zero-day vulnerability allows remote code execution. Active exploitation has been observed in the wild.",
category: "Vulnerability",
},
{
title: "Threat Actors Exploit Misconfigured Cloud Buckets",
summary:
"Public cloud storage misconfigurations continue to leak sensitive data at scale across industries.",
category: "Advisory",
},
];

const categoryStyle = (cat?: string) =>
cat === "Threat"
? "bg-red-600/10 text-red-400 ring-red-600/30"
: cat === "Vulnerability"
? "bg-amber-500/10 text-amber-400 ring-amber-500/30"
: "bg-cyan-500/10 text-cyan-400 ring-cyan-500/30";

const filteredNews = newsItems.filter(
(item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.summary.toLowerCase().includes(query.toLowerCase())
);
const [rows, setRows] = useState<NewsItem[]>([]);

useEffect(() => {
async function fetchNews() {
try {
const { items } = await httpJson<PaginatedResponse<NewsItem>>("/news");

console.log("data", items);

const mapped: NewsItem[] = items.map((item) => ({
...item,
published_at: item.published_at.split("T")[0],
}));

setRows(mapped);
} catch (err) {
console.error(err);
}
// finally {
// setLoading(false);
// }
}
fetchNews();
}, []);

useEffect(() => {
setCurrentPage(1);
}, [query]);

const filteredNews = useMemo(() => {
return rows.filter(
(item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.summary.toLowerCase().includes(query.toLowerCase()),
);
}, [rows, query]);

const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);

const paginatedNews = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
return filteredNews.slice(start, end);
}, [filteredNews, currentPage]);

return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 px-4 py-12">
Expand Down Expand Up @@ -78,35 +101,22 @@ export default function News() {

{/* News Grid */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredNews.map((item, idx) => (
{paginatedNews.map((item, idx) => (
<article
key={idx}
className="relative flex flex-col border border-neutral-800 bg-neutral-900 hover:bg-neutral-800/70 transition-colors"
>
{/* Severity bar */}
<div className="h-1 w-full bg-neutral-800">
<div
className={`h-full ${
item.category === "Threat"
? "bg-red-500"
: item.category === "Vulnerability"
? "bg-amber-500"
: "bg-cyan-500"
}`}
/>
</div>

<div className="flex flex-col flex-1 p-4">
{/* Meta */}
<div className="mb-3 flex items-center justify-between">
<span
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-medium ring-1 ${categoryStyle(
item.category
)}`}
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-medium ring-1`}
>
{item.category}
{item.source}
</span>
<span className="text-xs text-neutral-500">
{item.published_at}
</span>
<span className="text-xs text-neutral-500">Intelligence</span>
</div>

{/* Title */}
Expand All @@ -120,9 +130,12 @@ export default function News() {
</p>

{/* Action */}
<div className="mt-auto pt-4 text-sm font-medium text-cyan-400 hover:underline cursor-pointer">
<a
href={`/news/${item.id}`}
className="mt-auto pt-4 text-sm font-medium text-cyan-400 hover:underline cursor-pointer block"
>
View details →
</div>
</a>
</div>
</article>
))}
Expand All @@ -134,6 +147,30 @@ export default function News() {
)}
</section>

{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-3">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage((p) => p - 1)}
className="px-3 py-1 text-sm border border-neutral-700 disabled:opacity-40"
>
Prev
</button>

<span className="text-sm text-neutral-400">
Page {currentPage} of {totalPages}
</span>

<button
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
className="px-3 py-1 text-sm border border-neutral-700 disabled:opacity-40"
>
Next
</button>
</div>
)}

{/* Footer */}
<div className="mt-8 text-xs text-neutral-500">
All updates follow a unified bulletin format for consistent analyst
Expand Down
132 changes: 132 additions & 0 deletions client/cyber_lens/src/pages/NewsDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { httpJson } from "../utils/httpClient";

type NewsDetail = {
id: string;
title: string;
summary: string | null;
published_at: string | null;
source: string;
url: string;
iocs: { type: string; value: string }[];
};

export default function NewsDetail() {
const { id } = useParams<{ id: string }>();
const [article, setArticle] = useState<NewsDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
async function fetchDetail() {
if (!id) return;
try {
setLoading(true);
const data = await httpJson<NewsDetail>(`/news/${id}`);
setArticle({
...data,
published_at: data.published_at ? data.published_at.split("T")[0] : null
});
} catch (err) {
console.error(err);
setError("Failed to load article details.");
} finally {
setLoading(false);
}
}
fetchDetail();
}, [id]);

if (loading) {
return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 flex items-center justify-center">
<p className="text-neutral-500">Loading article...</p>
</div>
);
}

if (error || !article) {
return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 px-4 py-12">
<div className="max-w-4xl mx-auto">
<Link to="/news" className="text-cyan-400 hover:text-cyan-300 mb-6 inline-block">
← Back to News
</Link>
<div className="p-4 border border-red-900/50 bg-red-900/10 rounded text-red-200">
{error || "Article not found."}
</div>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 px-4 py-12">
<div className="max-w-4xl mx-auto">
<Link to="/news" className="text-cyan-400 hover:text-cyan-300 mb-6 inline-block text-sm font-medium transition-colors">
← Back to News
</Link>

<article className="bg-neutral-900 border border-neutral-800 rounded-lg p-6 sm:p-10 shadow-xl">
{/* Header */}
<header className="mb-8 border-b border-neutral-800 pb-6">
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm text-neutral-400">
<span className="px-2 py-1 bg-neutral-800 rounded text-neutral-300 font-medium">
{article.source}
</span>
{article.published_at && (
<>
<span>•</span>
<span>{article.published_at}</span>
</>
)}
</div>
<h1 className="text-2xl sm:text-4xl font-bold leading-tight text-white mb-6">
{article.title}
</h1>
{article.url && (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-cyan-400 hover:text-cyan-300 font-medium transition-colors"
>
Read original source
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</header>

{/* Content */}
<div className="prose prose-invert max-w-none mb-10 text-neutral-300 leading-relaxed whitespace-pre-wrap">
{article.summary || "No summary available."}
</div>

{/* IOCs Section */}
{article.iocs && article.iocs.length > 0 && (
<section className="bg-neutral-950/50 rounded-md border border-neutral-800 overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-800 bg-neutral-800/20">
<h2 className="text-lg font-semibold text-white">Indicators of Compromise (IOCs)</h2>
</div>
<div className="divide-y divide-neutral-800">
{article.iocs.map((ioc, idx) => (
<div key={idx} className="flex flex-col sm:flex-row sm:items-center px-4 py-3 gap-2">
<span className="text-xs font-mono uppercase text-neutral-500 w-24 shrink-0">
{ioc.type}
</span>
<code className="text-sm font-mono text-cyan-300 break-all select-all">
{ioc.value}
</code>
</div>
))}
</div>
</section>
)}
</article>
</div>
</div>
);
}
Loading