diff --git a/.github/workflows/enforce-staging-to-main.yml b/.github/workflows/enforce-staging-to-main.yml new file mode 100644 index 0000000..70aace0 --- /dev/null +++ b/.github/workflows/enforce-staging-to-main.yml @@ -0,0 +1,21 @@ +name: Enforce staging → main + +on: + pull_request: + branches: [main] + +jobs: + enforce-staging-source: + name: enforce-staging-source + runs-on: ubuntu-latest + steps: + - name: Verify PR source branch is staging + env: + HEAD_REF: ${{ github.head_ref }} + run: | + if [ "$HEAD_REF" != "staging" ]; then + echo "::error::Pull requests into main must come from 'staging' (got '$HEAD_REF')." + echo "Merge your branch into staging first, then open a PR from staging into main." + exit 1 + fi + echo "OK: PR source is staging." diff --git a/README.md b/README.md new file mode 100644 index 0000000..67bb2b4 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# The Forum + +Princeton's campus events platform, built by [TigerApps](https://tigerapps.org). + +This is a [Turborepo](https://turbo.build) monorepo managed with [Bun](https://bun.sh): + +| Package | What it is | +|---|---| +| `apps/web` | **The main app** — Next.js 15 (App Router), React 19, Tailwind v4, shadcn/ui | +| `apps/database` | Shared Drizzle ORM schema + migrations (PostgreSQL) | +| `apps/admin-web` | Admin dashboard — Vite + React | +| `backends/fastapi` | FastAPI backend (Python 3.12, managed with `uv`) | +| `apps/listserv-scraper` | Python scraper for Princeton listserv archives | +| `apps/mpu-scraper` | Scraper for MyPrincetonU events | + +New to the project? Follow **Quick start** below — it gets `apps/web` running locally, +which is the primary thing you need. The Python backend and scrapers are optional +until you work on them. + +--- + +## Prerequisites + +| Tool | Version | Install | +|---|---|---| +| [Bun](https://bun.sh) | ≥ 1.2 | macOS/Linux: `curl -fsSL https://bun.sh/install \| bash` · Windows: `powershell -c "irm bun.sh/install.ps1 \| iex"` | +| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | latest | docker.com (used only for local Postgres) | +| [Git](https://git-scm.com) | ≥ 2.40 | Pre-installed on macOS; `winget install Git.Git` on Windows | +| [uv](https://docs.astral.sh/uv/) | ≥ 0.5 | Only needed for the Python backend — see [Python backend](#python-backend-optional) | + +> **Windows:** use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) or Git Bash +> for all commands below. Restart your terminal after installing tools so PATH updates apply. + +**Never use `npm`, `yarn`, or `pnpm` in this repo — always `bun`.** + +--- + +## Quick start (`apps/web`) + +### 1. Clone and install + +```bash +git clone https://github.com/TigerAppsOrg/TheForum.git +cd TheForum +bun install # installs every workspace package + sets up Husky pre-commit hooks +``` + +### 2. Environment variables + +Copy the example files: + +```bash +cp .env.example .env # root — used by docker-compose +cp apps/web/.env.local.example apps/web/.env.local # Next.js app +cp apps/database/.env.example apps/database/.env # drizzle-kit CLI +``` + +Then fill in `apps/web/.env.local`. Env vars are validated at startup by +[`apps/web/src/env.ts`](apps/web/src/env.ts) — the app won't boot if a required +one is missing, and that file is the source of truth for what's required. + +> **Can't obtain a value yourself? Ask Ibraheem.** He is the contact for all +> credentials that aren't self-serve (Entra ID, Mapbox tokens, AWS, etc.). + +| Variable | Where to get it | +|---|---| +| `DATABASE_URL` | Default in the example file works as-is with the Docker database (port **5434**) | +| `AUTH_SECRET` | Generate your own: `openssl rand -base64 32` | +| `AUTH_AZURE_AD_CLIENT_ID` / `AUTH_AZURE_AD_CLIENT_SECRET` | **Ask Ibraheem** — these are the Microsoft Entra ID app credentials for Princeton CAS login | +| `AUTH_AZURE_AD_TENANT_ID` | Princeton's tenant ID — already filled in the example file | +| `NEXT_PUBLIC_MAPBOX_TOKEN` / `NEXT_PUBLIC_CAMPUS_MAP_TOKEN` / `NEXT_PUBLIC_CAMPUS_MAP_STYLE` | **Ask Ibraheem** — Mapbox tokens + the Princeton campus map style URL | +| `AWS_S3_BUCKET` / `AWS_REGION` | Optional (image uploads) — ask Ibraheem if you're working on that feature | + +### 3. Start the database + +Make sure **Docker Desktop is running**, then from the repo root: + +```bash +bun run db:up # starts Postgres 17 in Docker (container: the-forum-db, host port 5434) +bun run db:push # push the Drizzle schema into the fresh database +``` + +Sanity checks: + +```bash +docker compose ps # the-forum-db should show "Up (healthy)" +bun run db:logs # tail the Postgres logs if something looks wrong +``` + +The database URL is `postgresql://forum:forum_password@localhost:5434/the_forum` +(also reachable with any Postgres client, e.g. `psql`, TablePlus, or `bun run db:studio`). + +Optionally fill the database with realistic demo data: + +```bash +cd apps/database && bun run db:seed +``` + +### 4. Run the app + +```bash +cd apps/web && bun run dev +``` + +Open . You're set up. + +To run **everything at once** (web + admin + FastAPI) from the repo root: + +```bash +bun run dev # Turborepo TUI: web :3000, admin-web :5173, FastAPI :8000 +``` + +(FastAPI will only start if you've done the [Python backend](#python-backend-optional) setup.) + +--- + +## Everyday commands + +```bash +bun run check # Biome lint + format with auto-fix (run before pushing) +bun run format # format only +bun run build # build all packages + +bun run db:up # start Postgres db:down stop it (data persists) +bun run db:push # push schema (dev) db:generate generate SQL migrations +bun run db:migrate # apply migrations db:studio visual DB browser +``` + +Pre-commit hooks (Husky + lint-staged) automatically run Biome on staged files — +if your commit fails, read the Biome output, fix, and re-commit. + +### Conventions + +- **Env vars in `apps/web`:** always `import { env } from "~/env"` — never `process.env.*` directly. + New vars get added to `apps/web/src/env.ts` *and* the `.env.example` files. +- **UI components:** use [shadcn/ui](https://ui.shadcn.com). Add new ones from `apps/web`: + `bunx shadcn@latest add `. +- **Linting:** Biome only (no ESLint/Prettier). Python uses Ruff. + +--- + +## Python backend (optional) + +Only needed if you're working on `backends/fastapi` or the scrapers. + +```bash +cd backends/fastapi +cp .env.example .env # default DATABASE_URL works with the Docker database +uv sync # creates .venv and installs all dependencies +bun run dev # = uv run uvicorn app.main:app --reload --port 8000 +``` + +API docs live at . Lint with `uv run ruff check .` +and format with `uv run ruff format .`. + +--- + +## Branching workflow + +`main` is protected — you cannot push to it directly, and pull requests into `main` +are only accepted from `staging`. + +1. Branch off `main`: `git checkout -b feat/my-feature origin/main` +2. Open a PR **into `staging`** and merge it there +3. When `staging` is ready to ship, open a PR from `staging` into `main` + +--- + +## Troubleshooting + +**The app crashes on startup with "Invalid environment variables"** +A required var in `apps/web/.env.local` is missing or malformed — compare against +`apps/web/.env.local.example` and the table above. + +**`ECONNREFUSED` / `DATABASE_URL` errors** +The database container isn't running (`bun run db:up`), or your `DATABASE_URL` +uses the wrong port — the Docker database listens on **5434**, not 5432. + +**Port 5434 already in use** +Change `POSTGRES_PORT` in the root `.env` and update `DATABASE_URL` everywhere to match. + +**Husky hooks not running** +Re-run `bun install` from the repo root (the `prepare` script reinstalls hooks). + +**`bun run dev` doesn't start FastAPI** +Expected unless you've run `uv sync` in `backends/fastapi` and `uv` is on your PATH. + +**Wipe the database and start fresh** +`docker compose down -v` (deletes the data volume), then `bun run db:up && bun run db:push`. diff --git a/SETUP.md b/SETUP.md deleted file mode 100644 index afae4a4..0000000 --- a/SETUP.md +++ /dev/null @@ -1,227 +0,0 @@ -# The Forum — Setup Guide - -A Turborepo monorepo with: -- **`apps/web`** — Next.js 15 (App Router, Turbopack) -- **`apps/database`** — Drizzle ORM + PostgreSQL -- **`backends/fastapi`** — FastAPI (Python 3.12+) - ---- - -## Prerequisites - -### All platforms - -| Tool | Version | Install | -|------|---------|---------| -| [Bun](https://bun.sh) | ≥ 1.2 | `curl -fsSL https://bun.sh/install \| bash` | -| [Docker Desktop](https://www.docker.com/products/docker-desktop/) | latest | Download from docker.com | -| [uv](https://docs.astral.sh/uv/) | ≥ 0.5 | `curl -LsSf https://astral.sh/uv/install.sh \| sh` | -| [Git](https://git-scm.com) | ≥ 2.40 | Pre-installed on Mac; `winget install Git.Git` on Windows | - -### macOS specifics - -```bash -# Install Homebrew if not present -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - -# Install Bun -curl -fsSL https://bun.sh/install | bash - -# Install uv -curl -LsSf https://astral.sh/uv/install.sh | sh -``` - -### Windows specifics - -```powershell -# Install Bun (PowerShell, run as Administrator) -powershell -c "irm bun.sh/install.ps1 | iex" - -# Install uv (PowerShell) -powershell -c "irm https://astral.sh/uv/install.ps1 | iex" - -# Ensure Git Bash or WSL2 is available (recommended for shell scripts) -winget install Git.Git -``` - -> **Windows tip:** For the best experience use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) -> or Git Bash for all commands below. - ---- - -## 1 — Clone & Install JS dependencies - -```bash -git clone https://github.com/DIodide/TheForum.git -cd TheForum - -# Installs all JS/TS workspace packages and sets up Husky pre-commit hooks -bun install -``` - ---- - -## 2 — Environment Variables - -Copy the example files and fill in your values: - -```bash -# Root .env (used by docker-compose) -cp .env.example .env - -# Database package .env (used by drizzle-kit CLI) -cp apps/database/.env.example apps/database/.env - -# Next.js local env -cp apps/web/.env.local.example apps/web/.env.local - -# FastAPI env -cp backends/fastapi/.env.example backends/fastapi/.env -``` - -The defaults in `.env.example` work as-is for local Docker development. - ---- - -## 3 — Start the Database (Docker) - -```bash -# Start PostgreSQL container in the background -bun run db:up - -# Verify it's running -docker compose ps - -# Check logs if needed -bun run db:logs -``` - -The database is reachable at `postgresql://forum:forum_password@localhost:5432/the_forum`. - -### Run migrations - -```bash -# From the repo root — generates SQL migrations from schema changes -bun run db:generate # runs via turbo → apps/database - -# Apply migrations -bun run db:migrate - -# OR for rapid dev iteration (push schema directly, no migration files) -bun run db:push -``` - -### Drizzle Studio (visual DB browser) - -```bash -cd apps/database -bun run db:studio -``` - ---- - -## 4 — Install Python dependencies - -```bash -cd backends/fastapi - -# uv creates a .venv automatically and installs all deps -uv sync -``` - -This works identically on Mac, Linux, and Windows. - ---- - -## 5 — Run all dev servers - -From the repo root: - -```bash -bun run dev -``` - -This starts (in parallel via Turborepo): - -| Service | URL | -|---------|-----| -| Next.js (web) | http://localhost:3000 | -| FastAPI | http://localhost:8000 | -| FastAPI docs | http://localhost:8000/docs | - -### Run a single service - -```bash -# Web only -cd apps/web && bun run dev - -# FastAPI only -cd backends/fastapi && bun run dev -# or directly: -cd backends/fastapi && uv run uvicorn app.main:app --reload --port 8000 -``` - ---- - -## 6 — Linting & Formatting - -[Biome](https://biomejs.dev) handles JS/TS linting and formatting. - -```bash -# Lint + format all JS/TS files (auto-fix) -bun run check - -# Format only -bun run format - -# Check without fixing -bunx biome check . -``` - -Pre-commit hooks run automatically on `git commit` (via Husky + lint-staged), -enforcing Biome on all staged JS/TS/JSON/CSS files. - -For Python, use Ruff (already in dev dependencies): - -```bash -cd backends/fastapi -uv run ruff check . # lint -uv run ruff format . # format -``` - ---- - -## 7 — Stopping services - -```bash -# Stop the database container (data persisted in Docker volume) -bun run db:down - -# Stop + destroy all data -docker compose down -v -``` - ---- - -## Troubleshooting - -### `DATABASE_URL` not found -Make sure you copied the `.env.example` files (step 2) and that the database -container is running (`bun run db:up`). - -### Port 5432 already in use -Another PostgreSQL instance is running locally. Either stop it, or change -`POSTGRES_PORT` in your root `.env` (e.g. `POSTGRES_PORT=5433`) and update -`DATABASE_URL` accordingly. - -### `uv: command not found` on Windows -Restart your terminal after installing uv so the PATH update takes effect. -On PowerShell you may need: `$env:PATH += ";$env:USERPROFILE\.cargo\bin"`. - -### Husky hooks not running -Run `bun install` again from the repo root — the `prepare` script re-installs -the hooks. If using WSL2, make sure you're running git from inside WSL. - -### `bun run dev` doesn't start FastAPI -Ensure Python dependencies are installed (`cd backends/fastapi && uv sync`) -and that `uv` is on your PATH. diff --git a/apps/database/.env.example b/apps/database/.env.example index 144be91..47929e6 100644 --- a/apps/database/.env.example +++ b/apps/database/.env.example @@ -1,2 +1,2 @@ # Copy to .env for local development (used by drizzle-kit) -DATABASE_URL=postgresql://forum:forum_password@localhost:5432/the_forum +DATABASE_URL=postgresql://forum:forum_password@localhost:5434/the_forum diff --git a/apps/database/package.json b/apps/database/package.json index 183d242..01b9253 100644 --- a/apps/database/package.json +++ b/apps/database/package.json @@ -15,12 +15,12 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "drizzle-orm": "^0.38.3", + "drizzle-orm": "^0.45.2", "postgres": "^3.4.5" }, "devDependencies": { "@types/node": "^22.13.4", - "drizzle-kit": "^0.30.4", + "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^5.7.3" } diff --git a/apps/database/src/seed.ts b/apps/database/src/seed.ts index 620bbe3..b15cd16 100644 --- a/apps/database/src/seed.ts +++ b/apps/database/src/seed.ts @@ -10,12 +10,15 @@ import { campusLocations, eventTags, friendships, + interactions, + notifications, orgFollowers, orgMembers, organizations, rsvps, savedEvents, userInterests, + userRegions, users, } from "./schema"; @@ -355,6 +358,33 @@ async function seed() { } console.log(" User interests seeded"); + /* ═══ 3b. User Regions (set during onboarding) ═══ */ + const regionData: { + userId: string; + regions: ("central" | "east" | "west" | "south" | "north" | "off-campus")[]; + }[] = [ + { userId: uid("iamin"), regions: ["central", "east"] }, + { userId: uid("ajiang"), regions: ["central", "west"] }, + { userId: uid("arho"), regions: ["south"] }, + { userId: uid("pkap"), regions: ["east", "north"] }, + { userId: uid("syou"), regions: ["central"] }, + { userId: uid("cwang"), regions: ["west", "central"] }, + { userId: uid("gkash"), regions: ["south", "central"] }, + { userId: uid("jlee"), regions: ["west"] }, + { userId: uid("mkim"), regions: ["east"] }, + { userId: uid("rsingh"), regions: ["north", "central"] }, + { userId: uid("tchen"), regions: ["south", "west"] }, + { userId: uid("dpatel"), regions: ["central", "east"] }, + ]; + + for (const { userId, regions } of regionData) { + await db + .insert(userRegions) + .values(regions.map((region) => ({ userId, region }))) + .onConflictDoNothing(); + } + console.log(" User regions seeded"); + /* ═══ 4. Organizations ═══ */ const orgData = [ { @@ -497,6 +527,8 @@ async function seed() { { userId: uid("rsingh"), friendId: uid("gkash"), status: "accepted" as const }, { userId: uid("cwang"), friendId: uid("tchen"), status: "accepted" as const }, { userId: uid("mkim"), friendId: uid("dpatel"), status: "accepted" as const }, + // pending request so the friend-request UI has something to show + { userId: uid("mkim"), friendId: uid("iamin"), status: "pending" as const }, ]) .onConflictDoNothing(); console.log(" Friendships seeded"); @@ -723,8 +755,15 @@ async function seed() { }, ]; - const insertedEvents = []; + // Events have no unique constraint on title, so skip ones that already + // exist to keep re-runs of the seed from inserting duplicates. + const existingTitles = new Set( + (await db.select({ title: events.title }).from(events)).map((e) => e.title), + ); + + const insertedEvents: { id: string; tags: Tag[] }[] = []; for (const e of eventList) { + if (existingTitles.has(e.title)) continue; const { tags: tagList, ...vals } = e; const [inserted] = await db.insert(events).values(vals).returning({ id: events.id }); if (inserted) { @@ -737,27 +776,121 @@ async function seed() { } } } - console.log(` Events: ${insertedEvents.length}`); + console.log( + ` Events: ${insertedEvents.length} inserted, ${existingTitles.size} already present`, + ); /* ═══ 9. RSVPs ═══ */ const allUserIds = [...userMap.values()]; + const rsvpPairs: { userId: string; eventId: string }[] = []; for (const event of insertedEvents) { const attendees = pickN(allUserIds, 2 + Math.floor(Math.random() * 5)); for (const userId of attendees) { await db.insert(rsvps).values({ userId, eventId: event.id }).onConflictDoNothing(); + rsvpPairs.push({ userId, eventId: event.id }); } } console.log(" RSVPs seeded"); /* ═══ 10. Saved Events ═══ */ + const savePairs: { userId: string; eventId: string }[] = []; for (const userId of allUserIds) { const toSave = pickN(insertedEvents, 2 + Math.floor(Math.random() * 3)); for (const event of toSave) { await db.insert(savedEvents).values({ userId, eventId: event.id }).onConflictDoNothing(); + savePairs.push({ userId, eventId: event.id }); } } console.log(" Saved events seeded"); + /* ═══ 11. Notifications ═══ */ + // No natural unique key, so only seed when the table is empty. + const hasNotifications = await db.select({ id: notifications.id }).from(notifications).limit(1); + if (hasNotifications.length === 0) { + const allEvents = await db.select({ id: events.id, title: events.title }).from(events); + const eventIdByTitle = new Map(allEvents.map((e) => [e.title, e.id])); + const allHandsId = eventIdByTitle.get("TigerApps All Hands"); + const blockchainId = eventIdByTitle.get("Blockchain 101: What is Web3?"); + + // payload shapes mirror apps/web/src/actions/{friends,events,notifications}.ts + await db.insert(notifications).values([ + { + userId: uid("iamin"), + type: "friend_request" as const, + payload: { + fromUserId: uid("mkim"), + fromDisplayName: "Min-Jun Kim", + fromNetId: "mkim", + }, + }, + ...(allHandsId + ? [ + { + userId: uid("arho"), + type: "org_new_event" as const, + payload: { + eventId: allHandsId, + eventTitle: "TigerApps All Hands", + orgId: oid("Princeton TigerApps"), + orgName: "Princeton TigerApps", + }, + }, + ] + : []), + ...(blockchainId + ? [ + { + userId: uid("iamin"), + type: "event_reminder" as const, + payload: { eventId: blockchainId, eventTitle: "Blockchain 101: What is Web3?" }, + }, + ] + : []), + ]); + console.log(" Notifications seeded"); + } else { + console.log(" Notifications already present — skipped"); + } + + /* ═══ 12. Interactions (implicit feedback for recommendations) ═══ */ + // Weights mirror INTERACTION_WEIGHTS in apps/web/src/actions/interactions.ts. + // No natural unique key, so only seed when the table is empty. + const hasInteractions = await db.select({ id: interactions.id }).from(interactions).limit(1); + if (hasInteractions.length === 0) { + const rows = [ + ...rsvpPairs.map(({ userId, eventId }) => ({ + userId, + itemId: eventId, + itemType: "event" as const, + interactionType: "rsvp" as const, + interactionValue: 5.0, + })), + ...savePairs.map(({ userId, eventId }) => ({ + userId, + itemId: eventId, + itemType: "event" as const, + interactionType: "save" as const, + interactionValue: 3.0, + })), + // sprinkle of views and clicks so the rec pipeline has variety + ...allUserIds.flatMap((userId) => + pickN(insertedEvents, Math.min(4, insertedEvents.length)).map((event, i) => ({ + userId, + itemId: event.id, + itemType: "event" as const, + interactionType: (i % 2 === 0 ? "view" : "click") as "view" | "click", + interactionValue: i % 2 === 0 ? 1.0 : 2.0, + })), + ), + ]; + if (rows.length > 0) { + await db.insert(interactions).values(rows); + } + console.log(` Interactions seeded: ${rows.length}`); + } else { + console.log(" Interactions already present — skipped"); + } + console.log("\nSeed complete!"); process.exit(0); } diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index d253825..0b79c83 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -1,3 +1,26 @@ -# Copy to .env.local for local development -DATABASE_URL=postgresql://forum:forum_password@localhost:5432/the_forum +# Copy to .env.local for local development. +# All required vars are validated at startup by src/env.ts — see the root README +# for where to get each value. + +# ----- Database (Docker default — see `bun run db:up`) ----- +DATABASE_URL=postgresql://forum:forum_password@localhost:5434/the_forum + +# ----- Auth.js (Microsoft Entra ID / Princeton login) ----- +# Generate AUTH_SECRET yourself: openssl rand -base64 32 +AUTH_SECRET=your-auth-secret-here +# Ask Ibraheem for the client ID + secret +AUTH_AZURE_AD_CLIENT_ID=your-client-id +AUTH_AZURE_AD_CLIENT_SECRET=your-client-secret +# Princeton tenant ID (not secret) +AUTH_AZURE_AD_TENANT_ID=2ff60116-7431-425d-b5af-077d7791bda4 + +# ----- Mapbox (ask Ibraheem for these) ----- +NEXT_PUBLIC_MAPBOX_TOKEN=pk.your-mapbox-public-token +NEXT_PUBLIC_CAMPUS_MAP_TOKEN=pk.campus-map-public-token +NEXT_PUBLIC_CAMPUS_MAP_STYLE=mapbox://styles/account/style-id + +# ----- Optional ----- NEXT_PUBLIC_API_URL=http://localhost:8000 +# AWS S3 (image uploads) +# AWS_S3_BUCKET=the-forum-uploads +# AWS_REGION=us-east-1 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 0a20929..ea1d726 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -27,10 +27,12 @@ import { } from "@the-forum/database"; import { revalidatePath } from "next/cache"; import { auth } from "~/auth"; +import { formatEventDateTime } from "~/lib/date-format"; export interface FeedEvent { id: string; title: string; + description: string | null; orgId: string | null; orgName: string | null; datetime: string; @@ -240,15 +242,10 @@ export async function getFeedEvents(params?: { return { id: event.id, title: event.title, + description: event.description, orgId: event.orgId, orgName: event.orgName, - datetime: event.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(event.datetime), location: event.locationName ?? "TBD", tags: tagNames, flyerUrl: event.flyerUrl, @@ -276,6 +273,13 @@ export async function toggleRsvp(eventId: string): Promise<{ rsvped: boolean; co const userId = session.user.id; + // If the event doesn't exist in the DB (e.g. a demo/local-only event), no-op + const [eventRow] = await db.select().from(events).where(eq(events.id, eventId)).limit(1); + if (!eventRow) { + // Return zero count and no-op rsvp change to avoid FK constraint errors + return { rsvped: false, count: 0 }; + } + const [existing] = await db .select() .from(rsvps) @@ -307,6 +311,12 @@ export async function toggleSave(eventId: string): Promise<{ saved: boolean }> { const userId = session.user.id; + // If the event doesn't exist in the DB (e.g. demo/local-only event), no-op + const [eventRow] = await db.select().from(events).where(eq(events.id, eventId)).limit(1); + if (!eventRow) { + return { saved: false }; + } + const [existing] = await db .select() .from(savedEvents) @@ -490,6 +500,7 @@ export async function getSimilarEvents( .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -506,15 +517,10 @@ export async function getSimilarEvents( return rawEvents.map((event) => ({ id: event.id, title: event.title, + description: event.description, orgId: event.orgId, orgName: event.orgName, - datetime: event.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(event.datetime), location: event.locationName ?? "TBD", tags: [], flyerUrl: event.flyerUrl, @@ -712,6 +718,7 @@ export async function getMyEvents(): Promise<{ .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -729,6 +736,7 @@ export async function getMyEvents(): Promise<{ .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -747,6 +755,7 @@ export async function getMyEvents(): Promise<{ .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -763,15 +772,10 @@ export async function getMyEvents(): Promise<{ const mapEvent = (e: (typeof createdEvents)[0]): FeedEvent => ({ id: e.id, title: e.title, + description: e.description, orgId: e.orgId, orgName: e.orgName, - datetime: e.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(e.datetime), location: e.locationName ?? "TBD", tags: [], flyerUrl: e.flyerUrl, @@ -811,6 +815,7 @@ export async function getSavedEvents(): Promise { .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -828,15 +833,10 @@ export async function getSavedEvents(): Promise { return saved.map((event) => ({ id: event.id, title: event.title, + description: event.description, orgId: event.orgId, orgName: event.orgName, - datetime: event.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(event.datetime), location: event.locationName ?? "TBD", tags: [], flyerUrl: event.flyerUrl, @@ -878,6 +878,7 @@ export async function getFriendsEvents(): Promise { .select({ id: events.id, title: events.title, + description: events.description, datetime: events.datetime, flyerUrl: events.flyerUrl, locationName: campusLocations.name, @@ -905,15 +906,10 @@ export async function getFriendsEvents(): Promise { return friendsEvents.map((event) => ({ id: event.id, title: event.title, + description: event.description, orgId: event.orgId, orgName: event.orgName, - datetime: event.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(event.datetime), location: event.locationName ?? "TBD", tags: [], flyerUrl: event.flyerUrl, diff --git a/apps/web/src/actions/orgs.ts b/apps/web/src/actions/orgs.ts index 517c35a..e3015f7 100644 --- a/apps/web/src/actions/orgs.ts +++ b/apps/web/src/actions/orgs.ts @@ -20,6 +20,7 @@ import { } from "@the-forum/database"; import { revalidatePath } from "next/cache"; import { auth } from "~/auth"; +import { formatEventDateTime } from "~/lib/date-format"; export interface OrgListItem { id: string; @@ -176,13 +177,7 @@ export async function getOrg(orgId: string): Promise { return { id: e.id, title: e.title, - datetime: e.datetime.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }), + datetime: formatEventDateTime(e.datetime), locationName: e.locationName ?? "TBD", flyerUrl: e.flyerUrl, tags: tags.map((t) => t.tag), diff --git a/apps/web/src/actions/users.ts b/apps/web/src/actions/users.ts index 0d9aec2..6a90557 100644 --- a/apps/web/src/actions/users.ts +++ b/apps/web/src/actions/users.ts @@ -106,13 +106,51 @@ export interface UserProfile { regions: string[]; } -export async function getUserProfile(): Promise { +async function getCurrentUser() { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); - const [user] = await db.select().from(users).where(eq(users.id, session.user.id)).limit(1); + const [userById] = await db.select().from(users).where(eq(users.id, session.user.id)).limit(1); + if (userById) return userById; + + if (session.user.netId) { + const [userByNetId] = await db + .select() + .from(users) + .where(eq(users.netId, session.user.netId)) + .limit(1); + if (userByNetId) return userByNetId; + } + + if (session.user.email) { + const [userByEmail] = await db + .select() + .from(users) + .where(eq(users.email, session.user.email)) + .limit(1); + if (userByEmail) return userByEmail; + } + + const netId = session.user.netId ?? session.user.email?.split("@")[0]?.toLowerCase(); + if (!netId || !session.user.email) throw new Error("User not found"); + + const [createdUser] = await db + .insert(users) + .values({ + id: session.user.id, + netId, + email: session.user.email, + displayName: session.user.name ?? netId, + }) + .returning(); + + if (!createdUser) throw new Error("User not found"); + + return createdUser; +} - if (!user) throw new Error("User not found"); +export async function getUserProfile(): Promise { + const user = await getCurrentUser(); const interests = await db .select({ tag: userInterests.tag }) @@ -145,10 +183,8 @@ export async function updateProfile(data: { interests?: string[]; regions?: string[]; }): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); - - const userId = session.user.id; + const user = await getCurrentUser(); + const userId = user.id; await db .update(users) @@ -185,18 +221,16 @@ export async function updateProfile(data: { } revalidatePath("/settings"); + revalidatePath("/profile"); revalidatePath("/explore"); } export async function updateAvatar(avatarUrl: string): Promise { - const session = await auth(); - if (!session?.user?.id) throw new Error("Unauthorized"); + const user = await getCurrentUser(); - await db - .update(users) - .set({ avatarUrl, updatedAt: new Date() }) - .where(eq(users.id, session.user.id)); + await db.update(users).set({ avatarUrl, updatedAt: new Date() }).where(eq(users.id, user.id)); revalidatePath("/settings"); + revalidatePath("/profile"); revalidatePath("/explore"); } diff --git a/apps/web/src/app/(app)/explore/explore-client.tsx b/apps/web/src/app/(app)/explore/explore-client.tsx index a291b69..07a84df 100644 --- a/apps/web/src/app/(app)/explore/explore-client.tsx +++ b/apps/web/src/app/(app)/explore/explore-client.tsx @@ -12,6 +12,7 @@ import { } from "~/actions/events"; import { EventCard } from "~/components/events/event-card"; import { EventFilters } from "~/components/events/event-filters"; +import { formatEventDateTime } from "~/lib/date-format"; interface ExploreClientProps { initialEvents: FeedEvent[]; @@ -23,6 +24,28 @@ interface ExploreClientProps { userAvatarUrl?: string | null; } +// fake temporary event for UI testing +const demoEvent: FeedEvent = { + // Use a valid UUID so server-side DB operations don't error on demo data + id: "00000000-0000-0000-0000-000000000000", + title: "Fake Event", + description: "Practice event data for UI testing.", + orgId: "tigerapps", + orgName: "TigerApps", + // Use a fixed demo timestamp so server and client HTML match during hydration + datetime: formatEventDateTime(new Date("2026-06-17T22:25:00Z")), + location: "Lewis 122", + tags: ["music", "free-food", "performance"], + flyerUrl: null, + rsvpCount: 42, + friendsAttending: [ + { id: "user-1", displayName: "Donald Grump", avatarUrl: null }, + { id: "user-2", displayName: "Elvis Parsley", avatarUrl: null }, + ], + isRsvped: false, + isSaved: false, +}; + function getTodayString() { return new Date().toLocaleDateString("en-US", { weekday: "long", @@ -41,8 +64,9 @@ export function ExploreClient({ userName = "there", userAvatarUrl, }: ExploreClientProps) { - const [events, setEvents] = useState(initialEvents); - const [_total, setTotal] = useState(initialTotal); + const fallbackEvents = initialEvents.length > 0 ? initialEvents : [demoEvent]; + const [events, setEvents] = useState(fallbackEvents); + const [_total, setTotal] = useState(initialEvents.length > 0 ? initialTotal : 1); const [activeFilters, setActiveFilters] = useState([]); const [searchQuery, setSearchQuery] = useState(initialSearch); const [isPending, startTransition] = useTransition(); @@ -94,35 +118,24 @@ export function ExploreClient({ ); return ( -
+
{/* CENTER — Feed */} -
- {/* Avatar */} -
-
- {userAvatarUrl ? ( - {firstName} - ) : ( -
- {firstName[0]?.toUpperCase()} -
- )} -
-
- +
{/* Greeting */} -

- Hello - {firstName}, -

-
-
-

Today is {getTodayString()}

+
+

+ Hi + {firstName}, +

+
+
+

Today is {getTodayString()}

+
{/* Search */} -
-
+
+
{/* Filters */} -
+
{/* Feed */} -
+
{events.length === 0 ? (

diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx new file mode 100644 index 0000000..0bd9f09 --- /dev/null +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -0,0 +1,9 @@ +import { getFriends } from "~/actions/friends"; +import { getUserProfile } from "~/actions/users"; +import { SettingsClient } from "../settings/settings-client"; + +export default async function ProfilePage() { + const [profile, friends] = await Promise.all([getUserProfile(), getFriends()]); + + return ; +} diff --git a/apps/web/src/app/5/page.tsx b/apps/web/src/app/5/page.tsx deleted file mode 100644 index 533d023..0000000 --- a/apps/web/src/app/5/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { - CalendarDays, - Globe, - Heart, - MessageCircle, - Newspaper, - UserCircle, - Users, -} from "lucide-react"; - -// ── Sidebar ─────────────────────────────────────────────────── -const NAV = [ - { id: "feeds", Icon: Newspaper, label: "Feeds" }, - { id: "event", Icon: CalendarDays, label: "Event", badge: 3 }, - { id: "charity", Icon: Heart, label: "Charity" }, - { id: "friends", Icon: Users, label: "Friends" }, - { id: "community", Icon: MessageCircle, label: "Community" }, -]; - -const FOLLOWING = [ - { id: "u1", name: "Shiqara Miramini", color: "#6366f1" }, - { id: "u2", name: "Charlie Zapln", color: "#ec4899" }, - { id: "u3", name: "Pope Francis", color: "#f97316" }, - { id: "u4", name: "Donald Drump", color: "#22c55e" }, - { id: "u5", name: "Elvis Presley", color: "#3b82f6" }, -]; - -// ── Top tabs ────────────────────────────────────────────────── -const TABS = [ - { id: "community", label: "Community", Icon: Globe }, - { id: "friends", label: "Friends", Icon: Users, active: true }, - { id: "event", label: "Event", Icon: CalendarDays }, - { id: "profile", label: "Profile", Icon: UserCircle }, -]; - -// ── My Events cards ─────────────────────────────────────────── -const MY_EVENTS = [ - { - id: "me1", - title: "TigerApps Meeting", - date: "Wed, 2 Jan, 2026", - badge: "1-2B", - time: "3:00 PM – 5:30 PM", - bg: "#ede9fe", - }, - { - id: "me2", - title: "TigerApps Meeting", - date: "Wed, 2 Jan, 2026", - badge: "1-2B", - time: "3:00 PM – 5:30 PM", - bg: "#fce7f3", - }, - { - id: "me3", - title: "TigerApps Meeting", - date: "Wed, 2 Jan, 2026", - badge: "1-2B", - time: "3:00 PM – 5:30 PM", - bg: "#d1fae5", - }, -]; - -// ── Calendar ────────────────────────────────────────────────── -const CAL_DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; -const CAL_DATES = [ - [null, null, null, null, null, 1, 2], - [3, 4, 5, 6, 7, 8, 9], - [10, 11, 12, 13, 14, 15, 16], - [17, 18, 19, 20, 21, 22, 23], - [24, 25, 26, 27, 28, 29, 30], -]; -const DOTTED_DAYS = new Set([5, 12, 19, 26]); -const TODAY_CAL = 26; - -// ── Friends panel ───────────────────────────────────────────── -const FRIENDS = [ - { id: "f1", name: "Albert Shu", mutual: 2, color: "#6366f1" }, - { id: "f2", name: "Friend 3", mutual: 5, color: "#ec4899" }, - { id: "f3", name: "Friend 5", mutual: 3, color: "#f97316" }, - { id: "f4", name: "Friend 6", mutual: 1, color: "#22c55e" }, -]; - -export default function Page5() { - return ( -

- {/* ── Left Sidebar ────────────────────────────────────── */} - - - {/* ── Main Content ─────────────────────────────────────── */} -
- {/* Top bar with tabs */} -
-
-

Hello Albert,

-

Today is Tuesday, 17 February 2026

-
-
- {TABS.map(({ id, label, Icon, active }) => ( - - ))} -
-
- - {/* My Events */} -
-

My Events

-
- {MY_EVENTS.map(({ id, title, date, badge, time, bg }) => ( -
-
-

{title}

-
- {date} - - {badge} - -
-

{time}

-
- ))} -
-
-
- - {/* ── Right Panel ──────────────────────────────────────── */} - -
- ); -} diff --git a/apps/web/src/app/6/page.tsx b/apps/web/src/app/6/page.tsx deleted file mode 100644 index 2d1800c..0000000 --- a/apps/web/src/app/6/page.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { - Bell, - BookmarkPlus, - CalendarDays, - Home, - LogOut, - Map as MapIcon, - Search, - Settings, - Share2, - UserCircle, - Users, -} from "lucide-react"; - -const NAV = [ - { id: "home", Icon: Home, label: "Home", active: true }, - { id: "events", Icon: CalendarDays, label: "My Events" }, - { id: "map", Icon: MapIcon, label: "Map" }, - { id: "friends", Icon: Users, label: "My Friends" }, -]; - -const FILTER_CIRCLES = [ - { id: "orange", color: "#fb923c", label: "Art" }, - { id: "purple", color: "#a78bfa", label: "STEM" }, - { id: "yellow", color: "#fbbf24", label: "Music" }, - { id: "blue", color: "#60a5fa", label: "Sports" }, - { id: "pink", color: "#f472b6", label: "Social" }, -]; - -const EVENT_CARDS = [ - { - id: "ec1", - title: "Spring Dance Workshop", - date: "Feb 8, 8:30–10:00pm", - bg: "#1e1b4b", - accent: "#818cf8", - }, - { id: "ec2", title: "Book Donation Drive", date: "Feb 25–26", bg: "#fef3c7", accent: "#f59e0b" }, - { - id: "ec3", - title: "AI in College Panel", - date: "Feb 10, 5:00pm", - bg: "#eff6ff", - accent: "#3b82f6", - }, - { id: "ec4", title: "Cookie Social", date: "Feb 15, 3:00pm", bg: "#fef9c3", accent: "#ca8a04" }, - { - id: "ec5", - title: "Taiko Drumming Workshop", - date: "Feb 8, 11:30am", - bg: "#dc2626", - accent: "#fca5a5", - }, - { id: "ec6", title: "Study Session", date: "Feb 12, 7:00pm", bg: "#f0fdf4", accent: "#22c55e" }, -]; - -const RECENTLY_SAVED = [ - { - id: "rs1", - title: "Event With Too Many Exclamation Marks!!!", - from: "From Princeton TigerApps", - loc: "First Campus Center", - time: "Fri, Feb 27 at 7:30 PM", - color: "#dbeafe", - }, - { - id: "rs2", - title: "Dave's Hot Chicken Social", - from: "From Campus Center", - loc: "First Campus Center", - time: "Fri, Feb 27 at 7:30 PM", - color: "#fce7f3", - }, - { - id: "rs3", - title: "[Event] With the Brackets in Front", - from: "From Princeton TigerApps", - loc: "First Campus Center", - time: "Fri, Feb 27 at 7:30 PM", - color: "#d1fae5", - }, -]; - -const FRIENDS = [ - { - id: "f1", - name: "Dave's Not Chicken", - sub: "Angelina, Dalpher + 2 more are going", - color: "#6366f1", - }, - { id: "f2", name: "Mary Cecil Dance", sub: "Joshua is going", color: "#ec4899" }, - { - id: "f3", - name: "A Capella Arch Sing Event", - sub: "Brodwell and Andy are going", - color: "#f97316", - }, -]; - -export default function Page6() { - return ( -
- {/* ── Top Nav ───────────────────────────────────────────── */} -
- {/* Search bar */} -
- - - Search for classes, social, community, talking events - -
-
- - -
- A -
-
-
- - {/* ── Filter circles ────────────────────────────────────── */} -
- {FILTER_CIRCLES.map(({ id, color, label }) => ( - - ))} - -
- - {/* ── Body ─────────────────────────────────────────────── */} -
- {/* Dark left sidebar */} - - - {/* ── Main event grid ───────────────────────────────── */} -
-
- {EVENT_CARDS.map(({ id, title, date, bg, accent }) => ( -
-
-
-

{title}

-

{date}

-
- - -
-
-
- ))} -
-
- - {/* ── Right panel ──────────────────────────────────── */} - -
-
- ); -} diff --git a/apps/web/src/app/7/page.tsx b/apps/web/src/app/7/page.tsx deleted file mode 100644 index a61e268..0000000 --- a/apps/web/src/app/7/page.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { Bell, ChevronLeft, Heart, Plus, Share2, UserCircle } from "lucide-react"; - -const ATTENDEE_COLORS = [ - { id: "a1", color: "#22c55e" }, - { id: "a2", color: "#ef4444" }, - { id: "a3", color: "#3b82f6" }, - { id: "a4", color: "#22c55e" }, - { id: "a5", color: "#06b6d4" }, -]; - -const SIMILAR_EVENTS = [ - { - id: "se1", - title: "T8 Dance Workshop", - date: "February 8, 8:30–10:00pm", - bg: "#1e1b4b", - textColor: "white", - }, - { - id: "se2", - title: "Book Donation Drive", - date: "Feb 25–26", - bg: "#fef3c7", - textColor: "#78350f", - }, - { - id: "se3", - title: "First Gen College Students with AI", - date: "February 10, 5pm", - bg: "#eff6ff", - textColor: "#1e40af", - }, - { - id: "se4", - title: "Shrinky Dink & Cookie Seasonal", - date: "February 15, 3pm", - bg: "#fef9c3", - textColor: "#78350f", - }, -]; - -export default function Page7() { - return ( -
- {/* ── Header ───────────────────────────────────────────── */} -
-
- The Forum -
-
- - - -
-
- - {/* ── Back link ────────────────────────────────────────── */} -
- -
- - {/* ── Main event detail ─────────────────────────────────── */} -
- {/* Left: event flyer */} -
-
- {/* Title block */} -
-

Spring Beginner

-

Workshops

-
-
-

Intro to Taiko

-

Friday 2/6, 7–9pm

-

McAlpin Room, Woolworth Hall

-
-
-

Odaiko Fundamentals

-

Sunday 2/8, 11:30–1:30pm

-

McAlpin Room

-
-
-
- - {/* Vertical "tora taiko" text */} -
- - tora taiko - -
- - {/* Bottom: QR + note */} -
-
-

No musical experience required

-
-
-
- - {/* Right: event info */} -
- {/* Actions */} -
- - -
- -

- Tora Taiko Beginner Workshops -

- - Public Event - - -

- Want to learn tons of fun things by drum? We're holding two beginner workshops to learn - taiko — the art of Japanese drumming. Come to either or both! -

- -
    -
  • - • Intro to Taiko — Friday 2/6, 7–9pm, McAlpin - Room, Woolworth Hall -
  • -
  • - • Odaiko Fundamentals — Sunday 2/8, 11:30–1:30pm, - McAlpin Room in the Woodworth Music Building -
  • -
- -

- No musical experience required. See you there!! -

- - {/* RSVP button */} - - - {/* Attendees */} -
-
- {ATTENDEE_COLORS.map(({ id, color }, idx) => ( -
- ))} -
- 9 attending -
-
-
- - {/* ── Similar Events ─────────────────────────────────────── */} -
-

Similar Events

-
- {SIMILAR_EVENTS.map(({ id, title, date, bg, textColor }) => ( -
-
-

- {title} -

-

- {date} -

-
-
- ))} -
-
-
- ); -} diff --git a/apps/web/src/app/8/page.tsx b/apps/web/src/app/8/page.tsx deleted file mode 100644 index 972dccd..0000000 --- a/apps/web/src/app/8/page.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { Bell, ChevronLeft, Heart, Plus, Share2, UserCircle } from "lucide-react"; - -const ATTENDEE_COLORS = [ - { id: "a1", color: "#22c55e" }, - { id: "a2", color: "#ef4444" }, - { id: "a3", color: "#3b82f6" }, - { id: "a4", color: "#22c55e" }, - { id: "a5", color: "#06b6d4" }, -]; - -const SIMILAR_EVENTS = [ - { - id: "se1", - title: "T8 Dance Workshop", - date: "February 8, 8:30–10:00pm", - bg: "#0f172a", - textColor: "white", - }, - { - id: "se2", - title: "Book Donation Drive", - date: "Feb 25–26", - bg: "#fffbeb", - textColor: "#92400e", - }, - { - id: "se3", - title: "First Gen College Students with AI", - date: "February 10, 5pm", - bg: "#1e3a8a", - textColor: "white", - }, - { - id: "se4", - title: "Shrinky Dink & Cookie Seasonal", - date: "February 15, 3pm", - bg: "#fefce8", - textColor: "#854d0e", - }, -]; - -export default function Page8() { - return ( -
- {/* ── Header ───────────────────────────────────────────── */} -
- The Forum -
- - - -
-
- - {/* ── Back link ────────────────────────────────────────── */} -
- -
- - {/* ── Main detail ───────────────────────────────────────── */} -
- {/* Left: event flyer — higher fidelity (deeper blue tint) */} -
-
-
-

- Spring Beginner -

-

- Workshops -

-
-
-

Intro to Taiko

-

- Friday 2/6, 7–9pm · McAlpin Room, Woolworth Hall -

-
-
-

Odaiko Fundamentals

-

- Sunday 2/8, 11:30–1:30pm · McAlpin Room -

-
-
-
- - {/* Vertical title — more visible */} -
- - tora taiko - -
- -
-
-

No musical experience required

-
-
-
- - {/* Right: event info */} -
-
- - -
- -

- Tora Taiko Beginner Workshops -

-
- - Public Event - -
- -

- Want to learn tons of fun things by drum? We're holding two beginner workshops to learn - taiko — the art of Japanese drumming. Come to either or both! -

- -
    -
  • - - - Intro to Taiko — Friday 2/6, 7–9pm, McAlpin - Room, Woolworth Hall - -
  • -
  • - - - Odaiko Fundamentals — Sunday 2/8, - 11:30–1:30pm, McAlpin Room in the Woodworth Music Building - -
  • -
- -

- No musical experience required. See you there!! -

- - - -
-
- {ATTENDEE_COLORS.map(({ id, color }, idx) => ( -
- ))} -
-
- 9 attending -

and growing

-
-
-
-
- - {/* ── Similar Events ─────────────────────────────────────── */} -
-

- Similar Events -

-
- {SIMILAR_EVENTS.map(({ id, title, date, bg, textColor }) => ( -
-
-

- {title} -

-

- {date} -

-
-
- ))} -
-
-
- ); -} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b00bfd3..3cd13e3 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -3,7 +3,7 @@ @import "shadcn/tailwind.css"; @import "mapbox-gl/dist/mapbox-gl.css"; -@custom-variant dark (&:is(.dark *)); +@variant dark (&:is(.dark *)); @theme inline { --radius-sm: calc(var(--radius) - 4px); @@ -44,28 +44,41 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(255, 119, 0, 0.15); + --shadow-2xl: 0 25px 50px -12px rgba(255, 119, 0, 0.2); + --shadow-white-sm: 0 1px 2px 0 rgba(255, 255, 255, 0.25); + --shadow-white-md: 0 4px 6px -1px rgba(255, 255, 255, 0.5); + --shadow-white-lg: 0 10px 15px -3px rgba(255, 255, 255, 0.5); + --shadow-white-xl: 0 20px 25px -10px rgba(255, 255, 255, 0.5); + --shadow-white-2xl: 0 25px 50px -12px rgba(255, 255, 255, 0.5); /* Forum Design System — Fonts */ --font-source-serif: var(--font-source-serif); --font-dm-sans: var(--font-dm-sans); --font-dm-mono: var(--font-dm-mono); --font-inter: var(--font-inter); + --font-kalnia: var(--font-kalnia); /* Forum Design System — Colors */ --color-forum-orange: #ff7700; --color-forum-coral: #ff7151; --color-forum-coral-light: rgba(255, 156, 133, 0.25); --color-forum-coral-bg: rgba(255, 156, 133, 0.1); + --color-forum-coral-50: rgba(255, 156, 133, 0.5); --color-forum-turquoise: #a2eff0; --color-forum-turquoise-20: rgba(162, 239, 240, 0.2); --color-forum-turquoise-50: rgba(162, 239, 240, 0.5); --color-forum-pink: #ffd3ea; --color-forum-yellow: #fee882; --color-forum-yellow-50: rgba(254, 232, 130, 0.5); + --color-forum-yellow-10: rgba(254, 232, 130, 0.1); --color-forum-cerulean: #0a9cd5; --color-forum-black: #000000; --color-forum-dark-gray: #585858; - --color-forum-medium-gray: #d9d9d9; + --color-forum-medium-gray: #ececec; --color-forum-light-gray: #767676; --color-forum-placeholder: #889caf; --color-forum-border: #e1dfdb; @@ -109,12 +122,72 @@ @layer base { * { - @apply border-border outline-ring/50; + border-color: var(--color-border); + outline-color: color-mix(in srgb, var(--color-ring) 50%, transparent); } body { - @apply bg-background text-foreground; + background-color: var(--color-background); + color: var(--color-foreground); font-family: var(--font-dm-sans), sans-serif; } + html { + scroll-behavior: smooth; + scroll-padding-top: 4rem; + } +} + +@layer components { + /* button styles */ + .button-white { + padding: 1rem 1.5rem; + font-size: 0.875rem; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + background-color: rgba(255, 255, 255, 0.6); + border-radius: 6px; + box-shadow: var(--shadow-white-xl); + transition: all 300ms ease-in; + } + + .button-white:hover { + scale: 1.05; + background-color: rgba(255, 255, 255, 1); + color: var(--color-forum-black); + } + + .button-coral { + padding: 0.5rem 1rem; + background-color: rgba(253, 102, 64, 0.8); + color: white; + font-weight: 700; + font-size: 0.8125rem; + letter-spacing: 0.06em; + border-radius: 0.375rem; + transition: background-color 0.2s ease; + white-space: nowrap; + } + + .button-coral:hover { + background-color: var(--color-forum-coral); + } + + /* event cards */ + .card { + border-radius: var(--radius); + background-color: white; + border: 0.08rem solid var(--color-forum-medium-gray); + } + + .icon { + color: var(--color-forum-dark-gray); + transition: all 200ms ease-in; + } + + .icon:hover { + scale: 1.1; + color: var(--color-forum-coral); + } } /* Font utility classes */ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 37266cc..5886fb2 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { DM_Mono, DM_Sans, Inter, Source_Serif_4 } from "next/font/google"; +import { DM_Mono, DM_Sans, Inter, Kalnia, Source_Serif_4 } from "next/font/google"; import { Toaster } from "~/components/ui/sonner"; import "./globals.css"; @@ -27,6 +27,11 @@ const inter = Inter({ variable: "--font-inter", }); +const kalnia = Kalnia({ + subsets: ["latin"], + variable: "--font-kalnia", +}); + export const metadata: Metadata = { title: "Forum — Princeton Campus Events", description: "Your social life, curated. Discover campus events personalized for you.", @@ -40,7 +45,7 @@ export default function RootLayout({ return ( {children} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c2e6561..a2be016 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,232 +2,246 @@ import { signIn } from "~/auth"; export default function LandingPage() { return ( -
- {/* ═══ NAV BAR ═══ */} -
-
- - The Forum - - - by TigerApps - +
+ {/* ═══ NAV BAR (static) ═══ */} +
+ -
{/* ═══ HERO SECTION ═══ */} -
+
{/* Geometric background shapes — large, soft, overlapping */} -
- {/* ═══ WHAT WE OFFER SECTION ═══ */} -
-

- - What We Offer -

+ {/* ═══ FOR EVENT ATTENDEES ═══ */} +
+ {" "} + {/* Left column */} +
+ {/* Mockup */} +
+
+
+ UI mockup goes here +
+
-

- Everything you need -
- to never miss a thing -

+ {/* Quote */} +
+
+

+ “ +

+

+ Students shouldn't have to dig through listservs and group chats to find out + what's happening on their own campus.{" "} + + The best moments deserve to be discovered.” + +

+
+
+ +

+ The Forum Team — Princeton TigerApps +

+
+
+
+ {/* Right column */} +
+ {/* Label + heading */} +
+
+ +

+ For Event Attendees +

+
+

+ Everything you need to never miss{" "} + a thing. +

+
-
-

- “Students shouldn't have to dig through listservs and group chats to find out - what's happening on their own campus. -

-

- The best moments deserve to be discovered.” -

-
-

- The Forum Team — Princeton TigerApps -

+ {/* Second mockup */} +
+
+
+ Second mockup goes here +
+
- {/* ═══ TWO-COLUMN CARDS ═══ */} -
- {/* For Students — salmon/coral background */} -
-

- - For Students -

-

- Your campus -

-

- All of it. -

-

- Stop hearing about events after the fact. Join Forum and let campus life come to you — - curated, social, and completely personal. -

-
{ - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/explore" }); - }} - > - -
+ + +
- {/* For Org Leaders — light pink background */} -
-

- For Organization/Club Leaders -

-

- Put your events in front -
- of people who care. -

-

- Create your organization's page, publish events in minutes, and reach students - whose interests actually align with what you're building. -

-
{ - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/events/create" }); - }} - > - -
-
- + + +
- {/* ═══ FORUM FOOTER WORDMARK ═══ */} -
-

- FORUM -

+ {/* FORUM wordmark at bottom */} +
+

+ FORUM +

+
+
{/* ═══ FOOTER ═══ */} -