diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6383a632418d52f1dd06da8c8f8f8f0b4773d9f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js +/.next/ +/out/ + +# Production +/build + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Local env files +.env*.local +.env + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..165c08091aa59ca40078643d7a2b54bbee27f9b1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,152 @@ +# Deployment Guide for Hugging Face Spaces + +This guide explains how to deploy the Datasets Navigator app to Hugging Face Spaces using Docker. + +## Prerequisites + +- Docker installed and running (for local testing) +- Hugging Face account ([sign up here](https://huggingface.co/join)) + +## Files Created for Docker Deployment + +- `Dockerfile` - Multi-stage Docker build configuration +- `.dockerignore` - Excludes unnecessary files from Docker build +- `docker-compose.yml` - For local testing +- `README.md` - Updated with HF Spaces metadata +- `next.config.mjs` - Updated with `output: 'standalone'` for Docker + +## Local Testing (Optional) + +Before deploying to Hugging Face Spaces, you can test the Docker build locally: + +### Option 1: Using Docker directly + +```bash +# Build the image +docker build -t datasets-navigator . + +# Run the container +docker run -p 7860:7860 datasets-navigator + +# Access the app at http://localhost:7860 +``` + +### Option 2: Using Docker Compose + +```bash +# Build and run +docker-compose up --build + +# Access the app at http://localhost:7860 + +# Stop with Ctrl+C, or in detached mode: +docker-compose down +``` + +## Deploying to Hugging Face Spaces + +### Method 1: Via Hugging Face Web Interface + +1. Go to [Hugging Face Spaces](https://huggingface.co/spaces) +2. Click "Create new Space" +3. Fill in the details: + - **Space name**: Choose a name (e.g., `datasets-navigator`) + - **License**: Select appropriate license + - **SDK**: Select "Docker" + - **Hardware**: Start with "CPU basic" (can upgrade later) +4. Clone the Space repository: + ```bash + git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME + ``` +5. Copy all files from `datasets_navigator_app/` to the cloned repository +6. Push to Hugging Face: + ```bash + cd YOUR_SPACE_NAME + git add . + git commit -m "Initial commit: Datasets Navigator app" + git push + ``` + +### Method 2: Push Existing Repository + +If you already have a Space created: + +```bash +# Add Hugging Face as a remote +cd datasets_navigator_app +git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME + +# Push to Hugging Face +git add . +git commit -m "Deploy Datasets Navigator to HF Spaces" +git push hf main +``` + +## Configuration + +The app is configured via `README.md` frontmatter: + +```yaml +--- +title: Prova +emoji: 🍽️ +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +--- +``` + +You can customize: +- `title`: The Space's display name +- `emoji`: Icon shown in the Space +- `colorFrom` / `colorTo`: Gradient colors for the Space card +- `app_port`: Must match the port in Dockerfile (7860) + +## Environment Variables & Secrets + +If your app needs environment variables or secrets: + +1. Go to your Space settings on Hugging Face +2. Navigate to "Variables and secrets" +3. Add variables (public) or secrets (private) + +They will be available as environment variables in the Docker container. + +## Troubleshooting + +### Build Fails + +- Check the build logs in the Space's "Logs" tab +- Verify all dependencies are in `package.json` +- Ensure `package-lock.json` is committed + +### App Won't Start + +- Verify the app listens on `0.0.0.0:7860` +- Check the Docker logs for errors +- Ensure user permissions are correct (user ID 1000) + +### Port Issues + +- The app MUST listen on the port specified in `app_port` (7860) +- Verify `ENV PORT=7860` in Dockerfile +- Next.js reads the PORT environment variable automatically + +## Upgrading Space Hardware + +If you need more resources: + +1. Go to Space settings +2. Select "Hardware" tab +3. Choose upgrade (CPU/GPU options available) +4. Confirm the upgrade + +Note: GPU and upgraded hardware have associated costs. + +## References + +- [HF Spaces Docker Documentation](https://huggingface.co/docs/hub/spaces-sdks-docker) +- [Next.js Docker Documentation](https://nextjs.org/docs/app/building-your-application/deploying#docker-image) +- [Hugging Face Spaces Examples](https://huggingface.co/spaces) + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..65509679c1ac776ee14026756c1c172d10d472b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Use Node.js 18 Alpine for a lightweight image +FROM node:18-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build Next.js application +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=7860 +ENV HOSTNAME="0.0.0.0" + +# Use the existing node user (UID 1000) which matches HF Spaces requirement +# Copy necessary files from builder +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static + +# Switch to the node user (UID 1000) +USER node + +# Expose the port the app runs on +EXPOSE 7860 + +# Start the application +CMD ["node", "server.js"] + diff --git a/README copy.md b/README copy.md new file mode 100644 index 0000000000000000000000000000000000000000..e03e741cbb515973de8ee13e0ef0f42eaf5e6fda --- /dev/null +++ b/README copy.md @@ -0,0 +1,109 @@ +--- +title: Awesome Food Allergy Datasets +emoji: 🍽️ +colorFrom: green +colorTo: yellow +sdk: docker +app_port: 7860 +--- + +# Datasets Navigator + +A modern web application for navigating and exploring food allergy datasets, built with Next.js 14, React, TypeScript, and Tailwind CSS. + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or higher) +- [pnpm](https://pnpm.io/) (v8 or higher) + +If you don't have pnpm installed, you can install it with: +```bash +npm install -g pnpm +``` + +## Getting Started + +### Installation + +Install all dependencies: + +```bash +pnpm install +``` + +### Development + +Run the development server: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +The application will automatically reload when you make changes to the source files. + +### Building for Production + +Create an optimized production build: + +```bash +pnpm build +``` + +Start the production server: + +```bash +pnpm start +``` + +### Linting + +Run the linter to check for code quality issues: + +```bash +pnpm lint +``` + +## Tech Stack + +- **Framework:** [Next.js 14](https://nextjs.org/) with App Router +- **Language:** [TypeScript](https://www.typescriptlang.org/) +- **Styling:** [Tailwind CSS](https://tailwindcss.com/) +- **UI Components:** [Radix UI](https://www.radix-ui.com/) +- **Icons:** [Lucide React](https://lucide.dev/) +- **Charts:** [Recharts](https://recharts.org/) +- **Forms:** [React Hook Form](https://react-hook-form.com/) with [Zod](https://zod.dev/) + +## Project Structure + +``` +datasets_navigator/ +├── app/ # Next.js App Router pages +├── components/ # React components +│ └── ui/ # Reusable UI components +├── hooks/ # Custom React hooks +├── lib/ # Utility functions +├── public/ # Static assets +└── styles/ # Global styles +``` + +## Features + +- Interactive dataset exploration interface +- Modern, responsive design +- Dark/light theme support +- Component-based architecture +- Type-safe with TypeScript + +## Contributing + +1. Make your changes in a feature branch +2. Run `pnpm lint` to ensure code quality +3. Test your changes with `pnpm dev` +4. Submit a pull request + +## License + +See the [LICENSE](../../LICENSE) file for details. + diff --git a/README.md b/README.md index d745a21bcd1bcd91b29b247c23ef63a4dbe9e586..b0a3fb0b554db0ce45beead5dbe4f5db8ad0f187 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ --- -title: Awesome Food Allergy Datasets Viewer -emoji: 🐠 -colorFrom: yellow -colorTo: blue +title: Awesome Food Allergy Datasets +emoji: 🍽️ +colorFrom: green +colorTo: yellow sdk: docker pinned: false -license: apache-2.0 -short_description: Navigate the Awesome Food Allergy Datasets collec --- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..e716e9c39b91e615837414dc755b6a65bd14858a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,125 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..42d39db57d8dd4031ac10b69f094b825a820bfc4 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,36 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import { JetBrains_Mono as JetBrainsMono } from "next/font/google" +import { Analytics } from "@vercel/analytics/next" +import { Suspense } from "react" +import "./globals.css" +import { ThemeProvider } from "@/theme-provider" + +const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" }) +const jetBrainsMono = JetBrainsMono({ subsets: ["latin"], variable: "--font-jetbrains-mono", display: "swap" }) + +export const metadata: Metadata = { + title: "Awesome Food Allergy Research Datasets", + description: "A curated collection of datasets for advancing food allergy research", + generator: "v0.app", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + + {children} + + + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25265d1fa41eb403300322f7276d774e2d5fc85b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { DatasetAtlas } from "@/components/dataset-atlas" + +export default function Home() { + return +} diff --git a/components.json b/components.json new file mode 100644 index 0000000000000000000000000000000000000000..4ee62ee10547066d7ee70f94d79c786226c3c12c --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/dataset-atlas.tsx b/components/dataset-atlas.tsx new file mode 100644 index 0000000000000000000000000000000000000000..512b3c8a8ecd65426be5347ccecc3a35b2bb1df7 --- /dev/null +++ b/components/dataset-atlas.tsx @@ -0,0 +1,597 @@ +"use client" + +import { useState, useMemo, useEffect, useRef } from "react" +import { Search, ExternalLink, Mail, FileText, X, ChevronDown, BarChart3 } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { DatasetStatistics } from "@/components/dataset-statistics" +import { ScrollToTop } from "@/components/scroll-to-top" + +interface Dataset { + Name: string + Category: string + Description: string + Task: string + Data_Type: string + Source: string + "Paper link": string + Availability: string + Contact: string +} + +function getCategoryColor(category: string): { bg: string; text: string; hover: string } { + const colors = [ + { bg: "bg-blue-100 dark:bg-blue-950", text: "text-blue-700 dark:text-blue-300", hover: "hover:bg-blue-200 dark:hover:bg-blue-900" }, + { bg: "bg-emerald-100 dark:bg-emerald-950", text: "text-emerald-700 dark:text-emerald-300", hover: "hover:bg-emerald-200 dark:hover:bg-emerald-900" }, + { bg: "bg-amber-100 dark:bg-amber-950", text: "text-amber-700 dark:text-amber-300", hover: "hover:bg-amber-200 dark:hover:bg-amber-900" }, + { bg: "bg-rose-100 dark:bg-rose-950", text: "text-rose-700 dark:text-rose-300", hover: "hover:bg-rose-200 dark:hover:bg-rose-900" }, + { bg: "bg-cyan-100 dark:bg-cyan-950", text: "text-cyan-700 dark:text-cyan-300", hover: "hover:bg-cyan-200 dark:hover:bg-cyan-900" }, + ] + const hash = category.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) + return colors[hash % colors.length] +} + +function getDataTypeColor(dataType: string): { bg: string; text: string; hover: string } { + const colors = [ + { bg: "bg-indigo-100 dark:bg-indigo-950", text: "text-indigo-700 dark:text-indigo-300", hover: "hover:bg-indigo-200 dark:hover:bg-indigo-900" }, + { bg: "bg-teal-100 dark:bg-teal-950", text: "text-teal-700 dark:text-teal-300", hover: "hover:bg-teal-200 dark:hover:bg-teal-900" }, + { bg: "bg-orange-100 dark:bg-orange-950", text: "text-orange-700 dark:text-orange-300", hover: "hover:bg-orange-200 dark:hover:bg-orange-900" }, + { bg: "bg-pink-100 dark:bg-pink-950", text: "text-pink-700 dark:text-pink-300", hover: "hover:bg-pink-200 dark:hover:bg-pink-900" }, + { bg: "bg-lime-100 dark:bg-lime-950", text: "text-lime-700 dark:text-lime-300", hover: "hover:bg-lime-200 dark:hover:bg-lime-900" }, + { bg: "bg-sky-100 dark:bg-sky-950", text: "text-sky-700 dark:text-sky-300", hover: "hover:bg-sky-200 dark:hover:bg-sky-900" }, + ] + const hash = dataType.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0) + return colors[hash % colors.length] +} + +function getCategoryHoverClasses(category: string): string { + const sets = [ + { bg: "hover:!bg-blue-50 dark:hover:!bg-blue-950/40", text: "hover:!text-blue-700 dark:hover:!text-blue-300" }, + { bg: "hover:!bg-indigo-50 dark:hover:!bg-indigo-950/40", text: "hover:!text-indigo-700 dark:hover:!text-indigo-300" }, + { bg: "hover:!bg-violet-50 dark:hover:!bg-violet-950/40", text: "hover:!text-violet-700 dark:hover:!text-violet-300" }, + { bg: "hover:!bg-emerald-50 dark:hover:!bg-emerald-950/40", text: "hover:!text-emerald-700 dark:hover:!text-emerald-300" }, + { bg: "hover:!bg-amber-50 dark:hover:!bg-amber-950/40", text: "hover:!text-amber-700 dark:hover:!text-amber-300" }, + { bg: "hover:!bg-rose-50 dark:hover:!bg-rose-950/40", text: "hover:!text-rose-700 dark:hover:!text-rose-300" }, + ] as const + const hash = category.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) + const c = sets[hash % sets.length] + return `${c.bg} ${c.text}` +} + +/* ---------- Share-link + CSV helpers ---------- */ +function buildShareUrl(opts: { + searchQuery: string + selectedCategory: string + selectedTasks: string[] + selectedDataTypes: string[] +}) { + const u = new URL(window.location.href) + const p = u.searchParams + p.set("q", opts.searchQuery || "") + p.set("cat", opts.selectedCategory || "all") + p.set("tasks", opts.selectedTasks.join(",")) + p.set("types", opts.selectedDataTypes.join(",")) + u.search = p.toString() + return u.toString() +} + +function downloadCSV(rows: any[], filename = "datasets_filtered.csv") { + if (!rows.length) { + alert("Nothing to export — adjust your filters first.") + return + } + const headers = Object.keys(rows[0]) + const esc = (v: any) => `"${String(v ?? "").replace(/"/g, '""')}"` + const csv = [headers.join(","), ...rows.map(r => headers.map(h => esc(r[h])).join(","))].join("\n") + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +export function DatasetAtlas() { + const [datasets, setDatasets] = useState([]) + const [searchQuery, setSearchQuery] = useState("") + const [selectedCategory, setSelectedCategory] = useState("all") + const [selectedTasks, setSelectedTasks] = useState([]) + const [selectedDataTypes, setSelectedDataTypes] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedDataset, setSelectedDataset] = useState(null) + const taskTriggerRef = useRef(null) + const dataTypeTriggerRef = useRef(null) + const [taskMenuWidth, setTaskMenuWidth] = useState(null) + const [dataTypeMenuWidth, setDataTypeMenuWidth] = useState(null) + + // Chart filters passed to + const [chartFilterCategory, setChartFilterCategory] = useState(null) + const [chartFilterDataType, setChartFilterDataType] = useState(null) + const [chartFilterAvailability, setChartFilterAvailability] = useState(null) + + // Toggle for showing/hiding plots + const [showPlots, setShowPlots] = useState(false) + + useEffect(() => { + async function fetchData() { + try { + const response = await fetch( + "https://blobs.vusercontent.net/blob/Awesome%20food%20allergy%20datasets%20-%20Copia%20di%20Full%20view%20%281%29-nbq61oXBgltNRiIJIq8djoMoqX7ItK.tsv", + ) + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + const text = await response.text() + const parsed = parseTSV(text) + setDatasets(parsed) + } catch (error) { + console.error("[v0] Error fetching datasets:", error) + } finally { + setLoading(false) + } + } + fetchData() + }, []) + + useEffect(() => { + function updateWidths() { + if (taskTriggerRef.current) setTaskMenuWidth(Math.round(taskTriggerRef.current.getBoundingClientRect().width)) + if (dataTypeTriggerRef.current) setDataTypeMenuWidth(Math.round(dataTypeTriggerRef.current.getBoundingClientRect().width)) + } + updateWidths() + window.addEventListener("resize", updateWidths) + return () => window.removeEventListener("resize", updateWidths) + }, []) + + function parseTSV(text: string): Dataset[] { + const lines = text.split("\n") + const headers = lines[0].split("\t").map((h) => h.trim().replace(/^"|"$/g, "")) + return lines + .slice(1) + .filter((line) => line.trim()) + .map((line) => { + const values = line.split("\t").map((v) => v.trim()) + const dataset: any = {} + headers.forEach((header, index) => (dataset[header] = values[index] || "")) + return dataset as Dataset + }) + } + + const categories = useMemo(() => { + const cats = new Set(datasets.map((d) => d.Category).filter(Boolean)) + return ["all", ...Array.from(cats).sort()] + }, [datasets]) + + const tasks = useMemo(() => { + const set = new Set() + datasets.forEach((d) => { + if (d.Task) d.Task.split(",").map((t) => t.trim()).forEach((t) => t && set.add(t)) + }) + return Array.from(set).sort() + }, [datasets]) + + const dataTypes = useMemo(() => { + const set = new Set(datasets.map((d) => d.Data_Type).filter(Boolean)) + return Array.from(set).sort() + }, [datasets]) + + const filteredDatasets = useMemo(() => { + return datasets.filter((d) => { + const q = searchQuery.toLowerCase() + const matchesSearch = + d.Name?.toLowerCase().includes(q) || + d.Description?.toLowerCase().includes(q) || + d.Category?.toLowerCase().includes(q) || + d.Task?.toLowerCase().includes(q) || + d.Data_Type?.toLowerCase().includes(q) + + const matchesCategory = selectedCategory === "all" || d.Category === selectedCategory + const matchesTask = + selectedTasks.length === 0 || + d.Task.split(",").map((t) => t.trim()).some((t) => selectedTasks.includes(t)) + const matchesDataType = selectedDataTypes.length === 0 || selectedDataTypes.includes(d.Data_Type) + + const matchesChartCategory = !chartFilterCategory || d.Category === chartFilterCategory + const matchesChartDataType = !chartFilterDataType || d.Data_Type === chartFilterDataType + const matchesChartAvailability = !chartFilterAvailability || d.Availability === chartFilterAvailability + + return matchesSearch && matchesCategory && matchesTask && matchesDataType && + matchesChartCategory && matchesChartDataType && matchesChartAvailability + }) + }, [datasets, searchQuery, selectedCategory, selectedTasks, selectedDataTypes, chartFilterCategory, chartFilterDataType, chartFilterAvailability]) + + const toggleTask = (task: string) => + setSelectedTasks((prev) => (prev.includes(task) ? prev.filter((t) => t !== task) : [...prev, task])) + const removeTask = (task: string) => setSelectedTasks((prev) => prev.filter((t) => t !== task)) + const clearAllTasks = () => setSelectedTasks([]) + + const toggleDataType = (dataType: string) => + setSelectedDataTypes((prev) => (prev.includes(dataType) ? prev.filter((t) => t !== dataType) : [...prev, dataType])) + const removeDataType = (dataType: string) => setSelectedDataTypes((prev) => prev.filter((t) => t !== dataType)) + const clearAllDataTypes = () => setSelectedDataTypes([]) + + const handleCategoryChartClick = (category: string) => + setChartFilterCategory((prev) => (prev === category ? null : category)) + const handleDataTypeChartClick = (dataType: string) => + setChartFilterDataType((prev) => (prev === dataType ? null : dataType)) + const handleAvailabilityChartClick = (availability: string) => + setChartFilterAvailability((prev) => (prev === availability ? null : availability)) + + return ( +
+ {/* Header */} +
+
+

Awesome Food Allergy Research Datasets

+

+ A curated collection of datasets for advancing food allergy research +

+
+
+ +
+ {/* Search and Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 h-12 text-base" + /> +
+ + {/* --- Actions bar (Share + Export + Toggle Plots) — right under search --- */} +
+ + + + + +
+ +
+
+ +
+ {categories.map((category) => ( + + ))} +
+
+ +
+
+ +
+ + + + + + {tasks.map((task) => ( + toggleTask(task)}> + {task} + + ))} + + + + {selectedTasks.length > 0 && ( +
+ {selectedTasks.map((task) => ( + + {task} + + + ))} + +
+ )} +
+
+ +
+ +
+ + + + + + {dataTypes.map((dataType) => ( + toggleDataType(dataType)}> + {dataType} + + ))} + + + + {selectedDataTypes.length > 0 && ( +
+ {selectedDataTypes.map((dataType) => ( + + {dataType} + + + ))} + +
+ )} +
+
+
+
+
+ + {/* Statistics Charts */} + {!loading && datasets.length > 0 && showPlots && ( +
+ +
+ )} + + {/* Results Count */} +
+

+ Showing {filteredDatasets.length} of{" "} + {datasets.length} datasets +

+
+ + {/* Dataset Grid */} + {loading ? ( +
+

Loading datasets...

+
+ ) : filteredDatasets.length === 0 ? ( +
+

No datasets found matching your criteria.

+
+ ) : ( +
+ {filteredDatasets.map((dataset, index) => { + const categoryColors = getCategoryColor(dataset.Category) + const dataTypeColors = getDataTypeColor(dataset.Data_Type) + return ( + setSelectedDataset(dataset)}> + +
+ {dataset.Category && ( + + {dataset.Category} + + )} + {dataset.Data_Type && ( + + {dataset.Data_Type} + + )} + {dataset.Availability && ( + + {dataset.Availability} + + )} +
+ {dataset.Name} + {dataset.Task && ( + + Task: {dataset.Task} + + )} +
+ +

+ {dataset.Description} +

+
+ + {dataset.Source && ( + + )} +
+ {dataset["Paper link"] && ( + + )} + {dataset.Contact && ( + + )} +
+
+
+ ) + })} +
+ )} +
+ + {/* Modal Dialog for Expanded Dataset View */} + !open && setSelectedDataset(null)}> + + {selectedDataset && ( + <> + +
+ {selectedDataset.Category && ( + + {selectedDataset.Category} + + )} + {selectedDataset.Data_Type && ( + + {selectedDataset.Data_Type} + + )} + {selectedDataset.Availability && ( + + {selectedDataset.Availability} + + )} +
+ {selectedDataset.Name} + {selectedDataset.Task && ( + + Task: {selectedDataset.Task} + + )} +
+
+
+

Description

+

{selectedDataset.Description}

+
+ +
+ {selectedDataset.Source && ( + + )} +
+ {selectedDataset["Paper link"] && ( + + )} + {selectedDataset.Contact && ( + + )} +
+
+
+ + )} +
+
+ + +
+ ) +} diff --git a/components/dataset-statistics.tsx b/components/dataset-statistics.tsx new file mode 100644 index 0000000000000000000000000000000000000000..918291f624012d20763d7ed5eb914169045d4d74 --- /dev/null +++ b/components/dataset-statistics.tsx @@ -0,0 +1,297 @@ +"use client" + +import { useMemo } from "react" +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from "recharts" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +interface Dataset { + Name: string + Category: string + Description: string + Task: string + Data_Type: string + Source: string + "Paper link": string + Availability: string + Contact: string +} + +interface DatasetStatisticsProps { + datasets: Dataset[] + selectedCategory: string | null + selectedDataType: string | null + selectedAvailability: string | null + onCategoryClick: (category: string) => void + onDataTypeClick: (dataType: string) => void + onAvailabilityClick: (availability: string) => void +} + +const CATEGORY_COLORS = [ + "#6366f1", // indigo + "#10b981", // emerald + "#f59e0b", // amber + "#3b82f6", // blue + "#8b5cf6", // violet + "#06b6d4", // cyan +] + +const DATA_TYPE_COLORS = [ + "#3b82f6", // blue + "#10b981", // emerald + "#6366f1", // indigo + "#f59e0b", // amber + "#8b5cf6", // violet + "#06b6d4", // cyan +] + +const AVAILABILITY_COLORS = { + "Open source": "#3b82f6", // blue + "Gated": "#10b981", // emerald + "Unknown": "#6b7280", // gray +} + +export function DatasetStatistics({ + datasets, + selectedCategory, + selectedDataType, + selectedAvailability, + onCategoryClick, + onDataTypeClick, + onAvailabilityClick +}: DatasetStatisticsProps) { + const categoryData = useMemo(() => { + const counts: Record = {} + datasets.forEach((dataset) => { + if (dataset.Category) { + counts[dataset.Category] = (counts[dataset.Category] || 0) + 1 + } + }) + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + }, [datasets]) + + const dataTypeData = useMemo(() => { + const counts: Record = {} + datasets.forEach((dataset) => { + if (dataset.Data_Type) { + counts[dataset.Data_Type] = (counts[dataset.Data_Type] || 0) + 1 + } + }) + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value) + }, [datasets]) + + const availabilityData = useMemo(() => { + const counts: Record = {} + datasets.forEach((dataset) => { + if (dataset.Availability) { + counts[dataset.Availability] = (counts[dataset.Availability] || 0) + 1 + } + }) + return Object.entries(counts).map(([name, value]) => ({ name, value })) + }, [datasets]) + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].payload.name}

+

Count: {payload[0].value}

+
+ ) + } + return null + } + + const renderCustomBarLabel = (props: any) => { + const { x, y, width, height, value } = props + return ( + + {value} + + ) + } + + return ( +
+ {/* Top Row: By Data Type and Availability */} +
+ {/* By Data Type */} + + + + By Data Type + {selectedDataType && ( + + (Click to clear filter) + + )} + + + + + + + + + } /> + onDataTypeClick(data.name)} + cursor="pointer" + > + {dataTypeData.map((entry, index) => { + const isSelected = selectedDataType === entry.name + const shouldGreyscale = selectedDataType && !isSelected + const color = shouldGreyscale + ? "#94a3b8" + : DATA_TYPE_COLORS[index % DATA_TYPE_COLORS.length] + return ( + + ) + })} + + + + + + + {/* Availability */} + + + + Availability + {selectedAvailability && ( + + (Click to clear filter) + + )} + + + + + + `${name}: ${value}`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + onClick={(data) => onAvailabilityClick(data.name)} + cursor="pointer" + > + {availabilityData.map((entry, index) => { + const isSelected = selectedAvailability === entry.name + const shouldGreyscale = selectedAvailability && !isSelected + const originalColor = AVAILABILITY_COLORS[entry.name as keyof typeof AVAILABILITY_COLORS] || "#6b7280" + const color = shouldGreyscale ? "#94a3b8" : originalColor + return ( + + ) + })} + + } /> + {value}} + /> + + + + +
+ + {/* Bottom Row: By Category */} +
+ + + + By Category + {selectedCategory && ( + + (Click to clear filter) + + )} + + + + + + + + + } /> + onCategoryClick(data.name)} + cursor="pointer" + > + {categoryData.map((entry, index) => { + const isSelected = selectedCategory === entry.name + const shouldGreyscale = selectedCategory && !isSelected + const color = shouldGreyscale + ? "#94a3b8" + : CATEGORY_COLORS[index % CATEGORY_COLORS.length] + return ( + + ) + })} + + + + + +
+
+ ) +} diff --git a/components/scroll-to-top.tsx b/components/scroll-to-top.tsx new file mode 100644 index 0000000000000000000000000000000000000000..113ddc8aec0dda0f9a31bfd81f33456322395dd5 --- /dev/null +++ b/components/scroll-to-top.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useState, useEffect } from "react" +import { ArrowUp } from "lucide-react" +import { Button } from "@/components/ui/button" + +export function ScrollToTop() { + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + const toggleVisibility = () => { + // Show button when page is scrolled down 300px + if (window.scrollY > 300) { + setIsVisible(true) + } else { + setIsVisible(false) + } + } + + window.addEventListener("scroll", toggleVisibility) + + return () => { + window.removeEventListener("scroll", toggleVisibility) + } + }, []) + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }) + } + + return ( + <> + {isVisible && ( + + )} + + ) +} + diff --git a/components/site-footer.tsx b/components/site-footer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc71c2b79f576cfea0b5ffdee930a535aae4050f --- /dev/null +++ b/components/site-footer.tsx @@ -0,0 +1,11 @@ +export function SiteFooter() { + const year = new Date().getFullYear() + return ( +
+
+

© {year} Awesome Food Allergy Research Datasets

+

Powered by Next.js, Tailwind CSS, and shadcn/ui

+
+
+ ) +} diff --git a/components/site-header.tsx b/components/site-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ae6d8197be95405fad13793c3f706b508cfd031 --- /dev/null +++ b/components/site-header.tsx @@ -0,0 +1,15 @@ +"use client" + +import Link from "next/link" + +export function SiteHeader() { + return ( +
+
+ + Awesome Food Allergy Research Datasets + +
+
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55c2f6eb60b22a313a4c27bd0b2d728063cb8ab9 --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d690cf7e4593893e4c8e3415ef32bd80c37ab371 --- /dev/null +++ b/components/theme-toggle.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect, useState } from "react" +import { useTheme } from "next-themes" +import { Sun, Moon } from "lucide-react" +import { Button } from "@/components/ui/button" + +export function ThemeToggle() { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => setMounted(true), []) + + const cycleTheme = () => { + const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light" + setTheme(next) + } + + if (!mounted) return null + + return ( + + ) +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e538a33b9946acb372e2f569a7cb4b22b17cff12 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9704452664dabbb8cef88fa154c12f81b243120d --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6751abe6a83bc38bbb422f7fc47a534ec65f459 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40bb1208dbbf471b59cac12b60b992cb3cfd30dd --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa98465a30f89336a5205d3298bc5bf836baa1fd --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..098e43ce96ace98f308b13fffeac6985bd33cfce --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import type * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +export interface BadgeProps extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1750ff26a6f95d49cece850873e367f51c0be252 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return