# Project export: NewsReel

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: UC Berkeley AI Hackathon 2026
- Tagline: Tech news is buzzwordy, takes too long to read, and pumps out new content at a pace faster than your thoughts can keep up. Introducing NewsReel: Say goodbye to your attention span.
- Devpost: https://devpost.com/software/newsreel-18x95m
- GitHub: https://github.com/Wilson730/Newsreel
- Video: https://www.youtube.com/embed/hp04L9P73fU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Wilson Lau (1 commits), Cursor (1 commits)

## Devpost submission (written by the team)

### Inspiration

Tech develops at a rapid pace and there is a need to stay updated for professionals. Reading tech newsletters like TLDR feels like homework. The information is useful but nobody actually has time to sit down and read paragraphs. We wanted to see if we could make the same content more watchable and honestly more fun.

### What it does

NewsReel pulls daily stories from TLDR and turns each one into a short AI-generated cartoon video. Swipe right to save a story and link to the full article, swipe left to skip.

### How we built it

Scraped TLDR for daily stories, used Claude to write a video script for each one, then called the Pika 2.2 API via fal.ai to generate the cartoon video. FastAPI backend, Next.js frontend, stories stored in a JSON file. Videos are pre-generated in a batch so the feed loads instantly.

### Challenges we ran into

Getting Pika to consistently match a visual style took a lot of prompt iteration. More time was spent than we should have figuring out that pika.art and fal.ai are two different things, one is the consumer app, the other is the actual API.

### Accomplishments we're proud of

Getting the full pipeline working end to end scraping a real newsletter, generating a script with Claude, and producing an actual cartoon video with Pika

### What we learned

Writing good prompts for video generation is harder than it looks. Specificity is important, vague prompts get generic videos that might not match the actual news content.

### What's next

Support for any newsletter URL, more theme options, and a mobile app where the swipe feels more natural.

## README (from the GitHub repository)

# Newsreel

Tech news as a Tinder-style video feed. TLDR newsletter → Claude scripts → Pika videos → swipe through in 5 minutes.

## Stack

| Layer | Tech |
|-------|------|
| Scraper | Python + BeautifulSoup → tldr.tech/tech |
| Scripts | Claude Sonnet 4.6 |
| Videos | Pika (fal.ai or pika.me dev API) |
| Backend | FastAPI — 2 endpoints |
| Storage | `stories.json` (no database) |
| Frontend | Next.js + Tailwind — full viewport mobile feel |

## Quick start

### 1. Backend

```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Set ANTHROPIC_API_KEY

uvicorn app.main:app --reload --port 8000
```

Demo stories are pre-loaded in `data/stories.json` — frontend works immediately (shows scripts until videos are generated).

### 2. Generate scripts (Hours 1–4)

```bash
# Scrape TLDR + Claude scripts, print to terminal
python scripts/scrape_and_script.py --limit 5

# Write to stories.json
python scripts/scrape_and_script.py --limit 5 --write

# Fallback if TLDR blocks scraping
python scripts/scrape_and_script.py --demo --write
```

### 3. Generate videos (Hours 4–7, at Pika booth)

```bash
# Set FAL_KEY or PIKA_API_KEY in .env
python scripts/generate_videos.py
python scripts/generate_videos.py --id 1   # one story
```

### 4. Frontend

```bash
cd frontend
npm install
npm run dev
```

Open [http://localhost:3000](http://localhost:3000)

## API

```
GET  /stories            → all stories from stories.json
POST /stories/{id}/save  → flip saved: true
```

## Frontend controls

| Action | How |
|--------|-----|
| Skip | ← key, ✕ button, swipe/drag left |
| Save | → key, ✓ button, swipe/drag right |
| Saved library | Top-right link or `/saved` |

## Build order (from guide)

1. **Hours 1–4** — `scrape_and_script.py`, get 5 good scripts in terminal
2. **Hours 4–7** — Pika booth, `generate_videos.py`, tweak pika_prompts
3. **Hours 7–12** — Frontend (done), polish animations
4. **Hours 12–15** — Deploy, rehearse demo

## Demo script

> "Tech news is exhausting. TLDR sends 20 stories a day and reading it feels like homework. So we automated the part everyone actually wants — the video explainer."

Let first video play 10 seconds. Click right → next slides in. Click left on one. Open Saved → Read Article.

> "Newsreel. Tech news you'll actually watch."

## Risks

| Risk | Fix |
|------|-----|
| TLDR blocks scraping | `--demo` flag + hardcoded stories.json |
| Pika slow | Pre-generate before demo, never on-demand |
| Videos look bad | Tune pika_prompt quality, test 10+ |
| No API keys yet | Frontend shows script text overlay (works without video) |


## Detected evidence (automated analysis)

Indexed codebase: 39 recognized source files, 123 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (47 of 47)

```
.gitignore
backend/.env.example
backend/app/__init__.py
backend/app/config.py
backend/app/dates.py
backend/app/feed_loader.py
backend/app/filters.py
backend/app/image_store.py
backend/app/main.py
backend/app/newsletters.py
backend/app/pika_client.py
backend/app/scraper.py
backend/app/script_generator.py
backend/app/showcase.py
backend/app/stories_store.py
backend/app/video_service.py
backend/app/video_store.py
backend/data/stories.json
backend/requirements.txt
backend/scripts/attach_video.py
backend/scripts/fetch_thumbnails.py
backend/scripts/generate_demo.py
backend/scripts/generate_videos.py
backend/scripts/pregenerate_week.py
backend/scripts/scrape_and_script.py
frontend/.env.local
frontend/.env.local.example
frontend/next-env.d.ts
frontend/next.config.js
frontend/package.json
frontend/postcss.config.js
frontend/src/app/globals.css
frontend/src/app/layout.tsx
frontend/src/app/page.tsx
frontend/src/app/saved/page.tsx
frontend/src/components/CardDateBadge.tsx
frontend/src/components/DatePicker.tsx
frontend/src/components/FeedControls.tsx
frontend/src/components/StoryMedia.tsx
frontend/src/components/SwipeActionButton.tsx
frontend/src/components/SwipeCard.tsx
frontend/src/hooks/useStoryThumbnails.ts
frontend/src/hooks/useVideoPrefetch.ts
frontend/src/lib/api.ts
frontend/tailwind.config.ts
frontend/tsconfig.json
README.md
```

### Dependencies

- backend/requirements.txt: anthropic@>=0.40.0, beautifulsoup4@>=4.12.0, fastapi@>=0.115.0, httpx@>=0.27.0, python-dotenv@>=1.0.0, uvicorn[standard]@>=0.32.0
- frontend/package.json: @types/node@^22.10.0, @types/react@^19.0.0, @types/react-dom@^19.0.0, autoprefixer@^10.4.20, next@^15.1.0, postcss@^8.4.49, react@^19.0.0, react-dom@^19.0.0, tailwindcss@^3.4.16, typescript@^5.7.2

### Recent commits (newest first)

- Initial commit: Newsreel hackathon project.

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

### backend/requirements.txt

```
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
anthropic>=0.40.0
beautifulsoup4>=4.12.0
httpx>=0.27.0
python-dotenv>=1.0.0

```

### frontend/package.json

```
{
  "name": "newsreel-frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^15.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.10.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.16",
    "typescript": "^5.7.2"
  }
}

```

### backend/app/main.py

```python
from datetime import date

from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from app.config import get_settings
from app.dates import Period, period_bounds
from app.feed_loader import load_stories_for_range
from app.stories_store import (
    feed_meta,
    get_story,
    list_newsletters,
    query_stories,
    save_story,
    unsave_story,
)
from app.image_store import fetch_and_cache_thumbnail
from app.video_service import generate_story_video, queue_story_video, settings_has_pika

app = FastAPI(title="Newsreel")

settings = get_settings()
settings.videos_dir.mkdir(parents=True, exist_ok=True)
settings.images_dir.mkdir(parents=True, exist_ok=True)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.mount(
    "/media/videos",
    StaticFiles(directory=str(settings.videos_dir)),
    name="videos",
)

app.mount(
    "/media/images",
    StaticFiles(directory=str(settings.images_dir)),
    name="images",
)


class LoadFeedRequest(BaseModel):
    period: Period = "week"
    anchor: str | None = None
    offset: int = Field(0, ge=0)
    filters: list[str] = Field(default_factory=list)


@app.get("/")
async def root():
    return {
        "app": "Newsreel",
        "status": "ok",
        "pika_configured": settings_has_pika(),
    }


@app.get("/newsletters")
async def get_newsletters():
    return list_newsletters()


@app.get("/feed/meta")
async def get_feed_meta(
    period: Period = Query("week"),
    offset: int = Query(0, ge=0),
    anchor: str | None = Query(None),
    filters: str = Query(""),
):
    filter_list = [f.strip() for f in filters.split(",") if f.strip()]
    return feed_meta(period, offset, anchor, filter_list)


@app.get("/stories")
async def get_stories(
    period: Period = Query("week"),
    offset: int = Query(0, ge=0),
    anchor: str | None = Query(None),
    filters: str = Query(""),
    saved: bool | None = Query(None),
):
    filter_list = [f.strip() for f in filters.split(",") if f.strip()]
    stories = query_stories(
        period=period,
        offset=offset,
        anchor=anchor,
        filters=filter_list,
        saved_only=saved is True,
        unsaved_only=saved is False,
    )
    return {
        "meta": feed_meta(period, offset, anchor, filter_list),
        "stories": stories,
        "filters_applied": filter_list,
    }


@app.post("/feed/load")
async def load_feed(body: LoadFeedRequest):
    """Scrape TLDR newsletters for the requested period and merge into stories.json."""
    try:
        anchor_date = date.fromisoformat(body.anchor[:10]) if body.anchor else None
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid anchor date")

    start, end = period_bounds(body.period, body.offset, anchor_date)
    try:
        result = load_stories_for_range(start, end, body.filters)
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"Scrape failed: {e}") from e

    stories = query_stories(
        period=body.period,
        offset=body.offset,
        anchor=body.anchor,
        filters=body.filters,
        unsaved_only=False,
    )
    meta = feed_meta(body.period, body.offset, body.anchor, body.filters)
    return {
        "meta": meta,
        "stories": stories,
        "load": result,
        "filters_applied": body.filters,
    }


@app.get("/stories/{story_id}")
async def get_story_endpoint(story_id: int):
    story = get_story(story_id)
    if not story:
        raise HTTPException(status_code=404, detail="Story not found")
    return story


@app.post("/stories/{story_id}/fetch-thumbnail")
async def fetch_thumbnail_endpoint(story_id: int):
    """Fetch og:image from article URL and cache locally."""
    story = get_story(story_id)
    if not story:
        raise HTTPException(status_code=404, detail="Story not found")
    from app.stories_store import update_story

    path = await fetch_and_cache_thumbnail(story_id, story["article_url"])
    if path:
        return update_story(story_id, {"image_url": path})
    return story


@app.post("/stories/{story_id}/prefetch-video")
async def prefetch_video_endpoint(story_id: int):
    """Queue video generation without blocking — for cards ahead in the feed."""
    if not get_story(story_id):
        raise HTTPException(status_code=404, detail="Story not found")
    return queue_story_video(story_id)


@app.post("/stories/{story_id}/generate-video")
async def generate_video_endpoint(story_id: int, force: bool = Query(False)):
    """Generate a Pika video for one story and save the MP4 locally."""
    if not get_story(story_id):
        raise HTTPException(status_code=404, detail="Story not found")
    try:
        story = await generate_story_video(story_id, force=force)
        return story
    except RuntimeError as e:
        raise HTTPException(status_code=503, detail=str(e)) from e
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"Video generation failed: {e}") from e


@app.post("/stories/{story_id}/save")
async def save_story_endpoint(story_id: int):
    story = save_story(story_id)
    if not story:
        raise HTTPException(status_code=404, detail="Story not found")
    return story


@app.delete("/stories/{story_id}/save")
async def unsave_story_endpoint(story_id: int):
    story = unsave_story(story_id)
    if not story:
        raise HTTPException(status_code=404, detail="Story not found")
    return story

```

### frontend/src/app/layout.tsx

```typescript
import type { Metadata, Viewport } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Newsreel",
  description: "Tech news as a video feed",
};

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
  themeColor: "#000000",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="antialiased">{children}</body>
    </html>
  );
}

```

### frontend/src/app/page.tsx

```typescript
"use client";

import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import {
  fetchFeed,
  loadFeedFromSource,
  saveStory,
  shiftAnchor,
  todayIso,
  unsaveStory,
  type FeedMeta,
  type Period,
  type Story,
} from "@/lib/api";
import { FeedControls } from "@/components/FeedControls";
import { SwipeActionButton } from "@/components/SwipeActionButton";
import { SwipeCard, SWIPE_THRESHOLD, type SwipeCardHandle } from "@/components/SwipeCard";
import { useStoryThumbnails } from "@/hooks/useStoryThumbnails";
import { useVideoPrefetch } from "@/hooks/useVideoPrefetch";

const LS_FILTERS = "newsreel_filters";
const LS_SKIPPED = "newsreel_skipped";

type HistoryEntry = {
  index: number;
  story: Story;
  action: "skip" | "save";
};

function getSkippedKeys(): Set<string> {
  if (typeof window === "undefined") return new Set();
  try {
    return new Set(JSON.parse(localStorage.getItem(LS_SKIPPED) || "[]"));
  } catch {
    return new Set();
  }
}

function markSkipped(key: string) {
  const s = getSkippedKeys();
  s.add(key);
  localStorage.setItem(LS_SKIPPED, JSON.stringify([...s]));
}

function unmarkSkipped(key: string) {
  const s = getSkippedKeys();
  s.delete(key);
  localStorage.setItem(LS_SKIPPED, JSON.stringify([...s]));
}

function clearSkippedForStories(storyList: Story[]) {
  const skipped = getSkippedKeys();
  for (const s of storyList) {
    skipped.delete(s.dedupe_key);
  }
  localStorage.setItem(LS_SKIPPED, JSON.stringify([...skipped]));
}

export default function FeedPage() {
  const [stories, setStories] = useState<Story[]>([]);
  const [meta, setMeta] = useState<FeedMeta | null>(null);
  const [filters, setFilters] = useState<string[]>([]);
  const [period, setPeriod] = useState<Period>("week");
  const [anchor, setAnchor] = useState(todayIso);
  const [index, setIndex] = useState(0);
  const [history, setHistory] = useState<HistoryEntry[]>([]);
  const [loading, setLoading] = useState(true);
  const [loadingSource, setLoadingSource] = useState(false);
  const [key, setKey] = useState(0);
  const cardRef = useRef<SwipeCardHandle>(null);
  const busy = useRef(false);
  const [dragX, setDragX] = useState(0);
  const [clickFill, setClickFill] = useState<{ dir: "save" | "skip" | "rewind"; p: number } | null>(null);
  const clickAnimRef = useRef<number | null>(null);
  const clickAnimating = useRef(false);

  useEffect(() => {
    const saved = localStorage.getItem(LS_FILTERS);
    if (saved) {
      try {
        setFilters(JSON.parse(saved));
      } catch {
        /* ignore */
      }
    }
  }, []);

  const applyFeed = useCallback((data: { stories: Story[]; meta: FeedMeta }, includeSkipped = false) => {
    const skipped = getSkippedKeys();
    const filtered = includeSkipped
      ? data.stories
      : data.stories.filter((s) => !skipped.has(s.dedupe_key));
    setStories(filtered);
    setMeta(data.meta);
    return data.stories;
  }, []);

  const loadFeed = useCallback(
    async (opts?: { includeSkipped?: boolean }) => {
      setLoading(true);
      if (!opts?.includeSkipped) {
        setIndex(0);
        setHistory([]);
        setKey((k) => k + 1);
      }
      busy.current = false;
      try {
        const data = await fetchFeed({ period, anchor, filters, saved: false });
        return applyFeed(data, opts?.includeSkipped);
      } catch (e) {
        console.error(e);
        setStories([]);
        return [];
      } finally {
        setLoading(false);
      }
    },
    [period, anchor, filters, applyFeed]
  );

  useEffect(() => {
    loadFeed();
  }, [loadFeed]);

  const handleFiltersChange = (next: string[]) => {
    setFilters(next);
    localStorage.setItem(LS_FILTERS, JSON.stringify(next));
  };

  const loadFromTldr = useCallback(async () => {
    setLoadingSource(true);
    busy.current = false;
    try {
      const data = await loadFeedFromSource({ period, anchor, filters });
      clearSkippedForStories(data.stories);
      setStories(data.stories);
      setMeta(data.meta);
      setIndex(0);
      setHistory([]);
      setKey((k) => k + 1);
    } catch (e) {
      console.error(e);
      alert(e instanceof Error ? e.message : "Failed to load from TLDR");
    } finally {
      setLoadingSource(false);
      setLoading(false);
    }
  }, [period, anchor, filters]);

  const current = stories[index];
  const next = stories[index + 1];
  const canRewind = history.length > 0;

  const patchStory = useCallback((updated: Story) => {
    setStories((prev) => prev.map((s) => (s.id === updated.id ? updated : s)));
  }, []);

  useVideoPrefetch(stories, index);
  useStoryThumbnails(stories, index, patchStory);

  const goNext = useCallback((entry: HistoryEntry) => {
    setHistory((h) => [...h, entry]);
    setIndex((i) => i + 1);
    setKey((k) => k + 1);
    busy.current = false;
  }, []);

  const handleSave = useCallback(async () => {
    if (!current || busy.current) return;
    busy.current = true;
    await saveStory(current.id);
    goNext({ index, story: current, action: "save" });
  }, [current, index, goNext]);

  const handleSkip = useCallback(() => {
    if (!current || busy.current) return;
    busy.current = true;
    markSkipped(current.dedupe_key);
    goNext({ index, story: current, action: "skip" });
  }, [current, index, goNext]);

  const handleRewind = useCallback(async () => {
    if (!canRewind || busy.current) return;
    const last = history[history.length - 1];
    busy.current = true;

    try {
      if (last.action === "save") {
        await unsaveStory(last.story.id);
      } else {
        unmarkSkipped(last.story.dedupe_key);
      }
      setHistory((h) => h.slice(0, -1));
      setIndex(last.index);
      setKey((k) => k + 1);
    } finally {
      busy.current = false;
    }
  }, [canRewind, history]);

  const restartFeed = useCallback(async () => {
    busy.current = false;
    const all = await fetchFeed({ period, anchor, filters, saved: false });
    clearSkippedFor
[truncated — 10073 more characters]
```

### frontend/src/app/saved/page.tsx

```typescript
"use client";

import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CardDateBadge } from "@/components/CardDateBadge";
import { fetchFeed, fetchStoryThumbnail, storyImageUrl, storyVideoUrl, unsaveStory, type Story } from "@/lib/api";

export default function SavedPage() {
  const [saved, setSaved] = useState<Story[]>([]);
  const [loading, setLoading] = useState(true);
  const [removing, setRemoving] = useState<number | null>(null);
  const thumbFetched = useRef(new Set<number>());

  useEffect(() => {
    document.body.classList.add("scrollable-page");
    return () => document.body.classList.remove("scrollable-page");
  }, []);

  const loadSaved = useCallback(async () => {
    setLoading(true);
    try {
      const data = await fetchFeed({ saved: true });
      setSaved(data.stories);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    loadSaved();
  }, [loadSaved]);

  useEffect(() => {
    for (const story of saved) {
      if (thumbFetched.current.has(story.id)) continue;
      if (storyVideoUrl(story) && story.video_status === "ready") continue;
      if (storyImageUrl(story)) continue;
      thumbFetched.current.add(story.id);
      fetchStoryThumbnail(story.id).then((updated) => {
        if (updated?.image_url) {
          setSaved((prev) => prev.map((s) => (s.id === updated.id ? updated : s)));
        }
      });
    }
  }, [saved]);

  /** Newest articles first (by publish date), then save time */
  const sorted = useMemo(
    () =>
      [...saved].sort((a, b) => {
        const pub = b.published_date.localeCompare(a.published_date);
        if (pub !== 0) return pub;
        return (b.saved_at ?? "").localeCompare(a.saved_at ?? "");
      }),
    [saved]
  );

  const handleRemove = async (id: number) => {
    setRemoving(id);
    try {
      await unsaveStory(id);
      setSaved((prev) => prev.filter((s) => s.id !== id));
    } catch (e) {
      console.error(e);
    } finally {
      setRemoving(null);
    }
  };

  return (
    <div className="min-h-dvh bg-zinc-950 text-white flex flex-col">
      <header className="sticky top-0 z-10 shrink-0 bg-zinc-950/95 backdrop-blur border-b border-zinc-800 px-4 py-4 flex items-center gap-4 max-w-md mx-auto w-full">
        <Link href="/" className="text-zinc-400 hover:text-white text-sm">
          ← Feed
        </Link>
        <h1 className="text-lg font-semibold">Saved</h1>
        <span className="ml-auto text-xs text-zinc-500">{saved.length} stories</span>
      </header>

      <main className="flex-1 overflow-y-auto overscroll-y-contain min-h-0 w-full max-w-md mx-auto">
        {loading ? (
          <p className="p-8 text-zinc-400 text-center text-sm">Loading...</p>
        ) : saved.length === 0 ? (
          <div className="p-8 text-center text-zinc-400 min-h-[70vh] flex flex-col items-center justify-center">
            <p className="text-lg mb-2 text-white">Nothing saved yet.</p>
            <p className="text-sm mb-4 max-w-xs">Swipe right on stories in the feed to save them here.</p>
            <Link href="/" className="px-6 py-2.5 rounded-full bg-white text-black text-sm font-medium">
              Back to feed
            </Link>
          </div>
        ) : (
          <div className="pb-24 px-4 pt-3">
            <p className="text-[10px] text-zinc-600 mb-3 text-center">Sorted by story date</p>
            <div className="grid grid-cols-2 gap-3 w-full">
              {sorted.map((story) => (
                <a
                  key={story.id}
                  href={story.article_url}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="w-full rounded-xl overflow-hidden bg-zinc-900 border border-zinc-800 flex flex-col group relative hover:border-zinc-600 transition-colors"
                >
                  <button
                    type="button"
                    onClick={(e) => {
                      e.preventDefault();
                      e.stopPropagation();
                      handleRemove(story.id);
                    }}
                    disabled={removing === story.id}
                    aria-label="Remove saved story"
                    className="absolute top-1.5 right-1.5 z-20 w-6 h-6 rounded-full bg-black/70 text-zinc-300 hover:bg-red-600 hover:text-white flex items-center justify-center text-xs opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity disabled:opacity-50"
                  >
                    ✕
                  </button>
                  <div className="aspect-[9/16] w-full bg-black relative">
                    <CardDateBadge dateIso={story.published_date.slice(0, 10)} />
                    {storyVideoUrl(story) && story.video_status === "ready" ? (
                      <video
                        src={storyVideoUrl(story)!}
                        className="w-full h-full object-contain"
                        muted
                        loop
                        playsInline
                        onMouseEnter={(e) => e.currentTarget.play()}
                        onMouseLeave={(e) => {
                          e.currentTarget.pause();
                          e.currentTarget.currentTime = 0;
                        }}
                      />
                    ) : storyImageUrl(story) ? (
                      <div className="relative w-full h-full bg-zinc-950 overflow-hidden">
                        <img
                          src={storyImageUrl(story)!}
                          alt=""
                          aria-hidden
                          className="absolute inset-0 w-full h-full object-cover scale-110 blur-xl opacity-30"
                        />
                        <div className="absolute inset-0 flex items-center justify-center p-1">
                          <img
                            src={storyImageUrl(story)!}
   
[truncated — 1264 more characters]
```

### frontend/postcss.config.js

```javascript
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

```

### frontend/next.config.js

```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: { unoptimized: true },
};

module.exports = nextConfig;

```

### frontend/next-env.d.ts

```typescript
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

```

### frontend/tailwind.config.ts

```typescript
import type { Config } from "tailwindcss";

const config: Config = {
  content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
  theme: {
    extend: {
      fontFamily: {
        sans: ["system-ui", "-apple-system", "BlinkMacSystemFont", "sans-serif"],
      },
    },
  },
  plugins: [],
};
export default config;

```

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