feat: public site homepage, search, and article reader

- Add editorial/magazine-style public layout with serif wordmark.
- Build homepage with three bias columns and editor's picks.
- Add search page with query, bias filter, and sort tabs.
- Add article detail page with summary, bias badge, and reasoning.
- Article cards include expandable "Why this classification?" panel.
- Style with warm, retro-editorial color palette and responsive layout.
This commit is contained in:
hermes
2026-06-17 04:32:33 +00:00
parent 8daac94f5a
commit af11be82ac
10 changed files with 855 additions and 86 deletions

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const article = await prisma.article.findUnique({
where: { id, status: 'PUBLISHED' },
include: { source: true },
});
if (!article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 });
}
return NextResponse.json(article);
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { Bias } from '@prisma/client';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q') || '';
const bias = searchParams.get('bias') as Bias | null;
const sort = searchParams.get('sort') || 'rank';
const limit = Number(searchParams.get('limit') || '50');
const offset = Number(searchParams.get('offset') || '0');
const orderBy = sort === 'date'
? { publishedAt: 'desc' as const }
: { rankScore: 'desc' as const };
const articles = await prisma.article.findMany({
where: {
status: 'PUBLISHED',
...(bias && { primaryBias: bias }),
...(query && {
OR: [
{ title: { contains: query } },
{ summary: { contains: query } },
{ topics: { contains: query } },
],
}),
},
orderBy: [orderBy, { publishedAt: 'desc' }],
take: limit,
skip: offset,
include: { source: true },
});
return NextResponse.json({ articles });
}

View File

@@ -0,0 +1,123 @@
import { prisma } from '@/lib/db';
import { Bias } from '@prisma/client';
import { notFound } from 'next/navigation';
import { formatDistanceToNow, format } from 'date-fns';
import Link from 'next/link';
import { ArrowLeft, ExternalLink, BookOpen } from 'lucide-react';
const biasBadge: Record<Bias, { text: string; bg: string }> = {
LEFT: { text: 'Left Perspective', bg: 'bg-[var(--left)]' },
CENTER: { text: 'Center Perspective', bg: 'bg-[var(--center)]' },
RIGHT: { text: 'Right Perspective', bg: 'bg-[var(--right)]' },
};
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await prisma.article.findUnique({
where: { id, status: 'PUBLISHED' },
include: { source: true },
});
if (!article) notFound();
const tags: string[] = JSON.parse(article.politicalTags || '[]');
const topics: string[] = JSON.parse(article.topics || '[]');
const badge = biasBadge[article.primaryBias];
return (
<div className="max-w-4xl mx-auto">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-[var(--muted)] hover:text-[var(--accent)] transition-colors mb-6"
>
<ArrowLeft size={16} />
Back to front page
</Link>
<article className="bg-[var(--paper)] border border-[var(--border)]">
{article.imageUrl && (
<div className="border-b border-[var(--border)]">
<img
src={article.imageUrl}
alt={article.title}
className="w-full h-64 md:h-96 object-cover"
/>
</div>
)}
<div className="p-6 md:p-10">
<div className="flex flex-wrap items-center gap-3 mb-5">
<span className={`px-3 py-1 text-xs uppercase tracking-wider font-bold text-white ${badge.bg}`}>
{badge.text}
</span>
{tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 text-xs uppercase tracking-wider border border-[var(--border)] text-[var(--muted)]"
>
{tag}
</span>
))}
</div>
<h1 className="text-3xl md:text-5xl font-serif font-bold leading-tight mb-6">
{article.title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-[var(--muted)] mb-8 pb-6 border-b border-[var(--border)]">
<span className="font-medium uppercase tracking-wider">{article.source.name}</span>
<span className="text-[var(--border)]"></span>
<span>{article.publishedAt ? format(new Date(article.publishedAt), 'MMMM d, yyyy') : 'Recently'}</span>
<span className="text-[var(--border)]"></span>
<span>{article.publishedAt ? formatDistanceToNow(new Date(article.publishedAt), { addSuffix: true }) : ''}</span>
<span className="text-[var(--border)]"></span>
<span>Rank {article.rankScore}/10</span>
</div>
<div className="prose prose-lg max-w-none mb-10">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-[var(--ink)] first-letter:text-5xl first-letter:font-bold first-letter:float-left first-letter:mr-3 first-letter:mt-[-4px]">
{article.summary}
</p>
</div>
{article.aiExplanation && (
<div className="bg-[var(--warm-gray)] border-l-4 border-[var(--accent)] p-6 mb-10">
<h2 className="text-sm uppercase tracking-wider font-bold text-[var(--ink)] mb-2 flex items-center gap-2">
<BookOpen size={16} />
Why this bias?
</h2>
<p className="text-[var(--muted)] leading-relaxed italic">{article.aiExplanation}</p>
</div>
)}
{topics.length > 0 && (
<div className="mb-10">
<h2 className="text-sm uppercase tracking-wider font-bold text-[var(--ink)] mb-3">Topics</h2>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Link
key={topic}
href={`/search?q=${encodeURIComponent(topic)}`}
className="px-3 py-1.5 text-sm border border-[var(--border)] text-[var(--muted)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
{topic}
</Link>
))}
</div>
</div>
)}
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--ink)] text-[var(--paper)] font-medium uppercase tracking-wider hover:bg-[var(--accent)] transition-colors"
>
Read the original story
<ExternalLink size={16} />
</a>
</div>
</article>
</div>
);
}

View File

@@ -1,26 +1,102 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; /* Warm editorial palette */
--foreground: #171717; --background: #f7f3ed;
--foreground: #1f1b16;
--paper: #fffdf9;
--ink: #2a241d;
--muted: #6f6559;
--accent: #a0442c;
--accent-light: #c96b4f;
--border: #d8d0c4;
--warm-gray: #ebe4d8;
/* Bias colors - muted warm */
--left: #2d6b6b;
--left-soft: #e8f1f0;
--center: #7d6a3e;
--center-soft: #f4f0e4;
--right: #8b2e2e;
--right-soft: #f5e8e4;
--font-serif: Georgia, "Times New Roman", serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --color-paper: var(--paper);
--font-mono: var(--font-geist-mono); --color-ink: var(--ink);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-accent-light: var(--accent-light);
--color-border: var(--border);
--color-warm-gray: var(--warm-gray);
--color-left: var(--left);
--color-left-soft: var(--left-soft);
--color-center: var(--center);
--color-center-soft: var(--center-soft);
--color-right: var(--right);
--color-right-soft: var(--right-soft);
--font-serif: var(--font-serif);
--font-sans: var(--font-sans);
} }
@media (prefers-color-scheme: dark) { html {
:root { scroll-behavior: smooth;
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans);
background-image:
radial-gradient(circle at 20% 0%, rgba(160, 68, 44, 0.04) 0%, transparent 35%),
radial-gradient(circle at 80% 100%, rgba(45, 107, 107, 0.04) 0%, transparent 35%);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-serif);
font-weight: 600;
letter-spacing: -0.01em;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Decorative rules */
.rule-thick {
border-top: 3px solid var(--ink);
}
.rule-thin {
border-top: 1px solid var(--border);
}
/* Warm link underline */
.editorial-link {
color: var(--ink);
text-decoration: none;
background-image: linear-gradient(var(--accent-light), var(--accent-light));
background-size: 0% 2px;
background-position: left bottom;
background-repeat: no-repeat;
transition: background-size 0.25s ease;
}
.editorial-link:hover {
background-size: 100% 2px;
} }

View File

@@ -1,20 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import PublicLayout from "@/components/public/PublicLayout";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "BiasNews Aggregator",
description: "Generated by create next app", description: "News aggregated and classified by political bias",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -23,11 +13,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="en">
lang="en" <body className="antialiased">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} <PublicLayout>{children}</PublicLayout>
> </body>
<body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

View File

@@ -1,65 +1,109 @@
import Image from "next/image"; import { prisma } from '@/lib/db';
import { Bias } from '@prisma/client';
import BiasColumn from '@/components/public/BiasColumn';
import ArticleCard from '@/components/public/ArticleCard';
import Link from 'next/link';
export const revalidate = 60;
export default async function HomePage() {
const [left, center, right, featured] = await Promise.all([
prisma.article.findMany({
where: { status: 'PUBLISHED', primaryBias: Bias.LEFT },
orderBy: [{ rankScore: 'desc' }, { publishedAt: 'desc' }],
take: 6,
include: { source: true },
}),
prisma.article.findMany({
where: { status: 'PUBLISHED', primaryBias: Bias.CENTER },
orderBy: [{ rankScore: 'desc' }, { publishedAt: 'desc' }],
take: 6,
include: { source: true },
}),
prisma.article.findMany({
where: { status: 'PUBLISHED', primaryBias: Bias.RIGHT },
orderBy: [{ rankScore: 'desc' }, { publishedAt: 'desc' }],
take: 6,
include: { source: true },
}),
prisma.article.findMany({
where: { status: 'PUBLISHED' },
orderBy: [{ rankScore: 'desc' }, { publishedAt: 'desc' }],
take: 3,
include: { source: true },
}),
]);
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div>
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> {/* Hero / Featured */}
<Image {featured.length > 0 && (
className="dark:invert" <section className="mb-12 md:mb-16">
src="/next.svg" <div className="flex items-center gap-4 mb-6">
alt="Next.js logo" <h2 className="text-xl md:text-2xl font-serif font-bold">Editor&apos;s Picks</h2>
width={100} <div className="flex-1 h-px bg-[var(--border)]" />
height={20} </div>
priority
/> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <div className="lg:col-span-7">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <ArticleCard article={featured[0]} featured />
To get started, edit the page.tsx file. </div>
</h1> <div className="lg:col-span-5 flex flex-col gap-6">
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> {featured.slice(1).map((article) => (
Looking for a starting point or more instructions? Head over to{" "} <ArticleCard key={article.id} article={article} />
<a ))}
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" </div>
className="font-medium text-zinc-950 dark:text-zinc-50" </div>
> </section>
Templates )}
</a>{" "}
or the{" "} {/* Three-column bias view */}
<a <section>
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <div className="flex items-center gap-4 mb-8">
className="font-medium text-zinc-950 dark:text-zinc-50" <h2 className="text-xl md:text-2xl font-serif font-bold">Across the Spectrum</h2>
> <div className="flex-1 h-px bg-[var(--border)]" />
Learning <Link
</a>{" "} href="/search"
center. className="text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-light)] transition-colors"
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
> >
<Image View all
className="dark:invert" </Link>
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
<BiasColumn bias={Bias.LEFT} articles={left} />
<BiasColumn bias={Bias.CENTER} articles={center} />
<BiasColumn bias={Bias.RIGHT} articles={right} />
</div>
</section>
{/* Info box */}
<section className="mt-16 border-2 border-[var(--ink)] bg-[var(--paper)] p-6 md:p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h3 className="text-2xl font-serif font-bold mb-3">How bias is classified</h3>
<p className="text-[var(--muted)] leading-relaxed">
Each article is analyzed by an AI model trained to detect political framing.
We assign a primary bias Left, Center, or Right along with political tags
and an explanation of the reasoning.
</p>
</div>
<div className="flex items-center justify-around md:justify-end gap-6">
<div className="text-center">
<div className="text-3xl font-serif font-bold text-[var(--left)]">{left.length}</div>
<div className="text-xs uppercase tracking-wider text-[var(--muted)] mt-1">Left</div>
</div>
<div className="text-center">
<div className="text-3xl font-serif font-bold text-[var(--center)]">{center.length}</div>
<div className="text-xs uppercase tracking-wider text-[var(--muted)] mt-1">Center</div>
</div>
<div className="text-center">
<div className="text-3xl font-serif font-bold text-[var(--right)]">{right.length}</div>
<div className="text-xs uppercase tracking-wider text-[var(--muted)] mt-1">Right</div>
</div>
</div>
</div>
</section>
</div> </div>
); );
} }

154
src/app/search/page.tsx Normal file
View File

@@ -0,0 +1,154 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Bias } from '@prisma/client';
import ArticleCard from '@/components/public/ArticleCard';
import { Search } from 'lucide-react';
const biasOptions = [
{ value: '', label: 'All Perspectives' },
{ value: 'LEFT', label: 'Left' },
{ value: 'CENTER', label: 'Center' },
{ value: 'RIGHT', label: 'Right' },
];
const sortOptions = [
{ value: 'rank', label: 'Highest Ranked' },
{ value: 'date', label: 'Most Recent' },
];
type Article = {
id: string;
title: string;
summary: string | null;
url: string;
primaryBias: Bias;
politicalTags: string;
rankScore: number;
publishedAt: string | null;
imageUrl: string | null;
aiExplanation: string | null;
source: { name: string };
};
function SearchPageInner() {
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get('q') || '');
const [bias, setBias] = useState(searchParams.get('bias') || '');
const [sort, setSort] = useState(searchParams.get('sort') || 'rank');
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(false);
async function fetchArticles() {
setLoading(true);
const params = new URLSearchParams();
if (query) params.set('q', query);
if (bias) params.set('bias', bias);
params.set('sort', sort);
const res = await fetch(`/api/articles?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setArticles(data.articles);
}
setLoading(false);
}
useEffect(() => {
fetchArticles();
}, [bias, sort]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
fetchArticles();
};
return (
<div>
<div className="text-center mb-10">
<h1 className="text-3xl md:text-4xl font-serif font-bold mb-3">The Archive</h1>
<p className="text-[var(--muted)] max-w-xl mx-auto">
Search every story we&apos;ve analyzed by keyword, perspective, or recency.
</p>
</div>
<form onSubmit={handleSubmit} className="bg-[var(--paper)] border border-[var(--border)] p-4 mb-10">
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted)]" size={18} />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search headlines, summaries, topics..."
className="w-full pl-10 pr-4 py-3 bg-[var(--background)] border border-[var(--border)] text-[var(--ink)] placeholder:text-[var(--muted)] focus:outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
<select
value={bias}
onChange={(e) => setBias(e.target.value)}
className="px-4 py-3 bg-[var(--background)] border border-[var(--border)] text-[var(--ink)] focus:outline-none focus:border-[var(--accent)]"
>
{biasOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="px-4 py-3 bg-[var(--background)] border border-[var(--border)] text-[var(--ink)] focus:outline-none focus:border-[var(--accent)]"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="submit"
className="px-8 py-3 bg-[var(--ink)] text-[var(--paper)] font-medium uppercase tracking-wider hover:bg-[var(--accent)] transition-colors"
>
Search
</button>
</div>
</form>
{loading ? (
<p className="text-center text-[var(--muted)] font-serif italic">Flipping through the archives...</p>
) : (
<>
<div className="flex items-center gap-4 mb-6">
<p className="text-sm text-[var(--muted)] uppercase tracking-wider">
{articles.length} {articles.length === 1 ? 'result' : 'results'}
</p>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
{articles.length === 0 && (
<div className="text-center py-20 border border-dashed border-[var(--border)]">
<p className="text-xl font-serif italic text-[var(--muted)] mb-2">No stories found.</p>
<p className="text-sm text-[var(--muted)]">Try a different search or broaden your filters.</p>
</div>
)}
</>
)}
</div>
);
}
export default function SearchPage() {
return (
<Suspense fallback={<p className="text-center text-[var(--muted)] font-serif italic">Loading archive...</p>}>
<SearchPageInner />
</Suspense>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { Bias } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, Info } from 'lucide-react';
const biasBadge: Record<Bias, string> = {
LEFT: 'bg-[var(--left)] text-white',
CENTER: 'bg-[var(--center)] text-white',
RIGHT: 'bg-[var(--right)] text-white',
};
type Article = {
id: string;
title: string;
summary: string | null;
url: string;
primaryBias: Bias;
politicalTags: string;
rankScore: number;
publishedAt: Date | string | null;
imageUrl: string | null;
aiExplanation: string | null;
source: { name: string };
};
export default function ArticleCard({ article, featured = false }: { article: Article; featured?: boolean }) {
const tags: string[] = JSON.parse(article.politicalTags || '[]');
const [showReason, setShowReason] = useState(false);
return (
<article
className={`group bg-[var(--paper)] border border-[var(--border)] hover:shadow-lg transition-all duration-300 ${
featured ? 'p-6' : 'p-5'
}`}
>
{article.imageUrl && (
<div className="mb-4 overflow-hidden border border-[var(--border)]">
<img
src={article.imageUrl}
alt={article.title}
className="w-full h-48 object-cover group-hover:scale-[1.02] transition-transform duration-500"
loading="lazy"
/>
</div>
)}
<div className="flex items-center gap-2 mb-3">
<span className={`px-2 py-0.5 text-[10px] uppercase tracking-wider font-bold ${biasBadge[article.primaryBias]}`}>
{article.primaryBias}
</span>
{tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-[10px] uppercase tracking-wider border border-[var(--border)] text-[var(--muted)]"
>
{tag}
</span>
))}
</div>
<h3 className={`font-serif font-bold leading-tight mb-3 ${featured ? 'text-2xl md:text-3xl' : 'text-lg'}`}>
<Link
href={`/article/${article.id}`}
className="editorial-link"
>
{article.title}
</Link>
</h3>
<p className={`text-[var(--muted)] leading-relaxed mb-4 ${featured ? 'text-base line-clamp-4' : 'text-sm line-clamp-3'}`}>
{article.summary}
</p>
{article.aiExplanation && (
<div className="mb-4">
<button
type="button"
onClick={() => setShowReason((s) => !s)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--accent)] hover:text-[var(--accent-light)] transition-colors"
aria-expanded={showReason}
>
<Info size={14} />
{showReason ? 'Hide classification' : 'Why this classification?'}
{showReason ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{showReason && (
<div className="mt-3 text-sm text-[var(--muted)] bg-[var(--warm-gray)] border-l-4 border-[var(--accent)] p-3 italic leading-relaxed">
{article.aiExplanation}
</div>
)}
</div>
)}
<div className="flex items-center justify-between text-xs text-[var(--muted)] border-t border-[var(--border)] pt-3">
<span className="font-medium uppercase tracking-wider">{article.source.name}</span>
<span>
{article.publishedAt
? formatDistanceToNow(new Date(article.publishedAt), { addSuffix: true })
: 'Recently'}
</span>
</div>
</article>
);
}

View File

@@ -0,0 +1,65 @@
import { Bias } from '@prisma/client';
import ArticleCard from './ArticleCard';
const biasMeta: Record<Bias, { title: string; subtitle: string; bar: string }> = {
LEFT: {
title: 'Left',
subtitle: 'Progressive & liberal framing',
bar: 'bg-[var(--left)]',
},
CENTER: {
title: 'Center',
subtitle: 'Mainstream & balanced framing',
bar: 'bg-[var(--center)]',
},
RIGHT: {
title: 'Right',
subtitle: 'Conservative & traditional framing',
bar: 'bg-[var(--right)]',
},
};
type Article = {
id: string;
title: string;
summary: string | null;
url: string;
primaryBias: Bias;
politicalTags: string;
rankScore: number;
publishedAt: Date | string | null;
imageUrl: string | null;
aiExplanation: string | null;
source: { name: string };
};
export default function BiasColumn({ bias, articles }: { bias: Bias; articles: Article[] }) {
const meta = biasMeta[bias];
return (
<section className="flex flex-col h-full">
<div className="border-b-2 border-[var(--ink)] pb-3 mb-4">
<div className="flex items-baseline justify-between">
<h2 className="text-2xl font-serif font-bold">{meta.title}</h2>
<span className="text-xs text-[var(--muted)] uppercase tracking-wider">{articles.length} stories</span>
</div>
<p className="text-xs text-[var(--muted)] italic mt-1">{meta.subtitle}</p>
<div className={`h-1 w-full mt-3 ${meta.bar}`} />
</div>
<div className="flex-1 space-y-5">
{articles.slice(0, 1).map((article) => (
<ArticleCard key={article.id} article={article} featured />
))}
{articles.slice(1).map((article) => (
<ArticleCard key={article.id} article={article} />
))}
{articles.length === 0 && (
<div className="text-center py-12 border border-dashed border-[var(--border)] text-[var(--muted)]">
<p className="font-serif italic">No {meta.title.toLowerCase()} stories yet.</p>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Search, Menu, X } from 'lucide-react';
import { useState } from 'react';
const navItems = [
{ href: '/', label: 'Front Page' },
{ href: '/search', label: 'Archive' },
];
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const today = new Date();
const dateLine = `${days[today.getDay()]}, ${months[today.getMonth()]} ${today.getDate()}, ${today.getFullYear()}`;
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--ink)]">
{/* Top bar */}
<div className="border-b border-[var(--border)] bg-[var(--paper)]">
<div className="max-w-6xl mx-auto px-4 py-2 flex items-center justify-between text-xs tracking-wide text-[var(--muted)] uppercase">
<span>{dateLine}</span>
<div className="flex items-center gap-4">
<span className="hidden sm:inline">AI-assisted bias classification</span>
<Link href="/admin" className="hover:text-[var(--accent)] transition-colors">
Admin
</Link>
</div>
</div>
</div>
{/* Masthead */}
<header className="bg-[var(--paper)] border-b-4 border-[var(--ink)]">
<div className="max-w-6xl mx-auto px-4 py-8 md:py-12 text-center">
<Link href="/" className="inline-block">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-serif font-bold tracking-tight text-[var(--ink)]">
The Bias Times
</h1>
</Link>
<p className="mt-3 text-sm md:text-base text-[var(--muted)] font-serif italic max-w-xl mx-auto">
Read the news as it is reported from the left, the center, and the right.
</p>
</div>
</header>
{/* Navigation */}
<nav className="sticky top-0 z-50 bg-[var(--paper)] border-b border-[var(--border)] shadow-sm">
<div className="max-w-6xl mx-auto px-4">
<div className="flex items-center justify-between h-12">
<div className="hidden md:flex items-center gap-8">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`text-sm uppercase tracking-widest font-medium transition-colors ${
pathname === item.href
? 'text-[var(--accent)]'
: 'text-[var(--ink)] hover:text-[var(--accent)]'
}`}
>
{item.label}
</Link>
))}
</div>
<div className="hidden md:flex items-center gap-2">
<span className="text-xs uppercase tracking-wider text-[var(--muted)]">Perspectives</span>
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-[var(--left)]" title="Left" />
<span className="w-3 h-3 rounded-full bg-[var(--center)]" title="Center" />
<span className="w-3 h-3 rounded-full bg-[var(--right)]" title="Right" />
</div>
</div>
<div className="md:hidden flex items-center gap-3">
<Link
href="/search"
className="p-2 hover:bg-[var(--warm-gray)] rounded-full transition-colors"
>
<Search size={18} />
</Link>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="p-2 hover:bg-[var(--warm-gray)] rounded-full transition-colors"
>
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</div>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-[var(--border)] bg-[var(--paper)] px-4 py-3 space-y-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`block py-2 text-sm uppercase tracking-widest ${
pathname === item.href ? 'text-[var(--accent)]' : 'text-[var(--ink)]'
}`}
>
{item.label}
</Link>
))}
</div>
)}
</nav>
{/* Main content */}
<main className="max-w-6xl mx-auto px-4 py-8 md:py-12">
{children}
</main>
{/* Footer */}
<footer className="border-t-4 border-[var(--ink)] bg-[var(--paper)] mt-16">
<div className="max-w-6xl mx-auto px-4 py-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-sm">
<div>
<h4 className="font-serif text-lg font-bold mb-3">The Bias Times</h4>
<p className="text-[var(--muted)] leading-relaxed">
A news aggregator that surfaces stories from across the political spectrum,
classified by AI and curated by editors.
</p>
</div>
<div>
<h4 className="font-serif text-lg font-bold mb-3">Sections</h4>
<ul className="space-y-2 text-[var(--muted)]">
<li><Link href="/" className="hover:text-[var(--accent)]">Front Page</Link></li>
<li><Link href="/search" className="hover:text-[var(--accent)]">Archive</Link></li>
<li><Link href="/admin" className="hover:text-[var(--accent)]">Admin Console</Link></li>
</ul>
</div>
<div>
<h4 className="font-serif text-lg font-bold mb-3">Bias Key</h4>
<div className="space-y-2 text-[var(--muted)]">
<div className="flex items-center gap-2"><span className="w-3 h-3 rounded-full bg-[var(--left)]" /> Left perspective</div>
<div className="flex items-center gap-2"><span className="w-3 h-3 rounded-full bg-[var(--center)]" /> Center perspective</div>
<div className="flex items-center gap-2"><span className="w-3 h-3 rounded-full bg-[var(--right)]" /> Right perspective</div>
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-[var(--border)] text-center text-xs text-[var(--muted)] uppercase tracking-wider">
© {new Date().getFullYear()} The Bias Times AI-assisted bias classification
</div>
</div>
</footer>
</div>
);
}