Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Contribution Guide: Analysis & Exploration Tools

This guide explains the new features I've contributed

## What You've Built

I've added **three powerful analysis tools** to the Epstein Files Browser:

1. **Full-Text Search** - Find celebrities and documents instantly
2. **Timeline View** - See document activity chronologically
3. **Network Graph** - Visualize celebrity relationships and co-occurrences

## File Structure

### Core Logic (`lib/`)
- **`search-utils.ts`** - All analysis algorithms
- `searchCelebrities()` - Full-text search
- `extractDateFromFilename()` - Date extraction from document names
- `findCelebrityConnections()` - Network relationship discovery
- `getTopCelebrities()` - Ranking by frequency

### UI Components (`components/`)
- **`search-bar.tsx`** - Search input with autocomplete
- **`timeline.tsx`** - Timeline visualization
- **`network-graph.tsx`** - Interactive SVG network graph
- **`features-panel.tsx`** - Main container managing all three views
- **`header.tsx`** - Navigation between Browse and Analysis Tools

### Pages (`app/`)
- **`app/features/page.tsx`** - New features page
- **`app/layout.tsx`** - Updated with navigation header

## How Each Feature Works

### Search Implementation
```
User types → searchCelebrities() → Returns matches → UI displays results
```
- Searches both celebrity names and document filenames
- Case-insensitive, handles partial matches
- Results grouped by type (celebrities vs. documents)
- Limited to 5 results per category for performance

### Timeline Implementation
```
CELEBRITY_DATA → Extract dates from filenames → Group by day → Visualize
```
- Assumes EFTA numbers represent processing order
- Maps to approximate dates (can be improved with real metadata)
- Groups all appearances on each date
- Shows frequency with progress bars

### Network Graph Implementation
```
CELEBRITY_DATA → Find co-occurrences → Build node positions → Render SVG
```
- Finds celebrities appearing in same documents
- Calculates connection strength (frequency)
- Uses circular layout (can be improved with D3.js)
- Renders as SVG with hover/click interactivity

18 changes: 18 additions & 0 deletions app/features/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FeaturesPanel } from "@/components/features-panel";

export default function FeaturesPage() {
return (
<div className="container mx-auto py-8 px-4 max-w-4xl">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Analysis & Exploration Tools</h1>
<p className="text-muted-foreground">
Explore the documents using full-text search, timeline analysis, and network visualization.
</p>
</div>

<FeaturesPanel />
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Analytics } from "@vercel/analytics/next";
import { FilesProvider } from "@/lib/files-context";
import { FileItem } from "@/lib/cache";
import { Header } from "@/components/header";
import { FileItem, PdfManifest } from "@/lib/cache";
import "./globals.css";

Expand Down Expand Up @@ -74,6 +76,8 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<FilesProvider files={files}>
<Header />
<FilesProvider files={files} pdfManifest={pdfManifest}>
<NuqsAdapter>{children}</NuqsAdapter>
</FilesProvider>
Expand Down
185 changes: 185 additions & 0 deletions components/features-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"use client";

import { useState, useMemo } from "react";
import { CELEBRITY_DATA } from "@/lib/celebrity-data";
import { SearchBar } from "@/components/search-bar";
import { Timeline } from "@/components/timeline";
import { NetworkGraph } from "@/components/network-graph";
import { SearchResult } from "@/lib/search-utils";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ChevronLeft } from "lucide-react";

type ViewMode = "search" | "timeline" | "network";

export function FeaturesPanel() {
const [viewMode, setViewMode] = useState<ViewMode>("search");
const [selectedCelebrity, setSelectedCelebrity] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");

// Filter celebrities based on selection
const filteredCelebrities = useMemo(() => {
if (!selectedCelebrity) return CELEBRITY_DATA;
return CELEBRITY_DATA.filter((c) => c.name === selectedCelebrity);
}, [selectedCelebrity]);

const displayCelebrities = useMemo(() => {
if (selectedCelebrity) {
const celeb = CELEBRITY_DATA.find((c) => c.name === selectedCelebrity);
return celeb ? [celeb] : CELEBRITY_DATA.slice(0, 50);
}
return CELEBRITY_DATA.slice(0, 100);
}, [selectedCelebrity]);

const handleSearchResult = (result: SearchResult) => {
if (result.type === "celebrity") {
setSelectedCelebrity(result.value);
setSearchQuery(result.name);
}
};

const handleSelectCelebrity = (name: string) => {
setSelectedCelebrity(name);
setSearchQuery(name);
};

return (
<div className="w-full space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
{selectedCelebrity && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedCelebrity(null);
setSearchQuery("");
}}
>
<ChevronLeft className="w-4 h-4" />
</Button>
)}
<h2 className="text-lg font-semibold">
{selectedCelebrity || "Analysis Tools"}
</h2>
</div>

{/* Search Bar */}
<SearchBar
onSelectResult={handleSearchResult}
placeholder="Search by celebrity or document..."
/>

{/* View Mode Tabs */}
<div className="flex gap-2 border-b">
<Button
variant="ghost"
size="sm"
className={cn(
"rounded-none border-b-2 border-transparent",
viewMode === "search" && "border-primary"
)}
onClick={() => setViewMode("search")}
>
Full-Text Search
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"rounded-none border-b-2 border-transparent",
viewMode === "timeline" && "border-primary"
)}
onClick={() => setViewMode("timeline")}
>
Timeline
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
"rounded-none border-b-2 border-transparent",
viewMode === "network" && "border-primary"
)}
onClick={() => setViewMode("network")}
>
Network Graph
</Button>
</div>

{/* Content Area */}
<div className="min-h-[400px]">
{viewMode === "search" && (
<SearchView
celebrities={displayCelebrities}
onSelectCelebrity={handleSelectCelebrity}
/>
)}
{viewMode === "timeline" && (
<Timeline
celebrities={displayCelebrities}
onSelectCelebrity={handleSelectCelebrity}
/>
)}
{viewMode === "network" && (
<NetworkGraph
celebrities={displayCelebrities}
onSelectCelebrity={handleSelectCelebrity}
maxNodes={selectedCelebrity ? 20 : 15}
/>
)}
</div>
</div>
);
}

/**
* Search results view showing all matching celebrities
*/
function SearchView({
celebrities,
onSelectCelebrity,
}: {
celebrities: typeof CELEBRITY_DATA;
onSelectCelebrity: (name: string) => void;
}) {
const sorted = useMemo(
() => [...celebrities].sort((a, b) => b.count - a.count),
[celebrities]
);

return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Showing {sorted.length} of {CELEBRITY_DATA.length} celebrities
</div>

<div className="grid grid-cols-1 gap-2 max-h-[600px] overflow-y-auto">
{sorted.map((celebrity) => (
<button
key={celebrity.name}
onClick={() => onSelectCelebrity(celebrity.name)}
className={cn(
"flex items-center justify-between p-3 rounded-lg",
"bg-secondary/50 hover:bg-secondary transition-colors",
"border border-border hover:border-primary/50"
)}
>
<div className="text-left">
<div className="font-semibold">{celebrity.name}</div>
<div className="text-xs text-muted-foreground">
{celebrity.count} document{celebrity.count !== 1 ? "s" : ""}
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-primary">
{celebrity.appearances.length}
</div>
<div className="text-xs text-muted-foreground">appearances</div>
</div>
</button>
))}
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions components/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";

export function Header() {
const pathname = usePathname();

return (
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-40">
<div className="container flex h-14 max-w-screen-2xl items-center">
<Link href="/" className="flex items-center space-x-2 mr-auto">
<span className="font-bold text-lg">Epstein Files Browser</span>
</Link>

<nav className="flex items-center space-x-1 text-sm font-medium">
<Link
href="/"
className={cn(
"px-3 py-2 rounded-md transition-colors hover:bg-accent hover:text-accent-foreground",
pathname === "/" ? "bg-accent text-accent-foreground" : "text-muted-foreground"
)}
>
Browse
</Link>
<Link
href="/features"
className={cn(
"px-3 py-2 rounded-md transition-colors hover:bg-accent hover:text-accent-foreground",
pathname === "/features" ? "bg-accent text-accent-foreground" : "text-muted-foreground"
)}
>
Analysis Tools
</Link>
</nav>
</div>
</header>
);
}
Loading