From c28fc8154479cfc4d6cf3f1e806f24e4afe7793f Mon Sep 17 00:00:00 2001 From: Shashank Bangalore Lakshman Date: Tue, 19 May 2026 16:58:26 -0700 Subject: [PATCH 1/4] Build PI-hub: research dashboard for the eSPUD PI A Next.js + TypeScript app for managing a Principal Investigator's research practice. Single PI runs many projects, each with its own team, plan, setup, log, artifacts, and target venues. Features: - PI profile (identity, contact, online profiles, focus areas, expertise, education, publications) - Projects (status, plan, setup, Discord + Overleaf links, contributors, artifacts, append-only exploration log) - First-class team Members with avatars, role, expertise, and per-project assignment toggles - Call-for-paper deadline tracking with timeline visualization, live D:HH:MM:SS countdown, and per-assignment assessment status (not started / in progress / submitted / accepted / rejected / late / abandoned) - Dashboard with greeting, breadcrumb path, status overview donut, top-3 upcoming deadlines, activity feed, contributor leaderboard - Global URL-backed search across projects, members, expertise, and CFP topics - Archive instead of delete; /archives page restores or permanently deletes items - One-click PDF report export per project (@react-pdf/renderer) Storage: markdown files under content/ (YAML frontmatter is the source of truth; body is auto-generated). No external database. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 8 + CONTRIBUTING.md | 118 ++ README.md | 66 +- app/api/cfps/[id]/route.ts | 45 + app/api/cfps/route.ts | 36 + app/api/members/[id]/route.ts | 45 + app/api/members/route.ts | 34 + app/api/pi/route.ts | 46 + app/api/projects/[id]/report/route.ts | 41 + app/api/projects/[id]/route.ts | 34 + app/api/projects/route.ts | 38 + app/api/store/route.ts | 9 + app/archives/page.tsx | 155 +++ app/cfps/[id]/page.tsx | 219 ++++ app/cfps/new/page.tsx | 82 ++ app/cfps/page.tsx | 269 +++++ app/globals.css | 962 +++++++++++++++ app/icon.png | Bin 0 -> 83360 bytes app/layout.tsx | 30 + app/members/[id]/page.tsx | 201 ++++ app/members/new/page.tsx | 66 + app/members/page.tsx | 110 ++ app/page.tsx | 311 +++++ app/pi/page.tsx | 344 ++++++ app/projects/[id]/page.tsx | 670 +++++++++++ app/projects/new/page.tsx | 53 + app/projects/page.tsx | 108 ++ components/Bits.tsx | 190 +++ components/Nav.tsx | 126 ++ components/Topbar.tsx | 100 ++ content/.gitkeep | 0 content/pi.md | 100 ++ lib/fs-store.ts | 403 +++++++ lib/report.tsx | 396 ++++++ lib/store.tsx | 354 ++++++ lib/types.ts | 146 +++ next.config.mjs | 3 + package-lock.json | 1603 +++++++++++++++++++++++++ package.json | 25 + public/logo.png | Bin 0 -> 83360 bytes tsconfig.json | 21 + 41 files changed, 7566 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 app/api/cfps/[id]/route.ts create mode 100644 app/api/cfps/route.ts create mode 100644 app/api/members/[id]/route.ts create mode 100644 app/api/members/route.ts create mode 100644 app/api/pi/route.ts create mode 100644 app/api/projects/[id]/report/route.ts create mode 100644 app/api/projects/[id]/route.ts create mode 100644 app/api/projects/route.ts create mode 100644 app/api/store/route.ts create mode 100644 app/archives/page.tsx create mode 100644 app/cfps/[id]/page.tsx create mode 100644 app/cfps/new/page.tsx create mode 100644 app/cfps/page.tsx create mode 100644 app/globals.css create mode 100644 app/icon.png create mode 100644 app/layout.tsx create mode 100644 app/members/[id]/page.tsx create mode 100644 app/members/new/page.tsx create mode 100644 app/members/page.tsx create mode 100644 app/page.tsx create mode 100644 app/pi/page.tsx create mode 100644 app/projects/[id]/page.tsx create mode 100644 app/projects/new/page.tsx create mode 100644 app/projects/page.tsx create mode 100644 components/Bits.tsx create mode 100644 components/Nav.tsx create mode 100644 components/Topbar.tsx create mode 100644 content/.gitkeep create mode 100644 content/pi.md create mode 100644 lib/fs-store.ts create mode 100644 lib/report.tsx create mode 100644 lib/store.tsx create mode 100644 lib/types.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/logo.png create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6eef131 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +out +dist +.env*.local +.DS_Store +*.tsbuildinfo +next-env.d.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9525286 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,118 @@ +# 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 via next lint +npm run build # full type-check + production build +``` + +`npm run build` is the most thorough check — it catches TS errors that `next dev` swallows, and confirms the route tree compiles. + +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//route.ts` (GET, POST) and `app/api//[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//`: 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. diff --git a/README.md b/README.md index e0b1b38..f3b6e64 100644 --- a/README.md +++ b/README.md @@ -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/.md # one file per project +├── members/.md # one file per team member +└── cfps/.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: ` 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. diff --git a/app/api/cfps/[id]/route.ts b/app/api/cfps/[id]/route.ts new file mode 100644 index 0000000..f830bf2 --- /dev/null +++ b/app/api/cfps/[id]/route.ts @@ -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; + 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 }); +} diff --git a/app/api/cfps/route.ts b/app/api/cfps/route.ts new file mode 100644 index 0000000..2874ffc --- /dev/null +++ b/app/api/cfps/route.ts @@ -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; + 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); +} diff --git a/app/api/members/[id]/route.ts b/app/api/members/[id]/route.ts new file mode 100644 index 0000000..f1c6279 --- /dev/null +++ b/app/api/members/[id]/route.ts @@ -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; + 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 }); +} diff --git a/app/api/members/route.ts b/app/api/members/route.ts new file mode 100644 index 0000000..b148e45 --- /dev/null +++ b/app/api/members/route.ts @@ -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; + 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); +} diff --git a/app/api/pi/route.ts b/app/api/pi/route.ts new file mode 100644 index 0000000..15a596e --- /dev/null +++ b/app/api/pi/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { readPI, writePI } from "@/lib/fs-store"; +import type { PI } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function clean(s: unknown): string { + return typeof s === "string" ? s : ""; +} + +function cleanList(v: unknown, predicate: (x: unknown) => x is T): T[] { + return Array.isArray(v) ? v.filter(predicate) : []; +} + +const isString = (x: unknown): x is string => typeof x === "string"; + +export async function GET() { + return NextResponse.json(await readPI()); +} + +export async function PUT(req: Request) { + const body = (await req.json()) as Partial; + const pi: PI = { + name: clean(body.name), + title: clean(body.title), + pronouns: clean(body.pronouns), + affiliation: clean(body.affiliation), + location: clean(body.location), + timezone: clean(body.timezone), + avatarUrl: clean(body.avatarUrl), + email: clean(body.email), + website: clean(body.website), + orcid: clean(body.orcid), + googleScholar: clean(body.googleScholar), + github: clean(body.github), + linkedin: clean(body.linkedin), + twitter: clean(body.twitter), + bio: clean(body.bio), + focusAreas: cleanList(body.focusAreas, isString), + expertise: cleanList(body.expertise, isString), + education: Array.isArray(body.education) ? body.education : [], + publications: Array.isArray(body.publications) ? body.publications : [], + }; + await writePI(pi); + return NextResponse.json(pi); +} diff --git a/app/api/projects/[id]/report/route.ts b/app/api/projects/[id]/report/route.ts new file mode 100644 index 0000000..a331deb --- /dev/null +++ b/app/api/projects/[id]/report/route.ts @@ -0,0 +1,41 @@ +import { renderToBuffer } from "@react-pdf/renderer"; +import { readCFPs, readMembers, readPI, readProject } from "@/lib/fs-store"; +import { ProjectReport } from "@/lib/report"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "project"; +} + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const project = await readProject(id); + if (!project) { + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + const [pi, members, cfps] = await Promise.all([readPI(), readMembers(), readCFPs()]); + const generatedAt = new Date().toISOString(); + + const buffer = await renderToBuffer( + ProjectReport({ project, pi, members, cfps, generatedAt }) as never, + ); + + const filename = `${slugify(project.name)}-report.pdf`; + return new Response(buffer as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..a8794c8 --- /dev/null +++ b/app/api/projects/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { deleteProject, readProject, writeProject } from "@/lib/fs-store"; +import type { Project } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const project = await readProject(id); + if (!project) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(project); +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const existing = await readProject(id); + if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 }); + const patch = (await req.json()) as Partial; + const updated: Project = { + ...existing, + ...patch, + id: existing.id, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; + await writeProject(updated); + return NextResponse.json(updated); +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await deleteProject(id); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..efc398d --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { readProjects, writeProject } from "@/lib/fs-store"; +import type { Project } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json(await readProjects()); +} + +export async function POST(req: Request) { + const data = (await req.json()) as { name?: string; description?: string }; + if (!data.name || !data.name.trim()) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + const now = new Date().toISOString(); + const project: Project = { + id: randomUUID(), + name: data.name.trim(), + description: (data.description ?? "").trim(), + status: "exploration", + plan: "", + setup: "", + discord: "", + overleaf: "", + contributors: [], + memberIds: [], + artifacts: [], + log: [], + cfpAssignments: [], + archivedAt: "", + createdAt: now, + updatedAt: now, + }; + await writeProject(project); + return NextResponse.json(project); +} diff --git a/app/api/store/route.ts b/app/api/store/route.ts new file mode 100644 index 0000000..10fb1ab --- /dev/null +++ b/app/api/store/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { readStore } from "@/lib/fs-store"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const store = await readStore(); + return NextResponse.json(store); +} diff --git a/app/archives/page.tsx b/app/archives/page.tsx new file mode 100644 index 0000000..f434dd0 --- /dev/null +++ b/app/archives/page.tsx @@ -0,0 +1,155 @@ +"use client"; +import Link from "next/link"; +import { useMemo } from "react"; +import { useStore } from "@/lib/store"; +import { StatusBadge } from "@/components/Bits"; + +export default function ArchivesPage() { + const { store, loaded, updateProject, updateMember, updateCFP, deleteProject, deleteMember, deleteCFP } = useStore(); + + const projects = useMemo( + () => store.projects.filter((p) => p.archivedAt).sort((a, b) => b.archivedAt.localeCompare(a.archivedAt)), + [store.projects], + ); + const members = useMemo( + () => store.members.filter((m) => m.archivedAt).sort((a, b) => b.archivedAt.localeCompare(a.archivedAt)), + [store.members], + ); + const cfps = useMemo( + () => store.cfps.filter((c) => c.archivedAt).sort((a, b) => b.archivedAt.localeCompare(a.archivedAt)), + [store.cfps], + ); + + if (!loaded) return

Loading…

; + + const totalArchived = projects.length + members.length + cfps.length; + + return ( +
+
+

Archives

+

+ {totalArchived === 0 + ? "Nothing archived yet. Items you archive will appear here and can be restored." + : `${totalArchived} archived item${totalArchived === 1 ? "" : "s"}. Restore any to bring it back into the main lists.`} +

+
+ +
+
+

Projects ({projects.length})

+
+ {projects.length === 0 ? ( +
No archived projects.
+ ) : ( +
    + {projects.map((p) => ( +
  • +
    +
    + {p.name} + {p.description &&
    {p.description}
    } +
    + Archived {new Date(p.archivedAt).toLocaleDateString()} · last updated {new Date(p.updatedAt).toLocaleDateString()} +
    +
    +
    + + + +
    +
    +
  • + ))} +
+ )} +
+ +
+
+

Members ({members.length})

+
+ {members.length === 0 ? ( +
No archived members.
+ ) : ( +
    + {members.map((m) => ( +
  • +
    +
    + {m.name} + {m.role &&
    {m.role}
    } +
    + Archived {new Date(m.archivedAt).toLocaleDateString()} +
    +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+ +
+
+

Calls for papers ({cfps.length})

+
+ {cfps.length === 0 ? ( +
No archived CFPs.
+ ) : ( +
    + {cfps.map((c) => ( +
  • +
    +
    + {c.name} + {c.venue &&
    {c.venue}
    } +
    + Archived {new Date(c.archivedAt).toLocaleDateString()} + {c.submissionDeadline ? ` · submission was ${c.submissionDeadline}` : ""} +
    +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/cfps/[id]/page.tsx b/app/cfps/[id]/page.tsx new file mode 100644 index 0000000..a2263c6 --- /dev/null +++ b/app/cfps/[id]/page.tsx @@ -0,0 +1,219 @@ +"use client"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { use, useEffect, useMemo, useState } from "react"; +import { useStore } from "@/lib/store"; +import { CFPStatusBadge, Countdown, daysUntil, deadlineUrgency } from "@/components/Bits"; +import { CFP_STATUSES, type CallForPaper, type ProjectCFPStatus } from "@/lib/types"; + +function fmtDate(d: string): string { + if (!d) return "—"; + return new Date(d + "T00:00:00").toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +export default function CFPDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const { + store, + loaded, + updateCFP, + assignProjectToCFP, + updateCFPAssignment, + unassignProjectFromCFP, + } = useStore(); + const router = useRouter(); + const cfp = store.cfps.find((c) => c.id === id); + const [form, setForm] = useState(cfp ?? null); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + if (cfp) setForm(cfp); + }, [cfp]); + + const dirty = useMemo(() => { + if (!form || !cfp) return false; + return JSON.stringify(form) !== JSON.stringify(cfp); + }, [form, cfp]); + + if (!loaded) return

Loading…

; + if (!cfp || !form) { + return ( +
+

CFP not found

+ Back to deadlines +
+ ); + } + + const set = (key: K, value: CallForPaper[K]) => + setForm({ ...form, [key]: value }); + + const save = async () => { + await updateCFP(cfp.id, form); + setSavedAt(Date.now()); + }; + + const assigned = store.projects + .map((p) => { + const a = p.cfpAssignments.find((x) => x.cfpId === cfp.id); + return a ? { project: p, assignment: a } : null; + }) + .filter((x): x is { project: typeof store.projects[number]; assignment: typeof store.projects[number]["cfpAssignments"][number] } => !!x); + const available = store.projects.filter((p) => !p.cfpAssignments.some((a) => a.cfpId === cfp.id)); + + return ( +
+ {cfp.archivedAt && ( +
+ Archived {new Date(cfp.archivedAt).toLocaleDateString()} + +
+ )} +
+
+ ← All CFPs +
+ + {!cfp.archivedAt ? ( + + ) : ( + + )} +
+
+ +
+
+

{form.name}

+ + submission {form.submissionDeadline ? `· ${fmtDate(form.submissionDeadline)}` : "not set"} + +
+ {form.venue &&

{form.venue}{form.location ? ` · ${form.location}` : ""}

} + {form.url &&

{form.url}

} + {form.submissionDeadline && ( +
+ +
+ )} + {savedAt && !dirty &&

Saved {new Date(savedAt).toLocaleTimeString()}

} +
+
+ +
+

Details

+
+ set("name", e.target.value)} /> + set("venue", e.target.value)} /> + set("url", e.target.value)} placeholder="https://…" /> + set("location", e.target.value)} placeholder="e.g. Vancouver" /> +
+
+ set("abstractDeadline", e.target.value)} /> + set("submissionDeadline", e.target.value)} /> + set("notificationDate", e.target.value)} /> + set("conferenceDate", e.target.value)} /> +
+ + set("topics", e.target.value.split(",").map((s) => s.trim()).filter(Boolean))} + placeholder="e.g. on-device AI, compression, edge inference" + /> + + +