Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Type check
run: npm run typecheck

- name: Build
run: npm run build
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.next
out
dist
.env*.local
.DS_Store
*.tsbuildinfo
next-env.d.ts
121 changes: 121 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Contributing to PI-hub

A short guide to setting up, validating, and deploying PI-hub.

## Prerequisites

- **Node.js 20+** (tested with 22). On macOS, you may have multiple node installs — verify with `node --version`.
- **npm** (bundled with Node).

If a Homebrew node install is broken (missing dylibs), prefer the `/usr/local/bin` install or use a version manager like `nvm`/`fnm`.

## Local development

```bash
npm install
npm run dev
```

Then open http://localhost:3000. The dev server hot-reloads on file changes.

Data is read/written under `content/` (gitignored content lives next to checked-in seed data). If `content/` is empty, the app starts with a default PI shell and no projects.

## Project layout

```
app/
├── layout.tsx # shell + StoreProvider
├── page.tsx # dashboard
├── pi/page.tsx # PI profile editor
├── projects/ # list, new, detail
├── members/ # list, new, detail
├── cfps/ # list, new, detail (deadlines)
├── archives/page.tsx # restore archived items
└── api/ # REST routes that read/write content/*.md
├── store/route.ts # combined store snapshot
├── pi/route.ts
├── projects/[id]/route.ts
├── projects/[id]/report/route.ts # PDF export
├── members/[id]/route.ts
└── cfps/[id]/route.ts
components/
├── Nav.tsx # sidebar with SVG icons
├── Topbar.tsx # greeting, breadcrumb, search, add buttons
└── Bits.tsx # shared UI bits: avatars, badges, countdown
lib/
├── types.ts # PI, Project, Member, CallForPaper, etc.
├── store.tsx # client store + mutations via fetch
├── fs-store.ts # server-only filesystem read/write
└── report.tsx # @react-pdf/renderer Document
content/ # data lives here (markdown files)
public/ # static assets (logo.png)
```

## Conventions

- **Server-only modules** under `lib/fs-store.ts` and `lib/report.tsx` may use Node APIs (`fs`, `path`). Never import them from a client component.
- **Client mutations** go through `useStore()` (`lib/store.tsx`). Mutations are optimistic on the client and persisted by calling the matching API route.
- **Markdown round-trip:** YAML frontmatter is the source of truth. The body is regenerated on every write — never expect to round-trip arbitrary body edits.
- **Archive, don't delete.** UI exposes "Archive" (sets `archivedAt`). The DELETE API endpoints exist but are not exercised by the UI. The `/archives` page restores items by clearing `archivedAt`.

## Validation

Before pushing changes, run:

```bash
npm run lint # ESLint (flat config, eslint-config-next)
npm run typecheck # tsc --noEmit
npm run build # production build (also runs type checking)
```

GitHub Actions runs the same three checks on every push to `main` and every PR — see `.github/workflows/ci.yml`. PRs should be green before merging.

`npm run lint:fix` auto-fixes anything ESLint can handle. The lint rule `react-hooks/set-state-in-effect` is downgraded to a warning because the codebase uses the standard pattern of syncing form state when props change; that's intentional and not a bug.

Also worth verifying manually:

- Open `/`, `/projects`, `/members`, `/cfps`, `/archives`, `/pi`, plus a project / member / CFP detail page.
- Create one of each entity, assign relationships, archive, restore.
- Click **Export PDF** on a project and open the file — verify there's no overlapping text and pagination is clean.
- Check the topbar breadcrumb resolves UUIDs to friendly names.

## Deployment to Vercel

1. Push the repo to GitHub.
2. Import the repo on https://vercel.com — accept the default Next.js settings; no env vars required.
3. Set the production branch (typically `main`).
4. Deploy.

The Next.js framework preset on Vercel handles the build. The `@react-pdf/renderer` PDF endpoint runs as a Node serverless function (`export const runtime = "nodejs"` is already set in `app/api/projects/[id]/report/route.ts`).

### About the read-only filesystem

Vercel's serverless functions cannot persist writes to the project filesystem. That means:

- **Reads work** for whatever you committed under `content/` at deploy time.
- **Writes via the UI** appear to succeed but do not persist across requests / cold starts.

To update production data: edit locally, commit the `content/*.md` files, push, and let Vercel redeploy. For a writable production deployment, self-host (Render, Fly, a VM) with a persistent filesystem mounted at `content/`.

## Adding a new entity

When adding a new top-level entity (similar to Project / Member / CFP):

1. Define the type in `lib/types.ts` and add it to the `Store` shape.
2. Add `coerce`, `read`, `readAll`, `write`, `delete`, and a `render…Body` helper to `lib/fs-store.ts`. Include the entity directory in `ensureDirs()`. Make sure `readStore()` returns it.
3. Add `app/api/<entity>/route.ts` (GET, POST) and `app/api/<entity>/[id]/route.ts` (GET, PATCH, DELETE).
4. Extend the client `useStore()` in `lib/store.tsx` with add / update / archive (= update with `archivedAt`) functions.
5. Build pages under `app/<entity>/`: list, new, detail.
6. Add a sidebar entry in `components/Nav.tsx` (include an SVG icon in the `Icon` component switch).
7. Filter archived items out of the list pages; surface them on `/archives` with a Restore button.

## Reporting bugs / requesting features

Open an issue with:

- What you expected
- What happened instead
- Steps to reproduce (which page, which entity, which click)
- Browser + Node version

For visual issues, a screenshot saves a thousand words.
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,66 @@
# PI-hub
Principal Investigator Hub

A research hub for a Principal Investigator: a single place to plan projects, track teams, watch submission deadlines, and keep a durable log of what was tried and learned.

## Who this is for

PI-hub serves the PI of **eSPUD** — an independent research collective exploring on-device AI: models small enough to run on phones, sensors, wearables, and constrained hardware. We believe meaningful intelligence shouldn't require a datacenter round-trip, and that AI ethics — data use, consent, bias, downstream impact — should be reviewed before anything leaves the lab. We publish what we learn, including what didn't work.

The app assumes one PI managing many projects, each with its own contributors, plan, setup, log, artifacts, and target venues.

## Features

- **PI profile** — name, title, pronouns, affiliation, location, contact, online profiles (ORCID, Scholar, GitHub, LinkedIn, X), bio, focus areas, expertise tags, education and selected publications.
- **Projects** — name, description, status (exploration / planning / active / paused / archived), Discord link, research plan, research setup, contributor lists, artifacts, append-only exploration log.
- **Members** — first-class team roster with avatars, role, affiliation, GitHub, expertise tags, and per-project assignment toggles. Names are unique within a project.
- **Call-for-paper deadlines** — track venues with abstract / submission / notification / conference dates, topics, location, notes. A live timeline visualizes the next 6 months with color-coded urgency (green → amber → red as the deadline approaches; gray once passed).
- **Live countdown** — the next upcoming deadline is shown as a big D : HH : MM : SS ticker, updating every second. Compact countdowns appear on every CFP card and project assignment.
- **Assessment scoring** — each project assigned to a CFP carries a status: `not started`, `in progress`, `submitted`, `accepted`, `rejected`, `late`, `abandoned`. Statuses surface as color-coded badges everywhere the assignment appears.
- **Dashboard** — greeting, breadcrumb path, status overview donut, recent activity feed, top contributors, project table with avatar stacks and log-activity progress.
- **Global search** — searches projects, descriptions, members, expertise, and CFP topics. The query lives in the URL so it survives refresh.
- **Archives** — projects, members, and CFPs are archived rather than deleted. A dedicated `/archives` page lists everything that's been put away and lets you restore any item back to the active lists. Project assignments and CFP relationships are preserved across archive/restore.
- **PDF export** — every project has an "Export PDF" button that generates a clean, well-spaced report including the plan, setup, team, target venues with status, artifacts, and the full exploration log.

## Stack

- Next.js 15 (App Router) + React 19 + TypeScript
- Plain CSS (no UI framework)
- `@react-pdf/renderer` for PDF generation
- `js-yaml` for markdown frontmatter parsing
- **Storage: markdown files on disk** — no external database

## Storage model

Everything lives under `content/`:

```
content/
├── pi.md # the single PI profile
├── projects/<uuid>.md # one file per project
├── members/<uuid>.md # one file per team member
└── cfps/<uuid>.md # one file per call for papers
```

Each file has YAML frontmatter (the source of truth) plus a generated, human-readable markdown body. Editing in the UI rewrites the corresponding file via the API routes under `app/api/`.

Relationships are stored on the parent: a project's `memberIds: []` references members, and its `cfpAssignments: [{cfpId, status, notes, assignedAt}]` references CFPs. Archived items carry an `archivedAt: <ISO>` field; empty means active.

## Quick start

```bash
npm install
npm run dev
```

Open http://localhost:3000.

## Deployment

The app deploys cleanly as a Next.js project on Vercel. The serverless filesystem is read-only at runtime, so:

- **Reads work fine.** Whatever is committed under `content/` shows up in production.
- **Writes don't persist on Vercel.** To update production data, edit locally, commit the markdown files in `content/`, and redeploy.

For a self-hosted node deployment, writes persist normally on the host's filesystem.

See [CONTRIBUTING.md](./CONTRIBUTING.md) for development, validation, and deployment details.
45 changes: 45 additions & 0 deletions app/api/cfps/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { deleteCFP, readCFP, readProjects, writeCFP, writeProject } from "@/lib/fs-store";
import type { CallForPaper } from "@/lib/types";

export const dynamic = "force-dynamic";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const cfp = await readCFP(id);
if (!cfp) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(cfp);
}

export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const existing = await readCFP(id);
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
const patch = (await req.json()) as Partial<CallForPaper>;
const updated: CallForPaper = {
...existing,
...patch,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
topics: Array.isArray(patch.topics) ? patch.topics.filter((x): x is string => typeof x === "string") : existing.topics,
};
await writeCFP(updated);
return NextResponse.json(updated);
}

export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteCFP(id);
const projects = await readProjects();
for (const p of projects) {
if (p.cfpAssignments.some((a) => a.cfpId === id)) {
await writeProject({
...p,
cfpAssignments: p.cfpAssignments.filter((a) => a.cfpId !== id),
updatedAt: new Date().toISOString(),
});
}
}
return NextResponse.json({ ok: true });
}
36 changes: 36 additions & 0 deletions app/api/cfps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { readCFPs, writeCFP } from "@/lib/fs-store";
import type { CallForPaper } from "@/lib/types";

export const dynamic = "force-dynamic";

export async function GET() {
return NextResponse.json(await readCFPs());
}

export async function POST(req: Request) {
const data = (await req.json()) as Partial<CallForPaper>;
if (!data.name || !data.name.trim()) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const now = new Date().toISOString();
const cfp: CallForPaper = {
id: randomUUID(),
name: data.name.trim(),
venue: (data.venue ?? "").trim(),
url: (data.url ?? "").trim(),
abstractDeadline: (data.abstractDeadline ?? "").trim(),
submissionDeadline: (data.submissionDeadline ?? "").trim(),
notificationDate: (data.notificationDate ?? "").trim(),
conferenceDate: (data.conferenceDate ?? "").trim(),
location: (data.location ?? "").trim(),
topics: Array.isArray(data.topics) ? data.topics.filter((x): x is string => typeof x === "string") : [],
notes: (data.notes ?? "").trim(),
archivedAt: "",
createdAt: now,
updatedAt: now,
};
await writeCFP(cfp);
return NextResponse.json(cfp);
}
45 changes: 45 additions & 0 deletions app/api/members/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { deleteMember, readMember, readProjects, writeMember, writeProject } from "@/lib/fs-store";
import type { Member } from "@/lib/types";

export const dynamic = "force-dynamic";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const member = await readMember(id);
if (!member) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(member);
}

export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const existing = await readMember(id);
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
const patch = (await req.json()) as Partial<Member>;
const updated: Member = {
...existing,
...patch,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
expertise: Array.isArray(patch.expertise) ? patch.expertise.filter((x): x is string => typeof x === "string") : existing.expertise,
};
await writeMember(updated);
return NextResponse.json(updated);
}

export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await deleteMember(id);
const projects = await readProjects();
for (const p of projects) {
if (p.memberIds.includes(id)) {
await writeProject({
...p,
memberIds: p.memberIds.filter((mid) => mid !== id),
updatedAt: new Date().toISOString(),
});
}
}
return NextResponse.json({ ok: true });
}
34 changes: 34 additions & 0 deletions app/api/members/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { readMembers, writeMember } from "@/lib/fs-store";
import type { Member } from "@/lib/types";

export const dynamic = "force-dynamic";

export async function GET() {
return NextResponse.json(await readMembers());
}

export async function POST(req: Request) {
const data = (await req.json()) as Partial<Member>;
if (!data.name || !data.name.trim()) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const now = new Date().toISOString();
const member: Member = {
id: randomUUID(),
name: data.name.trim(),
role: (data.role ?? "").trim(),
email: (data.email ?? "").trim(),
affiliation: (data.affiliation ?? "").trim(),
bio: (data.bio ?? "").trim(),
avatarUrl: (data.avatarUrl ?? "").trim(),
github: (data.github ?? "").trim(),
expertise: Array.isArray(data.expertise) ? data.expertise.filter((x): x is string => typeof x === "string") : [],
archivedAt: "",
createdAt: now,
updatedAt: now,
};
await writeMember(member);
return NextResponse.json(member);
}
Loading
Loading