From af11be82ac725d4cae1478fb1f4507dfdc7a12c3 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 17 Jun 2026 04:32:33 +0000 Subject: [PATCH] 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. --- src/app/api/articles/[id]/route.ts | 19 +++ src/app/api/articles/route.ts | 36 ++++++ src/app/article/[id]/page.tsx | 123 +++++++++++++++++++ src/app/globals.css | 96 +++++++++++++-- src/app/layout.tsx | 25 ++-- src/app/page.tsx | 160 ++++++++++++++++--------- src/app/search/page.tsx | 154 ++++++++++++++++++++++++ src/components/public/ArticleCard.tsx | 108 +++++++++++++++++ src/components/public/BiasColumn.tsx | 65 ++++++++++ src/components/public/PublicLayout.tsx | 155 ++++++++++++++++++++++++ 10 files changed, 855 insertions(+), 86 deletions(-) create mode 100644 src/app/api/articles/[id]/route.ts create mode 100644 src/app/api/articles/route.ts create mode 100644 src/app/article/[id]/page.tsx create mode 100644 src/app/search/page.tsx create mode 100644 src/components/public/ArticleCard.tsx create mode 100644 src/components/public/BiasColumn.tsx create mode 100644 src/components/public/PublicLayout.tsx diff --git a/src/app/api/articles/[id]/route.ts b/src/app/api/articles/[id]/route.ts new file mode 100644 index 0000000..fd81384 --- /dev/null +++ b/src/app/api/articles/[id]/route.ts @@ -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); +} diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts new file mode 100644 index 0000000..f39575c --- /dev/null +++ b/src/app/api/articles/route.ts @@ -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 }); +} diff --git a/src/app/article/[id]/page.tsx b/src/app/article/[id]/page.tsx new file mode 100644 index 0000000..5498ec8 --- /dev/null +++ b/src/app/article/[id]/page.tsx @@ -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 = { + 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 ( +
+ + + Back to front page + + +
+ {article.imageUrl && ( +
+ {article.title} +
+ )} + +
+
+ + {badge.text} + + {tags.map((tag) => ( + + {tag} + + ))} +
+ +

+ {article.title} +

+ +
+ {article.source.name} + + {article.publishedAt ? format(new Date(article.publishedAt), 'MMMM d, yyyy') : 'Recently'} + + {article.publishedAt ? formatDistanceToNow(new Date(article.publishedAt), { addSuffix: true }) : ''} + + Rank {article.rankScore}/10 +
+ +
+

+ {article.summary} +

+
+ + {article.aiExplanation && ( +
+

+ + Why this bias? +

+

{article.aiExplanation}

+
+ )} + + {topics.length > 0 && ( +
+

Topics

+
+ {topics.map((topic) => ( + + {topic} + + ))} +
+
+ )} + + + Read the original story + + +
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..d42c819 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,102 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + /* Warm editorial palette */ + --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 { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-paper: var(--paper); + --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) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html { + scroll-behavior: smooth; } body { background: var(--background); 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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..4deb35c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,10 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import PublicLayout from "@/components/public/PublicLayout"; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "BiasNews Aggregator", + description: "News aggregated and classified by political bias", }; export default function RootLayout({ @@ -23,11 +13,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..a37d5be 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- + {/* Hero / Featured */} + {featured.length > 0 && ( +
+
+

Editor's Picks

+
+
+ +
+
+ +
+
+ {featured.slice(1).map((article) => ( + + ))} +
+
+
+ )} + + {/* Three-column bias view */} +
+
+ +
+ + + +
+ + + {/* Info box */} +
+
+
+

How bias is classified

+

+ 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. +

+
+
+
+
{left.length}
+
Left
+
+
+
{center.length}
+
Center
+
+
+
{right.length}
+
Right
+
+
+
+
); } diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..d4bedd6 --- /dev/null +++ b/src/app/search/page.tsx @@ -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([]); + 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 ( +
+
+

The Archive

+

+ Search every story we've analyzed by keyword, perspective, or recency. +

+
+ +
+
+
+ + 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" + /> +
+ + + +
+
+ + {loading ? ( +

Flipping through the archives...

+ ) : ( + <> +
+

+ {articles.length} {articles.length === 1 ? 'result' : 'results'} +

+
+
+ +
+ {articles.map((article) => ( + + ))} +
+ + {articles.length === 0 && ( +
+

No stories found.

+

Try a different search or broaden your filters.

+
+ )} + + )} +
+ ); +} + +export default function SearchPage() { + return ( + Loading archive...

}> + +
+ ); +} diff --git a/src/components/public/ArticleCard.tsx b/src/components/public/ArticleCard.tsx new file mode 100644 index 0000000..08a944e --- /dev/null +++ b/src/components/public/ArticleCard.tsx @@ -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 = { + 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.imageUrl && ( +
+ {article.title} +
+ )} + +
+ + {article.primaryBias} + + {tags.map((tag) => ( + + {tag} + + ))} +
+ +

+ + {article.title} + +

+ +

+ {article.summary} +

+ + {article.aiExplanation && ( +
+ + + {showReason && ( +
+ {article.aiExplanation} +
+ )} +
+ )} + +
+ {article.source.name} + + {article.publishedAt + ? formatDistanceToNow(new Date(article.publishedAt), { addSuffix: true }) + : 'Recently'} + +
+
+ ); +} diff --git a/src/components/public/BiasColumn.tsx b/src/components/public/BiasColumn.tsx new file mode 100644 index 0000000..54f3709 --- /dev/null +++ b/src/components/public/BiasColumn.tsx @@ -0,0 +1,65 @@ +import { Bias } from '@prisma/client'; +import ArticleCard from './ArticleCard'; + +const biasMeta: Record = { + 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 ( +
+
+
+

{meta.title}

+ {articles.length} stories +
+

{meta.subtitle}

+
+
+ +
+ {articles.slice(0, 1).map((article) => ( + + ))} + {articles.slice(1).map((article) => ( + + ))} + {articles.length === 0 && ( +
+

No {meta.title.toLowerCase()} stories yet.

+
+ )} +
+
+ ); +} diff --git a/src/components/public/PublicLayout.tsx b/src/components/public/PublicLayout.tsx new file mode 100644 index 0000000..1972c3f --- /dev/null +++ b/src/components/public/PublicLayout.tsx @@ -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 ( +
+ {/* Top bar */} +
+
+ {dateLine} +
+ AI-assisted bias classification + + Admin + +
+
+
+ + {/* Masthead */} +
+
+ +

+ The Bias Times +

+ +

+ “Read the news as it is reported — from the left, the center, and the right.” +

+
+
+ + {/* Navigation */} + + + {/* Main content */} +
+ {children} +
+ + {/* Footer */} + +
+ ); +}