# Project export: BasedNews

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: You can't be based if you don't know both sides of the story.
- Devpost: https://devpost.com/software/basednews
- GitHub: https://github.com/leo-kildani/cruzhacks2026
- Demo: https://cruzhacks2026-kappa.vercel.app/
- Video: https://www.youtube.com/embed/g2jY3BUfaoA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner ([Sponsor - n8n] Best Use of n8n)
- Team: 2 GitHub contributor(s) — leo kildani (12 commits), victor (2 commits)

## Devpost submission (written by the team)

### Inspiration

In an era of polarized media, Based News was created to give readers a clearer picture of political news. We believe that informed citizens make better decisions, and informed citizens need access to unbiased information.

### What it does

Based News is an AI-driven news aggregate, built to filter out the bias and deceit from mainstream media and other outlets, by conducting deep analysis on all sides of reporting. We offer general summaries of trending headlines, free from political bias, political bias analysis on different news outlets covering a headline, and a view into the public's opinion on a topic through Youtube comments.

### How we built it

Our tech stack runs deep. For development of our Full-Stack application, we used Next.js App Router, with Supabase and Prisma, deployed on Vercel. To build our analysis pipelines, we utilized N8N, Parallel Web Systems, Exa.ai, and some custom JS scripts that took FOREVER to get working. We've attached diagrams depicting the systems and pipelines used in this project.

### Challenges we ran into

Where do we even start. Firstly, we entered the project with a completely different idea than Based News. Our first five hours of the competition were landing on the idea of Based News and researching how in the world we were going to build it. Another challenge we ran into was honing our N8N skills and building robust workflows that function the way we intended. Additionally, we also faced the challenge of speeding up some of our workflows and API calls. The worst challenge of all was gaining insight on how the public viewed trending news. Ideally, we wanted to scrape Reddit or X, but Reddit API required you to request access and the X api rate-limited after 100 posts (NOT ENOUGH DATA). Hence, we found a workaround... Youtube Comments!!! We built our own API server using the Youtube Data API and a secret Python library to scrape all the popular comments off of videos related to specific headlines.

### Accomplishments we're proud of

An accomplishment that we are extremely proud of is the Headline Ingestion and Aggregation pipeline. It took the most time to complete and ended up working (almost) exactly how we wanted it to. Another accomplishment we are extremely proud of is collaborating on the same code base without any conflicts. Not running into any merge conflicts attributes greatly to the completion of this project.

### What we learned

We are baffled by the number of technologies and concepts we worked with and learned about over the course of this competition. We want to give a huge shoutout to the boys at OpenNote, who taught us so much about vector stores and web search agents, which were essential to the core functionalities of our project.

### What's next

BasedNews is barely an MVP. With the complex nature of the problem we are tackling, there are several bugs and inefficiencies in our systems. Our next steps are to finish and refine the core functions of the software, and optimize where we can.

## README (from the GitHub repository)

# Based News

A political news aggregator that provides neutral summaries with bias ratings and public opinion analysis. Built for CruzHacks 2026.

**Live Demo:** https://cruzhacks2026-kappa.vercel.app/

## Features

- **Neutral Summaries** - Direct, politically neutral summaries of the latest US political news
- **7-Point Bias Scale** - Every source categorized from Far Left to Far Right
- **Public Opinion** - Real-time sentiment analysis from YouTube comments

## Environment Variables

Create a `.env` file in the root directory with the following variables:

```bash
YOUTUBE_API_KEY=your_youtube_api_key_here
```

You can obtain a YouTube Data API key from the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).

## Running the Public Opinion API (FastAPI)

The public opinion analysis feature requires running a local FastAPI server exposed via ngrok.

### Prerequisites

- Python 3.11+
- ngrok account with a custom domain (or use the free tier)
- OpenAI API key
- install dependencies from `requirements.txt`

### Start the FastAPI server

In one terminal:

```bash
cd python_public_opinion
OPENAI_API_KEY=your_openai_api_key uvicorn main:app --host 0.0.0.0 --port 8000
```

### Start the ngrok tunnel

In another terminal:

```bash
ngrok http 8000 --domain=bursting-satyr-genuinely.ngrok-free.app
```

The FastAPI will now be accessible at `https://bursting-satyr-genuinely.ngrok-free.app`.

## API Endpoints

### 1. GET `/api/headlines`

Fetches paginated headlines from the database, ordered by date (newest first).

**Query Parameters:**
- `skip` (optional, default: 0) - Number of headlines to skip
- `take` (optional, default: 6) - Number of headlines to fetch

**Response:**
```json
{
  "headlines": [...],
  "hasMore": true,
  "totalCount": 25
}
```

**curl:**
```bash
curl "https://cruzhacks2026-kappa.vercel.app/api/headlines?skip=0&take=6"
```

---

### 2. POST `/api/headline-sources`

Forwards a headline to an n8n webhook to fetch related news sources with bias ratings. Used to find additional sources covering the same story.

**Request Body:**
```json
{
  "headline": "string (required)",
  "description": "string (required)", 
  "date": "string (required)"
}
```

**curl:**
```bash
curl -X POST "https://cruzhacks2026-kappa.vercel.app/api/headline-sources" \
  -H "Content-Type: application/json" \
  -d '{"headline": "Federal Judge Limits Immigration Enforcement", "description": "A federal judge issued a ruling restricting immigration agents.", "date": "2026-01-17"}'
```

---

### 3. POST `/api/ingest/headlines`

Ingests articles from 6 hardcoded RSS feeds (CNN, NYTimes, Fox News, ABC News, WSJ, LA Times) into the `Article` table. Skips duplicates based on link URL.

**Request Body:** None required

**Response:**
```json
{
  "ok": true,
  "totalParsed": 120,
  "totalInserted": 15,
  "perFeed": [
    { "url": "http://rss.cnn.com/...", "title": "CNN US", "parsed": 20, "inserted": 3 }
  ]
}
```

**curl:**
```bash
curl -X POST "https://cruzhacks2026-kappa.vercel.app/api/ingest/headlines"
```

---

### 4. GET `/api/headlines/[id]/public-opinion`

Returns public opinion analysis for a specific headline. If cached, returns immediately; otherwise, orchestrates calls to YouTube search and FastAPI analysis endpoints, then caches the result.

**Path Parameters:**
- `id` - The headline UUID

**Response:**
```json
{
  "summary": "Analysis of public sentiment...",
  "totalComments": 1500,
  "videosProcessed": 5,
  "cached": true
}
```

**curl:**
```bash
curl "https://cruzhacks2026-kappa.vercel.app/api/headlines/<HEADLINE_ID>/public-opinion"
```

*Note: Replace `<HEADLINE_ID>` with an actual headline UUID from the `/api/headlines` response.*

---

### 5. POST `/api/publicopinion`

Searches YouTube for videos matching a query string. Returns up to 5 most relevant videos. Used internally by the public-opinion endpoint.

**Request Body:**
```json
{
  "query": "string (required)",
  "publishedAfter": "ISO 8601 date string (optional)"
}
```

**Response:**
```json
{
  "videos": [
    {
      "videoId": "abc123",
      "title": "Video Title",
      "url": "https://www.youtube.com/watch?v=abc123",
      "thumbnail": "https://i.ytimg.com/...",
      "publishedAt": "2026-01-17T12:00:00Z"
    }
  ]
}
```

**curl:**
```bash
curl -X POST "https://cruzhacks2026-kappa.vercel.app/api/publicopinion" \
  -H "Content-Type: application/json" \
  -d '{"query": "immigration enforcement ruling", "publishedAfter": "2026-01-15T00:00:00Z"}'
```

---

### 6. POST `/api/publicopinion/analyze`

Forwards YouTube video URLs to a FastAPI backend (Python service) that scrapes comments and generates an AI-powered public sentiment summary.

**Request Body:**
```json
{
  "youtube_urls": ["https://www.youtube.com/watch?v=abc123", "..."]
}
```

**Response:**
```json
{
  "summary": "Public sentiment analysis...",
  "total_comments": 1500,
  "videos_processed": 5
}
```

**curl:**
```bash
curl -X POST "https://cruzhacks2026-kappa.vercel.app/api/publicopinion/analyze" \
  -H "Content-Type: application/json" \
  -d '{"youtube_urls": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"]}'
```

## Architecture

```
┌─────────────────────────────────────────────────────────────────────────┐
│                              Frontend                                    │
│                             (React UI)                                   │
└─────────────────────────────┬───────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                        Next.js API Routes                                │
├─────────────┬─────────────┬─────────────┬─────────────┬─────────────────┤
│ /headlines  │ /headline-  │ /ingest/    │ /headlines/ │ /publicopinion  │
│             │ sources     │ headlines   │ [id]/public │ /analyze        │
│             │             │             │ -opinion    │                 │
└──────┬──────┴──────┬──────┴──────┬──────┴──────┬──────┴────────┬────────┘
       │             │             │             │               │
       ▼             ▼             ▼             ▼               ▼
┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌─────────────────┐
│PostgreSQL│  │n8n       │  │RSS Feeds │  │YouTube   │  │FastAPI (Python) │
│          │  │Webhook   │  │          │  │Data API  │  │                 │
└──────────┘  └──────────┘  └──────────┘  └──────────┘  └─────────────────┘
```

## Tech Stack

- **Frontend:** Next.js, React, Tailwind CSS
- **Backend:** Next.js API Routes, Prisma ORM
- **Database:** PostgreSQL (Supabase)
- **External Services:** n8n, YouTube Data API, Exa.ai
- **ML/AI:** FastAPI Python service with OpenAI

## Detected evidence (automated analysis)

Indexed codebase: 27 recognized source files, 94 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- PostgreSQL (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Supabase (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (39 of 39)

```
.gitignore
app/about/page.tsx
app/api/headline-sources/route.ts
app/api/headlines/[id]/public-opinion/route.ts
app/api/headlines/route.ts
app/api/ingest/headlines/route.ts
app/api/publicopinion/analyze/route.ts
app/api/publicopinion/route.ts
app/globals.css
app/layout.tsx
app/page.tsx
components.json
components/footer.tsx
components/header.tsx
components/headline-sources-modal.tsx
components/headlines-section.tsx
components/ui/button.tsx
components/ui/card.tsx
components/ui/separator.tsx
eslint.config.mjs
lib/parse-rss.ts
lib/prisma.ts
lib/rss-feed.csv
lib/utils.ts
next.config.ts
out.txt
package.json
postcss.config.mjs
prisma.config.ts
prisma/migrations/20260117092326_add_article/migration.sql
prisma/migrations/20260118045706_add_processed_field/migration.sql
prisma/migrations/20260118052304_add_headlines_table/migration.sql
prisma/migrations/migration_lock.toml
prisma/schema.prisma
python_public_opinion/Dockerfile
python_public_opinion/main.py
python_public_opinion/requirements.txt
README.md
tsconfig.json
```

### Dependencies

- package.json: @prisma/adapter-pg@^7.2.0, @prisma/client@^7.2.0, @radix-ui/react-separator@^1.1.8, @radix-ui/react-slot@^1.2.4, @tailwindcss/postcss@^4, @types/node@^20.19.30, @types/pg@^8.16.0, @types/react@^19, @types/react-dom@^19, class-variance-authority@^0.7.1, clsx@^2.1.1, csv-parse@^6.1.0, dotenv@^17.2.3, eslint@^9, eslint-config-next@16.1.3, lucide-react@^0.562.0, next@16.1.3, pg@^8.17.1, prisma@^7.2.0, react@19.2.3, react-dom@19.2.3, rss-parser@^3.13.0, tailwind-merge@^3.4.0, tailwindcss@^4, tw-animate-css@^1.4.0, typescript@^5
- python_public_opinion/requirements.txt: fastapi, openai@>=1.0.0, uvicorn[standard], youtube-comment-downloader

### Recent commits (newest first)

- modify readme
- Victorbranch2 (#9)
- implement public opinion feature to frontend (#8)
- package.json fix
- update fastapi to use ngrok reverse proxy (#7)
- added related sources, general summary, and political bias of releveant topics (#6)
- implement python fastapi for youtube comment analysis; use youtube data api to retrieve urls (#5)
- Frontend (#4)
- add headlines to db (#3)
- Headlines agg api (#2)
- working rss parser on open rss files (#1)
- init

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "cruzhacks2026",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "prisma generate &&next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@prisma/adapter-pg": "^7.2.0",
    "@prisma/client": "^7.2.0",
    "@radix-ui/react-separator": "^1.1.8",
    "@radix-ui/react-slot": "^1.2.4",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "csv-parse": "^6.1.0",
    "dotenv": "^17.2.3",
    "lucide-react": "^0.562.0",
    "next": "16.1.3",
    "pg": "^8.17.1",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "rss-parser": "^3.13.0",
    "tailwind-merge": "^3.4.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20.19.30",
    "@types/pg": "^8.16.0",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.3",
    "prisma": "^7.2.0",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.4.0",
    "typescript": "^5"
  }
}

```

### python_public_opinion/requirements.txt

```
fastapi
uvicorn[standard]
youtube-comment-downloader
openai>=1.0.0

```

### python_public_opinion/Dockerfile

```
FROM python:3.11-slim

# Set working directory
WORKDIR /app

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY main.py .

# Expose port
EXPOSE 8000

# Run the application
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Based News | Unbiased Political News Aggregator",
  description:
    "Based News delivers unbiased, technology-driven political news aggregation. Get deeper insights into US politics with neutral analysis and transparent methodology.",
  keywords: ["news", "politics", "unbiased", "aggregator", "US news", "neutral"],
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
      >
        <Header />
        <main className="flex-1">{children}</main>
        <Footer />
      </body>
    </html>
  );
}

```

### python_public_opinion/main.py

```python
import os
from itertools import islice
from typing import List

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from youtube_comment_downloader import YoutubeCommentDownloader, SORT_BY_POPULAR
from openai import OpenAI

app = FastAPI(
    title="Public Opinion Analyzer",
    description="Extracts YouTube comments and synthesizes public opinion using AI",
    version="1.0.0",
)

# Add CORS middleware to allow requests from Vercel
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Allows all origins; can restrict to your Vercel domain if needed
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Initialize OpenAI client
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

# Initialize YouTube comment downloader
downloader = YoutubeCommentDownloader()


class AnalyzeRequest(BaseModel):
    youtube_urls: List[str]


class AnalyzeResponse(BaseModel):
    summary: str
    total_comments: int
    videos_processed: int


def extract_comments_from_video(url: str, limit: int = 75) -> List[dict]:
    """
    Extract top comments from a YouTube video URL.
    
    Args:
        url: YouTube video URL
        limit: Maximum number of comments to extract
        
    Returns:
        List of comment dictionaries
    """
    try:
        comments = downloader.get_comments_from_url(url, sort_by=SORT_BY_POPULAR)
        return list(islice(comments, limit))
    except Exception as e:
        print(f"Error extracting comments from {url}: {e}")
        return []


def synthesize_public_opinion(comments: List[dict], video_count: int) -> str:
    """
    Use OpenAI GPT-3.5-turbo to synthesize a public opinion summary from comments.
    
    Args:
        comments: List of comment dictionaries
        video_count: Number of videos the comments came from
        
    Returns:
        Synthesized public opinion summary
    """
    if not comments:
        return "No comments were found to analyze."
    
    # Extract comment text and build context
    comment_texts = []
    for comment in comments:
        text = comment.get("text", "").strip()
        if text:
            # Include like count for context on popularity
            likes = comment.get("votes", 0)
            comment_texts.append(f"[{likes} likes] {text}")
    
    if not comment_texts:
        return "No valid comment text was found to analyze."
    
    # Limit the total text to avoid token limits
    combined_comments = "\n".join(comment_texts[:200])  # Cap at 200 comments
    if len(combined_comments) > 15000:
        combined_comments = combined_comments[:15000] + "..."
    
    prompt = f"""You are analyzing public opinion based on YouTube comments from {video_count} video(s) related to a news topic.

Below are the top comments sorted by popularity (likes shown in brackets):

{combined_comments}

Based on these comments, provide a concise public opinion analysis that includes:
1. The overall sentiment (positive, negative, mixed, neutral)
2. The main themes or concerns expressed by commenters
3. Any notable patterns or consensus views
4. Any significant minority opinions or debates

Keep your analysis to 2-3 paragraphs and be objective in summarizing what the public thinks."""

    try:
        response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "system",
                    "content": "You are an objective analyst who summarizes public opinion based on social media comments. Be balanced and accurate in your assessments.",
                },
                {"role": "user", "content": prompt},
            ],
            max_tokens=500,
            temperature=0.7,
        )
        return response.choices[0].message.content.strip()
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Error calling OpenAI API: {str(e)}"
        )


@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze_public_opinion(request: AnalyzeRequest):
    """
    Analyze public opinion from YouTube video comments.
    
    Accepts a list of YouTube URLs, extracts top 75 comments from each,
    and uses AI to synthesize a public opinion summary.
    """
    if not request.youtube_urls:
        raise HTTPException(status_code=400, detail="youtube_urls list cannot be empty")
    
    # Extract comments from all videos
    all_comments = []
    videos_processed = 0
    
    for url in request.youtube_urls:
        comments = extract_comments_from_video(url, limit=75)
        if comments:
            all_comments.extend(comments)
            videos_processed += 1
    
    if videos_processed == 0:
        raise HTTPException(
            status_code=500,
            detail="Failed to extract comments from any of the provided videos",
        )
    
    # Synthesize public opinion
    summary = synthesize_public_opinion(all_comments, videos_processed)
    
    return AnalyzeResponse(
        summary=summary,
        total_comments=len(all_comments),
        videos_processed=videos_processed,
    )


@app.get("/health")
async def health_check():
    """Health check endpoint for container orchestration."""
    return {"status": "healthy"}

```

### app/page.tsx

```typescript
import { ArrowRight, Scale, Users, FileText, ChevronRight } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { prisma } from "@/lib/prisma";
import { HeadlinesSection, Headline } from "@/components/headlines-section";

export default async function Home() {
  // Fetch the latest 6 headlines and total count from the database
  const [headlinesData, totalCount] = await Promise.all([
    prisma.headlines.findMany({
      take: 6,
      orderBy: { date: 'desc' },
    }),
    prisma.headlines.count(),
  ]);

  // Serialize headlines for client component (convert Date to string)
  const headlines: Headline[] = headlinesData.map((h) => ({
    id: h.id,
    headline: h.headline,
    description: h.description,
    date: h.date.toISOString(),
    createdAt: h.createdAt.toISOString(),
    updatedAt: h.updatedAt.toISOString(),
  }));

  const hasMore = headlines.length < totalCount;
  return (
    <div className="flex flex-col">
      {/* Hero Section */}
      <section className="relative overflow-hidden border-b border-border/40">
        {/* Subtle gradient background */}
        <div className="absolute inset-0 bg-gradient-to-br from-brand-100/20 via-transparent to-brand-200/10" />
        
        {/* Tech grid pattern */}
        <div className="absolute inset-0 opacity-[0.02]">
          <div
            className="h-full w-full"
            style={{
              backgroundImage: `
                linear-gradient(to right, currentColor 1px, transparent 1px),
                linear-gradient(to bottom, currentColor 1px, transparent 1px)
              `,
              backgroundSize: "60px 60px",
            }}
          />
        </div>

        <div className="container relative mx-auto max-w-6xl px-4 py-20 md:py-28">
          <div className="max-w-3xl space-y-6">
            <div className="inline-flex items-center gap-2 rounded-full border border-brand-600/20 bg-brand-600/10 px-3 py-1 text-sm font-mono text-brand-700">
              <span className="inline-block h-2 w-2 rounded-full bg-brand-500 animate-pulse" />
              Live News Feed
            </div>
            
            <h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-foreground">
              US Politics,{" "}
              <span className="text-brand-600">Summarized & Neutral</span>
            </h1>
            
            <p className="text-lg md:text-xl text-muted-foreground max-w-2xl">
              Direct, summarized political news with full transparency. Read the 
              summary or dive deeper into all sources we utilized. See bias ratings 
              and public opinion at a glance.
            </p>

            <div className="flex flex-col sm:flex-row gap-4 pt-4">
              <Button size="lg" className="gap-2">
                Explore Latest News
                <ArrowRight className="h-4 w-4" />
              </Button>
              <Button variant="outline" size="lg" asChild>
                <Link href="/about">Learn Our Methodology</Link>
              </Button>
            </div>
          </div>

          {/* Feature badges */}
          <div className="mt-12 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl">
            <div className="flex items-center gap-3 text-sm text-muted-foreground">
              <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-600/10">
                <FileText className="h-5 w-5 text-brand-600" />
              </div>
              <span>Neutral Summaries</span>
            </div>
            <div className="flex items-center gap-3 text-sm text-muted-foreground">
              <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-600/10">
                <Scale className="h-5 w-5 text-brand-600" />
              </div>
              <span>7-Point Bias Scale</span>
            </div>
            <div className="flex items-center gap-3 text-sm text-muted-foreground">
              <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-600/10">
                <Users className="h-5 w-5 text-brand-600" />
              </div>
              <span>Public Opinion</span>
            </div>
          </div>
        </div>
      </section>

      {/* Latest News Section */}
      <section className="py-16 md:py-20">
        <div className="container mx-auto max-w-6xl px-4">
          <div className="flex items-center justify-between mb-8">
            <div>
              <h2 className="text-2xl md:text-3xl font-bold text-foreground">
                Latest Headlines
              </h2>
              <p className="text-muted-foreground mt-1">
                Real-time aggregated news from across the political spectrum
              </p>
            </div>
            <span className="hidden sm:inline-flex items-center gap-2 text-sm font-mono text-muted-foreground">
              <span className="inline-block h-2 w-2 rounded-full bg-brand-500 animate-pulse" />
              Updating live
            </span>
          </div>

          <HeadlinesSection
            initialHeadlines={headlines}
            initialHasMore={hasMore}
          />
        </div>
      </section>

      {/* What We Offer Section */}
      <section className="py-16 md:py-20 bg-muted/30 border-t border-border/40">
        <div className="container mx-auto max-w-6xl px-4">
          <div className="text-center mb-12">
            <h2 className="text-2xl md:text-3xl font-bold text-foreground">
              What We Offer
            </h2>
            <p className="text-muted-foreground mt-2 max-w-2xl mx-auto">
              Three powerful tools to help you understand US political news better
            </p>
          </div>

          <div className="grid gap-6 md:grid-cols-3">
            {/* Offering 1: Summarized News */}
         
[truncated — 3354 more characters]
```

### app/about/page.tsx

```typescript
import Image from "next/image";
import {
  Database,
  Cpu,
  Newspaper,
  ArrowRight,
  Globe,
  Scale,
  Eye,
  GitBranch,
  Layers,
  Search,
  Users,
} from "lucide-react";
import { Separator } from "@/components/ui/separator";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

export default function AboutPage() {
  return (
    <div className="flex flex-col">
      {/* Hero Section */}
      <section className="relative overflow-hidden border-b border-border/40 bg-gradient-to-br from-brand-100/20 via-transparent to-brand-200/10">
        <div className="container relative mx-auto max-w-6xl px-4 py-16 md:py-24">
          <div className="max-w-3xl space-y-6">
            <Image
              src="/basednewslogo.png"
              alt="Based News"
              width={280}
              height={100}
              className="h-auto w-[280px]"
              priority
            />
            <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-foreground">
              About <span className="text-brand-600">Based News</span>
            </h1>
            <p className="text-lg md:text-xl text-muted-foreground">
              A technology-driven approach to political news that prioritizes
              transparency, neutrality, and deeper understanding.
            </p>
          </div>
        </div>
      </section>

      {/* Mission Section */}
      <section className="py-16 md:py-20">
        <div className="container mx-auto max-w-6xl px-4">
          <div className="grid gap-12 lg:grid-cols-2 items-center">
            <div className="space-y-6">
              <div className="inline-flex items-center gap-2 text-sm font-mono text-brand-600 uppercase tracking-wider">
                <Scale className="h-4 w-4" />
                Our Mission
              </div>
              <h2 className="text-3xl md:text-4xl font-bold text-foreground">
                Neutral by Design, Not by Accident
              </h2>
              <p className="text-muted-foreground text-lg leading-relaxed">
                In an era of polarized media, Based News was created to give
                readers a clearer picture of political news. We believe that
                informed citizens make better decisions, and informed citizens
                need access to unbiased information.
              </p>
              <p className="text-muted-foreground text-lg leading-relaxed">
                Our platform aggregates news from across the political spectrum,
                analyzes coverage patterns, and presents stories with full
                transparency about sources and potential biases.
              </p>
            </div>

            <div className="grid gap-4 sm:grid-cols-2">
              <Card className="border-brand-600/20">
                <CardHeader>
                  <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-brand-600/10">
                    <Eye className="h-6 w-6 text-brand-600" />
                  </div>
                  <CardTitle className="text-lg">Transparency</CardTitle>
                </CardHeader>
                <CardContent>
                  <CardDescription>
                    Every source is attributed. Every algorithm is explained.
                  </CardDescription>
                </CardContent>
              </Card>

              <Card className="border-brand-600/20">
                <CardHeader>
                  <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-brand-600/10">
                    <Scale className="h-6 w-6 text-brand-600" />
                  </div>
                  <CardTitle className="text-lg">Neutrality</CardTitle>
                </CardHeader>
                <CardContent>
                  <CardDescription>
                    No editorial slant. Just facts and multi-perspective coverage.
                  </CardDescription>
                </CardContent>
              </Card>

              <Card className="border-brand-600/20">
                <CardHeader>
                  <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-brand-600/10">
                    <Cpu className="h-6 w-6 text-brand-600" />
                  </div>
                  <CardTitle className="text-lg">Technology</CardTitle>
                </CardHeader>
                <CardContent>
                  <CardDescription>
                    AI-powered analysis removes human editorial bias.
                  </CardDescription>
                </CardContent>
              </Card>

              <Card className="border-brand-600/20">
                <CardHeader>
                  <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-brand-600/10">
                    <Globe className="h-6 w-6 text-brand-600" />
                  </div>
                  <CardTitle className="text-lg">Accessibility</CardTitle>
                </CardHeader>
                <CardContent>
                  <CardDescription>
                    Free access to unbiased news for everyone.
                  </CardDescription>
                </CardContent>
              </Card>
            </div>
          </div>
        </div>
      </section>

      <Separator className="max-w-6xl mx-auto" />

      {/* Architecture Section */}
      <section className="py-16 md:py-20 bg-muted/20">
        <div className="container mx-auto max-w-6xl px-4">
          <div className="text-center mb-12">
            <div className="inline-flex items-center gap-2 text-sm font-mono text-brand-600 uppercase tracking-wider mb-4">
              <GitBranch className="h-4 w-4" />
              System Architecture
            </div>
            <h2 className="text-3xl md:text-4xl font-bold text-foreground">
              How Headlines Are Aggregated
            </h2>
            <p className="text-muted-foreground mt-4 max-w-2xl mx-auto">
              O
[truncated — 13055 more characters]
```

### app/api/headlines/route.ts

```typescript
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
 * GET /api/headlines
 * Query params:
 *   - skip: number of headlines to skip (default 0)
 *   - take: number of headlines to fetch (default 6)
 */
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const skip = parseInt(searchParams.get("skip") || "0", 10);
  const take = parseInt(searchParams.get("take") || "6", 10);

  try {
    // Fetch headlines and total count in parallel
    const [headlines, totalCount] = await Promise.all([
      prisma.headlines.findMany({
        skip,
        take,
        orderBy: { date: "desc" },
      }),
      prisma.headlines.count(),
    ]);

    // Determine if there are more headlines to load
    const hasMore = skip + headlines.length < totalCount;

    return NextResponse.json({
      headlines,
      hasMore,
      totalCount,
    });
  } catch (error) {
    console.error("Error fetching headlines:", error);
    return NextResponse.json(
      { error: "Failed to fetch headlines" },
      { status: 500 }
    );
  }
}

```

### app/api/publicopinion/route.ts

```typescript
import { NextResponse } from "next/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

interface YouTubeSearchItem {
  id: {
    videoId: string;
  };
  snippet: {
    title: string;
    publishedAt: string;
    thumbnails: {
      default: {
        url: string;
      };
    };
  };
}

interface YouTubeSearchResponse {
  items: YouTubeSearchItem[];
  error?: {
    message: string;
  };
}

/**
 * POST /api/publicopinion
 * Body:
 *   - query: search term for YouTube (required)
 *   - publishedAfter: ISO 8601 date string to filter videos (optional)
 *
 * Returns the 5 most relevant YouTube videos matching the query
 */
export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { query, publishedAfter } = body;

    // Validate required query parameter
    if (!query || typeof query !== "string" || query.trim() === "") {
      return NextResponse.json(
        { error: "Missing or invalid 'query' parameter" },
        { status: 400 }
      );
    }

    // Validate publishedAfter if provided
    if (publishedAfter) {
      const date = new Date(publishedAfter);
      if (isNaN(date.getTime())) {
        return NextResponse.json(
          { error: "Invalid 'publishedAfter' date format. Use ISO 8601 format." },
          { status: 400 }
        );
      }
    }

    // Check for API key
    const apiKey = process.env.YOUTUBE_API_KEY;
    if (!apiKey) {
      console.error("YOUTUBE_API_KEY environment variable is not set");
      return NextResponse.json(
        { error: "YouTube API is not configured" },
        { status: 500 }
      );
    }

    // Build YouTube API URL
    const youtubeUrl = new URL("https://www.googleapis.com/youtube/v3/search");
    youtubeUrl.searchParams.set("part", "snippet");
    youtubeUrl.searchParams.set("key", apiKey);
    youtubeUrl.searchParams.set("q", query.trim());
    youtubeUrl.searchParams.set("type", "video");
    youtubeUrl.searchParams.set("maxResults", "5");
    youtubeUrl.searchParams.set("order", "relevance");

    if (publishedAfter) {
      // Ensure the date is in RFC 3339 format
      const date = new Date(publishedAfter);
      youtubeUrl.searchParams.set("publishedAfter", date.toISOString());
    }

    // Call YouTube Data API
    const response = await fetch(youtubeUrl.toString());
    const data: YouTubeSearchResponse = await response.json();

    if (!response.ok) {
      console.error("YouTube API error:", data.error?.message || "Unknown error");
      return NextResponse.json(
        { error: data.error?.message || "Failed to fetch from YouTube API" },
        { status: response.status }
      );
    }

    // Transform response to return video links
    const videos = (data.items || []).map((item) => ({
      videoId: item.id.videoId,
      title: item.snippet.title,
      url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
      thumbnail: item.snippet.thumbnails.default.url,
      publishedAt: item.snippet.publishedAt,
    }));

    return NextResponse.json({ videos });
  } catch (error) {
    console.error("Error in publicopinion API:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

```

### app/api/headline-sources/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

const N8N_WEBHOOK_URL =
  process.env.N8N_WEBHOOK_URL ||
  "https://kildanicruzhacks.app.n8n.cloud/webhook/headline-sources";

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { headlineId, headline, description, date } = body;

    if (!headline || !description || !date) {
      return NextResponse.json(
        { error: "Missing required fields: headline, description, date" },
        { status: 400 }
      );
    }

    // Check if we have cached sources for this headline
    if (headlineId) {
      const cachedSources = await prisma.headlineSource.findMany({
        where: { headlineId },
        orderBy: { createdAt: "desc" },
      });

      if (cachedSources.length > 0) {
        // Return cached sources in the expected format
        const sources = cachedSources.map((source) => ({
          title: source.title,
          url: source.url,
          source: source.source,
          publishedDate: source.publishedDate?.toISOString() || null,
          bias_rating: source.biasRating,
          bias_analysis: source.biasAnalysis || "",
          excerpt: source.excerpt || "",
        }));

        return NextResponse.json({ sources, cached: true });
      }
    }

    // No cached sources, fetch from n8n
    const response = await fetch(N8N_WEBHOOK_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ headline, description, date }),
    });

    if (!response.ok) {
      const errorText = await response.text();
      console.error("n8n webhook error:", errorText);
      return NextResponse.json(
        { error: "Failed to fetch sources from n8n" },
        { status: 502 }
      );
    }

    const data = await response.json();
    console.log("n8n response:", JSON.stringify(data, null, 2));

    // Save sources to database if we have a headlineId
    if (headlineId && data.sources && Array.isArray(data.sources)) {
      try {
        await prisma.headlineSource.createMany({
          data: data.sources.map((source: {
            title?: string;
            url: string;
            source?: string;
            publishedDate?: string | null;
            bias_rating?: string;
            bias_analysis?: string;
            excerpt?: string;
          }) => ({
            headlineId,
            title: source.title || source.source || "Unknown",
            url: source.url,
            source: source.source || new URL(source.url).hostname,
            biasRating: source.bias_rating || "unknown",
            biasAnalysis: source.bias_analysis || null,
            excerpt: source.excerpt || null,
            publishedDate: source.publishedDate
              ? new Date(source.publishedDate)
              : null,
          })),
        });
        console.log(`Cached ${data.sources.length} sources for headline ${headlineId}`);
      } catch (dbError) {
        console.error("Failed to cache sources:", dbError);
        // Continue even if caching fails
      }
    }

    return NextResponse.json(data);
  } catch (error) {
    console.error("Error in headline-sources API:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

```

[19 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]