From 159c696f32abf08f423ec3805f1d98e3ed66de3f Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 13:58:40 -0800 Subject: [PATCH 01/52] staff link --- apps/backend/src/bootstrap/loaders/express.ts | 2 + apps/staff-frontend/.gitignore | 3 ++ apps/staff-frontend/Dockerfile | 22 +++++++++++ apps/staff-frontend/eslint.config.mjs | 3 ++ apps/staff-frontend/index.html | 13 +++++++ apps/staff-frontend/package.json | 27 +++++++++++++ apps/staff-frontend/src/App.tsx | 15 ++++++++ apps/staff-frontend/src/main.tsx | 5 +++ apps/staff-frontend/src/vite-env.d.ts | 1 + apps/staff-frontend/tsconfig.json | 12 ++++++ apps/staff-frontend/tsconfig.node.json | 10 +++++ apps/staff-frontend/vite.config.ts | 29 ++++++++++++++ docker-compose.yml | 13 +++++++ infra/staff/.helmignore | 23 +++++++++++ infra/staff/Chart.yaml | 24 ++++++++++++ infra/staff/templates/_helpers.tpl | 25 ++++++++++++ infra/staff/templates/frontend.yaml | 38 +++++++++++++++++++ infra/staff/templates/ingress.yaml | 25 ++++++++++++ infra/staff/values.yaml | 17 +++++++++ nginx.conf | 16 ++++++++ package-lock.json | 22 +++++++++++ 21 files changed, 345 insertions(+) create mode 100644 apps/staff-frontend/.gitignore create mode 100644 apps/staff-frontend/Dockerfile create mode 100644 apps/staff-frontend/eslint.config.mjs create mode 100644 apps/staff-frontend/index.html create mode 100644 apps/staff-frontend/package.json create mode 100644 apps/staff-frontend/src/App.tsx create mode 100644 apps/staff-frontend/src/main.tsx create mode 100644 apps/staff-frontend/src/vite-env.d.ts create mode 100644 apps/staff-frontend/tsconfig.json create mode 100644 apps/staff-frontend/tsconfig.node.json create mode 100644 apps/staff-frontend/vite.config.ts create mode 100644 infra/staff/.helmignore create mode 100644 infra/staff/Chart.yaml create mode 100644 infra/staff/templates/_helpers.tpl create mode 100644 infra/staff/templates/frontend.yaml create mode 100644 infra/staff/templates/ingress.yaml create mode 100644 infra/staff/values.yaml diff --git a/apps/backend/src/bootstrap/loaders/express.ts b/apps/backend/src/bootstrap/loaders/express.ts index bad42a2b1..94bd49cb2 100644 --- a/apps/backend/src/bootstrap/loaders/express.ts +++ b/apps/backend/src/bootstrap/loaders/express.ts @@ -27,7 +27,9 @@ export default async ( config.url, "http://localhost:8080", "http://localhost:8081", + "http://localhost:8082", "https://ag.berkeleytime.com", + "https://staff.berkeleytime.com", ], credentials: true, }) diff --git a/apps/staff-frontend/.gitignore b/apps/staff-frontend/.gitignore new file mode 100644 index 000000000..578fcf980 --- /dev/null +++ b/apps/staff-frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.turbo diff --git a/apps/staff-frontend/Dockerfile b/apps/staff-frontend/Dockerfile new file mode 100644 index 000000000..cdb4ef59d --- /dev/null +++ b/apps/staff-frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:alpine AS base +RUN ["npm", "install", "-g", "turbo@latest"] + +FROM base AS staff-frontend-builder +WORKDIR /staff-frontend +COPY . . +RUN ["turbo", "prune", "staff-frontend", "--docker"] + +FROM base AS staff-frontend-dev +WORKDIR /staff-frontend + +COPY --from=staff-frontend-builder /staff-frontend/out/json/ . +COPY --from=staff-frontend-builder /staff-frontend/out/package-lock.json ./package-lock.json +RUN ["npm", "install"] + +COPY --from=staff-frontend-builder /staff-frontend/out/full/ . +ENTRYPOINT ["turbo", "run", "dev", "--filter=staff-frontend"] + +FROM staff-frontend-dev AS staff-frontend-prod +RUN ["turbo", "run", "build", "--filter=staff-frontend", "--env-mode=loose"] + +ENTRYPOINT ["turbo", "run", "start", "--filter=staff-frontend"] diff --git a/apps/staff-frontend/eslint.config.mjs b/apps/staff-frontend/eslint.config.mjs new file mode 100644 index 000000000..a5a38b5ff --- /dev/null +++ b/apps/staff-frontend/eslint.config.mjs @@ -0,0 +1,3 @@ +import config from "@repo/eslint-config/index.mjs"; + +export default [...config]; diff --git a/apps/staff-frontend/index.html b/apps/staff-frontend/index.html new file mode 100644 index 000000000..8d83918c8 --- /dev/null +++ b/apps/staff-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Staff Dashboard — Berkeleytime + + + +
+ + + diff --git a/apps/staff-frontend/package.json b/apps/staff-frontend/package.json new file mode 100644 index 000000000..3cde4ada0 --- /dev/null +++ b/apps/staff-frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "staff-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src/", + "start": "serve -s dist -p 3002" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/node": "^24.7.0", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.37.0", + "serve": "^14.2.5", + "typescript": "^5.9.3", + "vite": "7.1.9" + } +} diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx new file mode 100644 index 000000000..94007b152 --- /dev/null +++ b/apps/staff-frontend/src/App.tsx @@ -0,0 +1,15 @@ +export default function App() { + return ( +
+

Staff Dashboard

+
+ ); +} diff --git a/apps/staff-frontend/src/main.tsx b/apps/staff-frontend/src/main.tsx new file mode 100644 index 000000000..d9c2538dc --- /dev/null +++ b/apps/staff-frontend/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; + +import App from "./App"; + +createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/apps/staff-frontend/src/vite-env.d.ts b/apps/staff-frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/staff-frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/staff-frontend/tsconfig.json b/apps/staff-frontend/tsconfig.json new file mode 100644 index 000000000..1629bc4ed --- /dev/null +++ b/apps/staff-frontend/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/typescript-config/vite.json", + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/staff-frontend/tsconfig.node.json b/apps/staff-frontend/tsconfig.node.json new file mode 100644 index 000000000..3adda81a1 --- /dev/null +++ b/apps/staff-frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/staff-frontend/vite.config.ts b/apps/staff-frontend/vite.config.ts new file mode 100644 index 000000000..c4fbaeeef --- /dev/null +++ b/apps/staff-frontend/vite.config.ts @@ -0,0 +1,29 @@ +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + host: true, + port: 3002, + allowedHosts: ["staff-frontend"], + proxy: { + "/api/graphql": { + target: "http://localhost:8080", + changeOrigin: true, + secure: false, + }, + }, + }, + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + plugins: [react()], + css: { + modules: { + localsConvention: "camelCaseOnly", + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml index 2025314cd..a71cbb4b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,17 @@ services: volumes: - ./apps/ag-frontend:/ag-frontend/apps/ag-frontend - ./packages:/ag-frontend/packages + staff-frontend: + build: + context: . + dockerfile: ./apps/staff-frontend/Dockerfile + target: staff-frontend-dev + networks: + - bt + restart: always + volumes: + - ./apps/staff-frontend:/staff-frontend/apps/staff-frontend + - ./packages:/staff-frontend/packages mongodb: image: mongo:7.0.5 command: ["--replSet", "rs0", "--bind_ip_all", "--port", "27017"] @@ -64,12 +75,14 @@ services: - backend - frontend - ag-frontend + - staff-frontend image: nginx:1.21 networks: - bt ports: - 8080:8080 - 8081:8081 + - 8082:8082 restart: always volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf diff --git a/infra/staff/.helmignore b/infra/staff/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/infra/staff/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/infra/staff/Chart.yaml b/infra/staff/Chart.yaml new file mode 100644 index 000000000..e29f582d7 --- /dev/null +++ b/infra/staff/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: bt-staff +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.0.0-alpha" diff --git a/infra/staff/templates/_helpers.tpl b/infra/staff/templates/_helpers.tpl new file mode 100644 index 000000000..fff13f046 --- /dev/null +++ b/infra/staff/templates/_helpers.tpl @@ -0,0 +1,25 @@ +{{/* +Chart name and version +*/}} +{{- define "bt-staff.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Labels applied to all resources. +*/}} +{{- define "bt-staff.labels" -}} +helm.sh/chart: {{ include "bt-staff.chart" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/instance: {{ .Release.Name }} +env: {{ .Values.env }} +{{- end -}} + +{{- define "bt-staff.frontendLabels" -}} +app.kubernetes.io/name: frontend +{{ include "bt-staff.labels" . }} +{{- end -}} + +{{- define "bt-staff.frontendName" -}} +{{ .Release.Name }}-frontend +{{- end -}} diff --git a/infra/staff/templates/frontend.yaml b/infra/staff/templates/frontend.yaml new file mode 100644 index 000000000..539b2c60d --- /dev/null +++ b/infra/staff/templates/frontend.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bt-staff.frontendName" . }} + labels: + {{- include "bt-staff.frontendLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.frontend.replicas }} + selector: + matchLabels: + {{- include "bt-staff.frontendLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "bt-staff.frontendLabels" . | nindent 8 }} + spec: + containers: + - name: frontend + image: {{ printf "%s/%s:%s" .Values.frontend.image.registry .Values.frontend.image.repository ( toString .Values.frontend.image.tag ) }} + imagePullPolicy: Always + ports: + - containerPort: {{ .Values.frontend.port }} + +--- + +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bt-staff.frontendName" . }}-svc + labels: + {{- include "bt-staff.frontendLabels" . | nindent 4 }} +spec: + selector: + {{- include "bt-staff.frontendLabels" . | nindent 4 }} + ports: + - protocol: TCP + port: {{ .Values.port }} + targetPort: {{ .Values.frontend.port }} diff --git a/infra/staff/templates/ingress.yaml b/infra/staff/templates/ingress.yaml new file mode 100644 index 000000000..0048a034c --- /dev/null +++ b/infra/staff/templates/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-ingress + labels: + {{- include "bt-staff.labels" . | nindent 4 }} + annotations: + cert-manager.io/issuer: {{ .Values.issuer }} +spec: + ingressClassName: nginx + tls: + - hosts: + - {{ .Values.host }} + secretName: bt-staff-cert + rules: + - host: {{ .Values.host }} + http: + paths: + - path: {{ .Values.frontend.path }} + pathType: Prefix + backend: + service: + name: {{ include "bt-staff.frontendName" . }}-svc + port: + number: {{ .Values.port }} diff --git a/infra/staff/values.yaml b/infra/staff/values.yaml new file mode 100644 index 000000000..15eae9c6f --- /dev/null +++ b/infra/staff/values.yaml @@ -0,0 +1,17 @@ +env: prod + +host: staff.berkeleytime.com +port: 80 + +issuer: letsencrypt-prod + +frontend: + replicas: 1 + + port: 3002 + path: / + + image: + registry: docker.io + repository: octoberkeleytime/bt-staff-frontend + tag: prod diff --git a/nginx.conf b/nginx.conf index 91bef6e39..87c4cc603 100644 --- a/nginx.conf +++ b/nginx.conf @@ -35,3 +35,19 @@ server { proxy_set_header Connection "upgrade"; } } + +server { + listen 8082; + server_name localhost; + access_log off; + + # Frontend + location / { + proxy_pass http://staff-frontend:3002; + + # Ensure Nginx properly handles WebSocket requests + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/package-lock.json b/package-lock.json index 386219653..f67a1c204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -177,6 +177,24 @@ "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, + "apps/staff-frontend": { + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@repo/eslint-config": "*", + "@repo/typescript-config": "*", + "@types/node": "^24.7.0", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.37.0", + "serve": "^14.2.5", + "typescript": "^5.9.3", + "vite": "7.1.9" + } + }, "apps/storybook": { "name": "@repo/storybook", "dependencies": { @@ -15962,6 +15980,10 @@ "version": "0.0.2", "license": "MIT" }, + "node_modules/staff-frontend": { + "resolved": "apps/staff-frontend", + "link": true + }, "node_modules/statuses": { "version": "2.0.2", "license": "MIT", From c752d704e8fed2232eb9ea03d713b6c02dd95cd1 Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 14:03:07 -0800 Subject: [PATCH 02/52] session persistence across domains --- apps/backend/src/bootstrap/loaders/passport.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/bootstrap/loaders/passport.ts b/apps/backend/src/bootstrap/loaders/passport.ts index d93383a5d..19300857e 100644 --- a/apps/backend/src/bootstrap/loaders/passport.ts +++ b/apps/backend/src/bootstrap/loaders/passport.ts @@ -35,6 +35,7 @@ export default async (app: Application, redis: RedisClientType) => { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 365, // 1 year sameSite: "lax", + domain: config.isDev ? undefined : ".berkeleytime.com", }, store: new RedisStore({ client: redis, From 0e38ad03ed671a2a2558bdc7d06e3e6c1ab8ec5a Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 14:11:52 -0800 Subject: [PATCH 03/52] /about page --- apps/frontend/src/app/About/About.module.scss | 39 +++++++ .../About/MemberCard/MemberCard.module.scss | 103 ++++++++++++++++++ .../src/app/About/MemberCard/index.tsx | 40 +++++++ apps/frontend/src/app/About/index.tsx | 100 ++++++++++++++++- 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/app/About/About.module.scss create mode 100644 apps/frontend/src/app/About/MemberCard/MemberCard.module.scss create mode 100644 apps/frontend/src/app/About/MemberCard/index.tsx diff --git a/apps/frontend/src/app/About/About.module.scss b/apps/frontend/src/app/About/About.module.scss new file mode 100644 index 000000000..4911ee068 --- /dev/null +++ b/apps/frontend/src/app/About/About.module.scss @@ -0,0 +1,39 @@ +.root { + padding: 48px; + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 20px; + width: 100%; + box-sizing: border-box; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.spacer { + flex: 1; +} + +.selectWrapper { + width: 180px; +} + +.title { + font-size: 24px; + font-weight: var(--font-semibold); + color: var(--heading-color); + margin: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + width: 100%; +} diff --git a/apps/frontend/src/app/About/MemberCard/MemberCard.module.scss b/apps/frontend/src/app/About/MemberCard/MemberCard.module.scss new file mode 100644 index 000000000..9f7b12701 --- /dev/null +++ b/apps/frontend/src/app/About/MemberCard/MemberCard.module.scss @@ -0,0 +1,103 @@ +.card { + background-color: var(--zinc-50); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 100%; + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + cursor: pointer; + box-sizing: border-box; + + :global(body[data-theme="dark"]) &, + :global(body:not([data-theme])) & { + @media (prefers-color-scheme: dark) { + background-color: var(--button-hover-color); + } + } + + :global(body[data-theme="dark"]) & { + background-color: var(--button-hover-color); + } +} + +.imageContainer { + position: relative; + width: 100%; + aspect-ratio: 1; + background-color: var(--background-color); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + flex: 1 1 auto; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.placeholder { + width: 48px; + height: 48px; + color: var(--paragraph-color); + opacity: 0.5; +} + +.footer { + background-color: var(--foreground-color); + border-top: 1px solid var(--border-color); + padding: 20px; + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + overflow: hidden; +} + +.nameRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.linkButton { + flex-shrink: 0; + display: flex; + align-items: center; + color: var(--paragraph-color); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + color: var(--heading-color); + } +} + +.name { + font-size: var(--text-16); + font-weight: var(--font-semibold); + color: var(--heading-color); + letter-spacing: -0.16px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.role { + font-size: var(--text-14); + font-weight: var(--font-medium); + color: var(--paragraph-color); + letter-spacing: -0.14px; + margin: 0; +} diff --git a/apps/frontend/src/app/About/MemberCard/index.tsx b/apps/frontend/src/app/About/MemberCard/index.tsx new file mode 100644 index 000000000..2b0c903eb --- /dev/null +++ b/apps/frontend/src/app/About/MemberCard/index.tsx @@ -0,0 +1,40 @@ +import { Link, User } from "iconoir-react"; + +import styles from "./MemberCard.module.scss"; + +interface MemberCardProps { + name: string; + role: string; + imageUrl?: string; + link?: string; +} + +export function MemberCard({ name, role, imageUrl, link }: MemberCardProps) { + return ( +
+
+ {imageUrl ? ( + {name} + ) : ( + + )} +
+
+
+

{name}

+ {link && ( + + + + )} +
+

{role}

+
+
+ ); +} diff --git a/apps/frontend/src/app/About/index.tsx b/apps/frontend/src/app/About/index.tsx index 7321bd3e0..e74afaf44 100644 --- a/apps/frontend/src/app/About/index.tsx +++ b/apps/frontend/src/app/About/index.tsx @@ -1,5 +1,101 @@ -import { Container } from "@repo/theme"; +import { useState } from "react"; + +import { Search } from "iconoir-react"; + +import { IconButton, Select } from "@repo/theme"; + +import styles from "./About.module.scss"; +import { MemberCard } from "./MemberCard"; + +const SEMESTERS = [ + { value: "spring-2026", label: "Spring 2026" }, + { value: "fall-2025", label: "Fall 2025" }, + { value: "spring-2025", label: "Spring 2025" }, + { value: "fall-2024", label: "Fall 2024" }, + { value: "spring-2024", label: "Spring 2024" }, + { value: "fall-2023", label: "Fall 2023" }, + { value: "spring-2023", label: "Spring 2023" }, + { value: "fall-2022", label: "Fall 2022" }, + { value: "founding", label: "Founders" }, +]; + +const TEAM_MEMBERS = [ + { name: "Alex Chen", role: "President", link: "https://linkedin.com/in/" }, + { name: "Sarah Kim", role: "VP Engineering", link: "https://linkedin.com/in/" }, + { name: "Michael Park", role: "VP Design", link: "https://linkedin.com/in/" }, + { name: "Emily Zhang", role: "VP Product", link: "https://linkedin.com/in/" }, + { name: "David Lee", role: "Tech Lead", link: "https://linkedin.com/in/" }, + { name: "Jessica Wu", role: "Design Lead", link: "https://linkedin.com/in/" }, + { name: "Ryan Patel", role: "Backend Lead", link: "https://linkedin.com/in/" }, + { name: "Amanda Liu", role: "Frontend Lead", link: "https://linkedin.com/in/" }, + { name: "Kevin Nguyen", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Rachel Wang", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Brian Tran", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Michelle Huang", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Justin Cho", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Stephanie Lin", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Andrew Yang", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Nicole Cheng", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Christopher Ma", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Jennifer Sun", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Daniel Lim", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Ashley Tan", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Matthew Ho", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Victoria Guo", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Jonathan Xu", role: "Product Manager", link: "https://linkedin.com/in/" }, + { name: "Samantha Yee", role: "Product Manager", link: "https://linkedin.com/in/" }, + { name: "Tyler Chang", role: "Data Analyst", link: "https://linkedin.com/in/" }, + { name: "Olivia Zhao", role: "Data Analyst", link: "https://linkedin.com/in/" }, + { name: "Brandon Shi", role: "DevOps", link: "https://linkedin.com/in/" }, + { name: "Katherine Lau", role: "DevOps", link: "https://linkedin.com/in/" }, + { name: "Nathan Fong", role: "QA Engineer", link: "https://linkedin.com/in/" }, + { name: "Grace Chu", role: "QA Engineer", link: "https://linkedin.com/in/" }, + { name: "Eric Wong", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Megan Tsai", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Steven Hsu", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Christina Lai", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Patrick Kwan", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Diana Ye", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Jason Cheung", role: "Designer", link: "https://linkedin.com/in/" }, + { name: "Vanessa Lu", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Henry Tam", role: "Developer", link: "https://linkedin.com/in/" }, + { name: "Cynthia Ong", role: "Developer", link: "https://linkedin.com/in/" }, +]; export default function About() { - return About; + const [selectedSemester, setSelectedSemester] = useState( + "spring-2026" + ); + + return ( +
+
+

Our Team

+
+
+
+ + {selectedUser && ( +
+
{selectedUser.name}
+
{selectedUser.email}
+
ID: {selectedUser._id}
+
+ )}
); } - diff --git a/apps/staff-frontend/src/components/Layout/index.tsx b/apps/staff-frontend/src/components/Layout/index.tsx index 796900381..6a1f28acd 100644 --- a/apps/staff-frontend/src/components/Layout/index.tsx +++ b/apps/staff-frontend/src/components/Layout/index.tsx @@ -14,4 +14,3 @@ export default function Layout() {
); } - diff --git a/apps/staff-frontend/src/components/NavigationBar/NavigationBar.module.scss b/apps/staff-frontend/src/components/NavigationBar/NavigationBar.module.scss index 88acc520d..ed56a4ff6 100644 --- a/apps/staff-frontend/src/components/NavigationBar/NavigationBar.module.scss +++ b/apps/staff-frontend/src/components/NavigationBar/NavigationBar.module.scss @@ -3,10 +3,6 @@ z-index: 1003; } -.profileDropdown { - z-index: 1003; -} - .themeDropdownItem { font-size: 14px; } diff --git a/apps/staff-frontend/src/components/NavigationBar/index.tsx b/apps/staff-frontend/src/components/NavigationBar/index.tsx index a2939e5e1..0a28c7216 100644 --- a/apps/staff-frontend/src/components/NavigationBar/index.tsx +++ b/apps/staff-frontend/src/components/NavigationBar/index.tsx @@ -1,23 +1,7 @@ -import { - ArrowRight, - HalfMoon, - LogOut, - ProfileCircle, - SunLight, - User, -} from "iconoir-react"; +import { HalfMoon, SunLight } from "iconoir-react"; import { Link } from "react-router-dom"; -import { - Button, - DropdownMenu, - Flex, - IconButton, - useTheme, -} from "@repo/theme"; - -import { useUser } from "@/contexts/UserContext"; -import { signIn, signOut } from "@/lib/auth"; +import { DropdownMenu, Flex, IconButton, useTheme } from "@repo/theme"; import styles from "./NavigationBar.module.scss"; @@ -75,52 +59,15 @@ const ThemeDropdown = ({ }; export default function NavigationBar() { - const { user } = useUser(); const { theme, setTheme } = useTheme(); return ( - + Berkeleytime [Staff]
- {user ? ( - - - - - - - - Account - - - - signOut()}> - Sign Out - - - - ) : ( - - )} ); } - diff --git a/apps/staff-frontend/src/contexts/UserContext.tsx b/apps/staff-frontend/src/contexts/UserContext.tsx deleted file mode 100644 index 6776fe61f..000000000 --- a/apps/staff-frontend/src/contexts/UserContext.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, useContext, ReactNode } from "react"; - -import { useQuery, gql } from "@apollo/client"; - -export interface IUser { - _id: string; - email: string; - name: string | null; - student: boolean; -} - -export interface UserContextType { - user: IUser | undefined; - loading: boolean; - error?: Error; -} - -const UserContext = createContext(null); - -const READ_USER = gql` - query GetUser { - user { - _id - email - name - student - } - } -`; - -export function useUser() { - const userContext = useContext(UserContext); - - if (!userContext) - throw new Error("useUser must be used within a UserProvider"); - - return userContext; -} - -interface UserProviderProps { - children: ReactNode; -} - -export function UserProvider({ children }: UserProviderProps) { - const { data, loading, error } = useQuery(READ_USER, { - fetchPolicy: "cache-and-network", - }); - - return ( - - {children} - - ); -} - -export default UserContext; - diff --git a/apps/staff-frontend/src/lib/auth.ts b/apps/staff-frontend/src/lib/auth.ts deleted file mode 100644 index 3bbb98dbc..000000000 --- a/apps/staff-frontend/src/lib/auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const signIn = (redirectURI?: string) => { - redirectURI = - redirectURI ?? - window.location.origin + window.location.pathname + window.location.search; - - window.location.href = `${window.location.origin}/api/login?redirect_uri=${redirectURI}`; -}; - -export const signOut = async (redirectURI?: string) => { - redirectURI = - redirectURI ?? window.location.pathname + window.location.search; - - window.location.href = `${window.location.origin}/api/logout?redirect_uri=${redirectURI}`; -}; - diff --git a/apps/staff-frontend/vite.config.ts b/apps/staff-frontend/vite.config.ts index c4fbaeeef..5ff269f51 100644 --- a/apps/staff-frontend/vite.config.ts +++ b/apps/staff-frontend/vite.config.ts @@ -8,8 +8,8 @@ export default defineConfig({ port: 3002, allowedHosts: ["staff-frontend"], proxy: { - "/api/graphql": { - target: "http://localhost:8080", + "/api": { + target: "http://backend:5001", changeOrigin: true, secure: false, }, diff --git a/packages/gql-typedefs/staff.ts b/packages/gql-typedefs/staff.ts index 5143b78d6..efc9073fa 100644 --- a/packages/gql-typedefs/staff.ts +++ b/packages/gql-typedefs/staff.ts @@ -26,6 +26,15 @@ export const staffTypeDef = gql` isAlumni: Boolean! } + """ + A user account for search results. + """ + type UserSearchResult { + _id: ID! + name: String! + email: String! + } + type Query { """ Get all staff members for a specific semester. @@ -36,5 +45,10 @@ export const staffTypeDef = gql` Get a staff member by ID. """ staffMember(id: ID!): StaffMember + + """ + Get all users (staff only). + """ + allUsers: [UserSearchResult!]! } `; diff --git a/packages/theme/src/components/Accordion/index.tsx b/packages/theme/src/components/Accordion/index.tsx index 73b0eac73..d28953ae2 100644 --- a/packages/theme/src/components/Accordion/index.tsx +++ b/packages/theme/src/components/Accordion/index.tsx @@ -57,7 +57,9 @@ function AccordionContent({ }: React.ComponentProps) { return ( -
{children}
+
+ {children} +
); } From c8fdfa7fe897c18a3db9981be46f078879a9e459 Mon Sep 17 00:00:00 2001 From: ARtheboss <30683624+ARtheboss@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:12:45 -0800 Subject: [PATCH 07/52] mongoose setup --- apps/backend/src/bootstrap/index.ts | 2 +- apps/backend/src/bootstrap/loaders/express.ts | 2 +- apps/backend/src/bootstrap/loaders/index.ts | 2 +- .../backend/src/bootstrap/loaders/mongoose.ts | 2 +- .../backend/src/bootstrap/loaders/passport.ts | 2 +- apps/backend/src/bootstrap/loaders/redis.ts | 2 +- apps/backend/src/main.ts | 2 +- apps/staff-frontend/package.json | 2 + apps/staff-frontend/src/App.tsx | 27 ++++++++++++ apps/staff-frontend/src/hooks/api/index.ts | 1 + .../src/hooks/api/users/index.ts | 1 + .../src/hooks/api/users/useReadUser.ts | 12 ++++++ apps/staff-frontend/src/lib/api/users.ts | 42 +++++++++++++++++++ apps/staff-frontend/src/mongoose.ts | 14 +++++++ packages/common/src/index.ts | 1 + .../common/src/utils}/config.ts | 0 16 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 apps/staff-frontend/src/hooks/api/index.ts create mode 100644 apps/staff-frontend/src/hooks/api/users/index.ts create mode 100644 apps/staff-frontend/src/hooks/api/users/useReadUser.ts create mode 100644 apps/staff-frontend/src/lib/api/users.ts create mode 100644 apps/staff-frontend/src/mongoose.ts rename {apps/backend/src => packages/common/src/utils}/config.ts (100%) diff --git a/apps/backend/src/bootstrap/index.ts b/apps/backend/src/bootstrap/index.ts index 175366e0b..472911c72 100644 --- a/apps/backend/src/bootstrap/index.ts +++ b/apps/backend/src/bootstrap/index.ts @@ -1,7 +1,7 @@ import express, { json } from "express"; import http from "http"; -import { Config } from "../config"; +import { Config } from "../../../../packages/common/src/utils/config"; import cacheRoutes from "../modules/cache/routes"; import loaders, { loadCacheWarmingDependencies } from "./loaders"; diff --git a/apps/backend/src/bootstrap/loaders/express.ts b/apps/backend/src/bootstrap/loaders/express.ts index 94bd49cb2..f65fde37f 100644 --- a/apps/backend/src/bootstrap/loaders/express.ts +++ b/apps/backend/src/bootstrap/loaders/express.ts @@ -6,7 +6,7 @@ import { type Application, json } from "express"; import helmet from "helmet"; import { RedisClientType } from "redis"; -import { config } from "../../config"; +import { config } from "../../../../../packages/common/src/utils/config"; import passportLoader from "./passport"; export default async ( diff --git a/apps/backend/src/bootstrap/loaders/index.ts b/apps/backend/src/bootstrap/loaders/index.ts index dcfa29d59..d2a331175 100644 --- a/apps/backend/src/bootstrap/loaders/index.ts +++ b/apps/backend/src/bootstrap/loaders/index.ts @@ -1,6 +1,6 @@ import { type Application, Router } from "express"; -import { config } from "../../config"; +import { config } from "../../../../../packages/common/src/utils/config"; // loaders import apolloLoader from "./apollo"; import expressLoader from "./express"; diff --git a/apps/backend/src/bootstrap/loaders/mongoose.ts b/apps/backend/src/bootstrap/loaders/mongoose.ts index 8cdce7f31..7ec8a675b 100644 --- a/apps/backend/src/bootstrap/loaders/mongoose.ts +++ b/apps/backend/src/bootstrap/loaders/mongoose.ts @@ -1,6 +1,6 @@ import mongoose from "mongoose"; -import { config } from "../../config"; +import { config } from "@repo/common"; // Close the Mongoose default connection is the event of application termination process.on("SIGINT", async () => { diff --git a/apps/backend/src/bootstrap/loaders/passport.ts b/apps/backend/src/bootstrap/loaders/passport.ts index 19300857e..9737b03f0 100644 --- a/apps/backend/src/bootstrap/loaders/passport.ts +++ b/apps/backend/src/bootstrap/loaders/passport.ts @@ -7,7 +7,7 @@ import type { RedisClientType } from "redis"; import { UserModel } from "@repo/common"; -import { config } from "../../config"; +import { config } from "../../../../../packages/common/src/utils/config"; const LOGIN_ROUTE = "/login"; const LOGIN_REDIRECT_ROUTE = "/login/redirect"; diff --git a/apps/backend/src/bootstrap/loaders/redis.ts b/apps/backend/src/bootstrap/loaders/redis.ts index 7dfa9afd5..d8e8fb522 100644 --- a/apps/backend/src/bootstrap/loaders/redis.ts +++ b/apps/backend/src/bootstrap/loaders/redis.ts @@ -1,6 +1,6 @@ import { RedisClientType, createClient } from "redis"; -import { config } from "../../config"; +import { config } from "../../../../../packages/common/src/utils/config"; export default async (): Promise => { const client = createClient({ diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 05b4ae747..981707291 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,5 +1,5 @@ import bootstrap, { bootstrapCacheWarmingServer } from "./bootstrap"; -import { config } from "./config"; +import { config } from "../../../packages/common/src/utils/config"; // Start main backend server bootstrap(config); diff --git a/apps/staff-frontend/package.json b/apps/staff-frontend/package.json index 3cde4ada0..f9d682a40 100644 --- a/apps/staff-frontend/package.json +++ b/apps/staff-frontend/package.json @@ -9,6 +9,8 @@ "start": "serve -s dist -p 3002" }, "dependencies": { + "@apollo/client": "4.0.7", + "graphql": "^16.11.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx index 94007b152..8a8a55ed6 100644 --- a/apps/staff-frontend/src/App.tsx +++ b/apps/staff-frontend/src/App.tsx @@ -1,4 +1,31 @@ +import { useReadUser } from "./hooks/api/users/useReadUser"; + +const BASE = import.meta.env.DEV + ? "http://localhost:3000" + : "https://beta.berkeleytime.com"; + + +export const signIn = (redirectURI?: string) => { + redirectURI = + redirectURI ?? + window.location.origin + window.location.pathname + window.location.search; + + window.location.href = `${BASE}/api/login?redirect_uri=${redirectURI}`; +}; + export default function App() { + + const { data: user, loading: userLoading } = useReadUser(); + + if (userLoading || !user) { + signIn(); + return ( +
+

Loading...

+
+ ); + } + return (
) => { + const query = useQuery(READ_USER, options); + + return { + ...query, + data: query.data?.user, + }; +}; diff --git a/apps/staff-frontend/src/lib/api/users.ts b/apps/staff-frontend/src/lib/api/users.ts new file mode 100644 index 000000000..671436b83 --- /dev/null +++ b/apps/staff-frontend/src/lib/api/users.ts @@ -0,0 +1,42 @@ +import { gql } from "@apollo/client"; + +export interface IUser { + _id: string; + name: string; + staff: boolean; + email: string; +} + +export interface ReadUserResponse { + user: IUser; +} + +export const BASE = import.meta.env.DEV + ? "http://localhost:8080" + : "https://beta.berkeleytime.com"; + +export const READ_USER = gql` + query GetUser { + user { + _id + email + name + staff + } + } +`; + +export const signIn = (redirectURI?: string) => { + redirectURI = + redirectURI ?? + window.location.origin + window.location.pathname + window.location.search; + + window.location.href = `${BASE}/api/login?redirect_uri=${redirectURI}`; +}; + +export const signOut = async (redirectURI?: string) => { + redirectURI = + redirectURI ?? window.location.pathname + window.location.search; + + window.location.href = `${BASE}/api/logout?redirect_uri=${redirectURI}`; +}; diff --git a/apps/staff-frontend/src/mongoose.ts b/apps/staff-frontend/src/mongoose.ts new file mode 100644 index 000000000..7ec8a675b --- /dev/null +++ b/apps/staff-frontend/src/mongoose.ts @@ -0,0 +1,14 @@ +import mongoose from "mongoose"; + +import { config } from "@repo/common"; + +// Close the Mongoose default connection is the event of application termination +process.on("SIGINT", async () => { + await mongoose.connection.close(); + process.exit(0); +}); + +// Your Mongoose setup goes here +export default async (): Promise => { + return mongoose.connect(config.mongoDB.uri); +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a5bf42bf2..10eee8df9 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,2 +1,3 @@ export * from "./models"; export * from "./utils/grade-distribution"; +export * from "./utils/config"; \ No newline at end of file diff --git a/apps/backend/src/config.ts b/packages/common/src/utils/config.ts similarity index 100% rename from apps/backend/src/config.ts rename to packages/common/src/utils/config.ts From 7c6b07bda9462ddd6bff28ce96a4e566c786abf3 Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 16:38:20 -0800 Subject: [PATCH 08/52] appearance --- .../src/app/Dashboard/Dashboard.module.scss | 69 ++++++++++++++++--- .../src/app/Dashboard/index.tsx | 38 ++++++++-- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss index 86df237ac..08ce70899 100644 --- a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss +++ b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss @@ -21,27 +21,80 @@ .selectedUser { margin-top: 24px; - padding: 16px; - border-radius: 8px; + padding: 20px 24px; + border-radius: 12px; background-color: var(--foreground-color); border: 1px solid var(--border-color); } .selectedUserName { - font-size: 16px; - font-weight: 500; + font-size: 18px; + font-weight: 600; color: var(--heading-color); } .selectedUserEmail { + display: flex; + align-items: center; + gap: 12px; font-size: 14px; color: var(--label-color); - margin-top: 4px; + margin-top: 6px; } -.selectedUserId { +.unaffiliatedWarning { + display: inline-flex; + align-items: center; + gap: 4px; + color: #e67700; font-size: 12px; + font-weight: 500; +} + +.staffStatus { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +.staffBadge, +.notStaffBadge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; +} + +.staffBadge { + color: #2e7d32; +} + +.notStaffBadge { + color: #c62828; +} + +.addStaffRow { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +.addStaffButton { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 12px; + border: 1px dashed var(--border-color); + border-radius: 8px; + background: none; color: var(--label-color); - margin-top: 8px; - font-family: monospace; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + + &:hover { + border-color: var(--label-color); + background-color: var(--border-color); + } } diff --git a/apps/staff-frontend/src/app/Dashboard/index.tsx b/apps/staff-frontend/src/app/Dashboard/index.tsx index 81a348ff2..71565bbfb 100644 --- a/apps/staff-frontend/src/app/Dashboard/index.tsx +++ b/apps/staff-frontend/src/app/Dashboard/index.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { gql, useQuery } from "@apollo/client"; +import { Plus, UserBadgeCheck, WarningTriangleSolid } from "iconoir-react"; import { OptionItem, Select } from "@repo/theme"; @@ -76,13 +77,36 @@ export default function Dashboard() { />
- {selectedUser && ( -
-
{selectedUser.name}
-
{selectedUser.email}
-
ID: {selectedUser._id}
-
- )} + {selectedUser && (() => { + const isStaff = false; // TODO: Replace with actual logic + return ( +
+
{selectedUser.name}
+
+ {selectedUser.email} + {!selectedUser.email.endsWith("@berkeley.edu") && ( + + + Unaffiliated email + + )} +
+
+ + + {isStaff ? "Staff member" : "Not a staff yet"} + +
+ {!isStaff && ( +
+ +
+ )} +
+ ); + })()}
); } From 8329e0e2f2cbd77bafde771ea0394f52299ae03d Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 17:03:49 -0800 Subject: [PATCH 09/52] dashboard styling --- .../src/app/Dashboard/Dashboard.module.scss | 129 ++++++++++++++++-- .../src/app/Dashboard/index.tsx | 125 +++++++++++++++-- .../ThemeProvider/ThemeProvider.scss | 2 +- 3 files changed, 239 insertions(+), 17 deletions(-) diff --git a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss index 08ce70899..f0247b01c 100644 --- a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss +++ b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss @@ -46,12 +46,15 @@ display: inline-flex; align-items: center; gap: 4px; - color: #e67700; + color: var(--orange-500); font-size: 12px; font-weight: 500; } .staffStatus { + display: flex; + align-items: center; + gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color); @@ -67,34 +70,144 @@ } .staffBadge { - color: #2e7d32; + color: var(--green-500); } .notStaffBadge { - color: #c62828; + color: var(--red-500); +} + +.personalLink { + margin-top: 8px; + + a { + font-size: 13px; + color: var(--blue-500); + + &:hover { + text-decoration: underline; + } + } } -.addStaffRow { +.semesterRoles { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color); } -.addStaffButton { +.semesterRolesHeader { + font-size: 12px; + font-weight: 600; + color: var(--label-color); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.semesterRole { + display: flex; + align-items: center; + gap: 12px; + + & + & { + margin-top: 12px; + } +} + +.semesterRolePhoto { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.semesterRolePhotoPlaceholder { + width: 48px; + height: 48px; + border-radius: 8px; + background-color: var(--button-hover-color); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--label-color); +} + +.semesterRoleInfo { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 1; +} + +.semesterRoleTerm { + font-size: 13px; + font-weight: 500; + color: var(--heading-color); +} + +.semesterRoleTitle { + font-size: 13px; + color: var(--paragraph-color); +} + +.semesterRoleTeam { + font-size: 12px; + color: var(--label-color); + padding: 2px 8px; + background-color: var(--border-color); + border-radius: 4px; +} + +.editButton { display: flex; align-items: center; justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: transparent; + color: var(--paragraph-color); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--button-hover-color); + } +} + +.alumniBadge { + font-size: 12px; + font-weight: 500; + color: var(--purple-500); + padding: 2px 8px; + background-color: var(--purple-500-20); + border-radius: 4px; +} + +.addButton { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; width: 100%; - padding: 12px; + height: 48px; + margin-top: 12px; border: 1px dashed var(--border-color); border-radius: 8px; - background: none; + background: transparent; + font-size: 14px; color: var(--label-color); cursor: pointer; transition: border-color 0.15s ease, background-color 0.15s ease; &:hover { border-color: var(--label-color); - background-color: var(--border-color); + background-color: var(--button-hover-color); } } diff --git a/apps/staff-frontend/src/app/Dashboard/index.tsx b/apps/staff-frontend/src/app/Dashboard/index.tsx index 71565bbfb..7f8c00c33 100644 --- a/apps/staff-frontend/src/app/Dashboard/index.tsx +++ b/apps/staff-frontend/src/app/Dashboard/index.tsx @@ -1,7 +1,7 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { gql, useQuery } from "@apollo/client"; -import { Plus, UserBadgeCheck, WarningTriangleSolid } from "iconoir-react"; +import { EditPencil, Plus, User, UserBadgeCheck, WarningTriangleSolid } from "iconoir-react"; import { OptionItem, Select } from "@repo/theme"; @@ -13,6 +13,56 @@ interface UserSearchResult { email: string; } +interface SemesterRole { + _id: string; + year: number; + semester: "Spring" | "Summer" | "Fall" | "Winter"; + role: string; + team?: string; + photo?: string; +} + +interface StaffMember { + _id: string; + name: string; + personalLink?: string; + isAlumni: boolean; + semesterRoles: SemesterRole[]; +} + +// Dummy data for testing UI +const createDummyStaffMember = (photos: string[]): StaffMember => ({ + _id: "dummy-staff-id", + name: "John Doe", + personalLink: "https://johndoe.com", + isAlumni: true, + semesterRoles: [ + { + _id: "role-1", + year: 2024, + semester: "Fall", + role: "Engineering Lead", + team: "Backend", + photo: photos[0], + }, + { + _id: "role-2", + year: 2024, + semester: "Spring", + role: "Software Engineer", + team: "Backend", + photo: photos[1], + }, + { + _id: "role-3", + year: 2023, + semester: "Fall", + role: "Software Engineer", + team: "Frontend", + }, + ], +}); + const ALL_USERS = gql` query AllUsers { allUsers { @@ -28,11 +78,27 @@ export default function Dashboard() { null ); const [searchQuery, setSearchQuery] = useState(""); + const [dogPhotos, setDogPhotos] = useState([]); const { data, loading } = useQuery<{ allUsers: UserSearchResult[] }>( ALL_USERS ); + // Fetch random dog photos for dummy data + useEffect(() => { + const fetchDogPhotos = async () => { + const photos = await Promise.all( + [1, 2].map(async () => { + const res = await fetch("https://dog.ceo/api/breeds/image/random"); + const data = await res.json(); + return data.message; + }) + ); + setDogPhotos(photos); + }; + fetchDogPhotos(); + }, []); + const options = useMemo[]>(() => { if (!data?.allUsers) return []; @@ -78,7 +144,10 @@ export default function Dashboard() {
{selectedUser && (() => { - const isStaff = false; // TODO: Replace with actual logic + // Use dummy data for now + const staffMember: StaffMember | null = createDummyStaffMember(dogPhotos); + const isStaff = staffMember !== null; + return (
{selectedUser.name}
@@ -91,19 +160,59 @@ export default function Dashboard() { )}
+
{isStaff ? "Staff member" : "Not a staff yet"} + {isStaff && staffMember.isAlumni && ( + Alumni + )}
- {!isStaff && ( -
- + + {isStaff && staffMember?.personalLink && ( + )} + +
+
Experience
+ {isStaff && staffMember?.semesterRoles.map((role) => ( +
+ {role.photo ? ( + {`${selectedUser.name} + ) : ( +
+ +
+ )} +
+ + {role.semester} {role.year} + + {role.role} + {role.team && ( + {role.team} + )} +
+ +
+ ))} + +
); })()} diff --git a/packages/theme/src/components/ThemeProvider/ThemeProvider.scss b/packages/theme/src/components/ThemeProvider/ThemeProvider.scss index 1b9697965..b7e13c66c 100644 --- a/packages/theme/src/components/ThemeProvider/ThemeProvider.scss +++ b/packages/theme/src/components/ThemeProvider/ThemeProvider.scss @@ -314,7 +314,7 @@ --light-border-color: rgb(0 0 0 / 10%); - --light-heading-color: var(--neutral-w900); + --light-heading-color: var(--neutral-900); --light-paragraph-color: var(--neutral-500); --light-paragraph-color-50: var(--neutral-500-50); --light-description-color: var(--neutral-700); From 6995f167281427ffaad695561b64a22bb147c51b Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 17:17:09 -0800 Subject: [PATCH 10/52] fix sign in --- apps/staff-frontend/src/App.tsx | 72 +++++--- .../src/app/Dashboard/index.tsx | 155 ++++++++++-------- apps/staff-frontend/src/lib/api/users.ts | 8 +- apps/staff-frontend/src/main.tsx | 16 +- apps/staff-frontend/src/mongoose.ts | 14 -- 5 files changed, 152 insertions(+), 113 deletions(-) delete mode 100644 apps/staff-frontend/src/mongoose.ts diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx index b258681d7..ffa2521e1 100644 --- a/apps/staff-frontend/src/App.tsx +++ b/apps/staff-frontend/src/App.tsx @@ -1,5 +1,3 @@ -import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; -import { ApolloProvider } from "@apollo/client/react"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { ThemeProvider } from "@repo/theme"; @@ -9,16 +7,12 @@ import Layout from "@/components/Layout"; import Dashboard from "./app/Dashboard"; import { useReadUser } from "./hooks/api/users/useReadUser"; -const BASE = import.meta.env.DEV - ? "http://localhost:3000" - : "https://beta.berkeleytime.com"; - export const signIn = (redirectURI?: string) => { redirectURI = redirectURI ?? window.location.origin + window.location.pathname + window.location.search; - window.location.href = `${BASE}/api/login?redirect_uri=${redirectURI}`; + window.location.href = `${window.location.origin}/api/login?redirect_uri=${redirectURI}`; }; const router = createBrowserRouter([ @@ -33,31 +27,61 @@ const router = createBrowserRouter([ }, ]); -const client = new ApolloClient({ - link: new HttpLink({ - uri: "/api/graphql", - credentials: "include", - }), - cache: new InMemoryCache(), -}); - export default function App() { const { data: user, loading: userLoading } = useReadUser(); - if (userLoading || !user) { - signIn(); + if (userLoading) { return ( -
-

Loading...

-
+ +
+ Loading... +
+
); } - return ( - + if (!user) { + return ( - +
+

Staff Dashboard

+ +
-
+ ); + } + + return ( + + + ); } diff --git a/apps/staff-frontend/src/app/Dashboard/index.tsx b/apps/staff-frontend/src/app/Dashboard/index.tsx index 7f8c00c33..448973a0a 100644 --- a/apps/staff-frontend/src/app/Dashboard/index.tsx +++ b/apps/staff-frontend/src/app/Dashboard/index.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { gql, useQuery } from "@apollo/client"; -import { EditPencil, Plus, User, UserBadgeCheck, WarningTriangleSolid } from "iconoir-react"; +import { + EditPencil, + Plus, + User, + UserBadgeCheck, + WarningTriangleSolid, +} from "iconoir-react"; import { OptionItem, Select } from "@repo/theme"; @@ -143,79 +149,92 @@ export default function Dashboard() { /> - {selectedUser && (() => { - // Use dummy data for now - const staffMember: StaffMember | null = createDummyStaffMember(dogPhotos); - const isStaff = staffMember !== null; - - return ( -
-
{selectedUser.name}
-
- {selectedUser.email} - {!selectedUser.email.endsWith("@berkeley.edu") && ( - - - Unaffiliated email + {selectedUser && + (() => { + // Use dummy data for now + const staffMember: StaffMember | null = + createDummyStaffMember(dogPhotos); + const isStaff = staffMember !== null; + + return ( +
+
{selectedUser.name}
+
+ {selectedUser.email} + {!selectedUser.email.endsWith("@berkeley.edu") && ( + + + Unaffiliated email + + )} +
+ +
+ + + {isStaff ? "Staff member" : "Not a staff yet"} - )} -
+ {isStaff && staffMember.isAlumni && ( + Alumni + )} +
-
- - - {isStaff ? "Staff member" : "Not a staff yet"} - - {isStaff && staffMember.isAlumni && ( - Alumni + {isStaff && staffMember?.personalLink && ( + )} -
- {isStaff && staffMember?.personalLink && ( - - )} - -
-
Experience
- {isStaff && staffMember?.semesterRoles.map((role) => ( -
- {role.photo ? ( - {`${selectedUser.name} - ) : ( -
- +
+
Experience
+ {isStaff && + staffMember?.semesterRoles.map((role) => ( +
+ {role.photo ? ( + {`${selectedUser.name} + ) : ( +
+ +
+ )} +
+ + {role.semester} {role.year} + + + {role.role} + + {role.team && ( + + {role.team} + + )} +
+
- )} -
- - {role.semester} {role.year} - - {role.role} - {role.team && ( - {role.team} - )} -
- -
- ))} - + ))} + +
-
- ); - })()} + ); + })()}
); } diff --git a/apps/staff-frontend/src/lib/api/users.ts b/apps/staff-frontend/src/lib/api/users.ts index 671436b83..63e6db4e2 100644 --- a/apps/staff-frontend/src/lib/api/users.ts +++ b/apps/staff-frontend/src/lib/api/users.ts @@ -11,10 +11,6 @@ export interface ReadUserResponse { user: IUser; } -export const BASE = import.meta.env.DEV - ? "http://localhost:8080" - : "https://beta.berkeleytime.com"; - export const READ_USER = gql` query GetUser { user { @@ -31,12 +27,12 @@ export const signIn = (redirectURI?: string) => { redirectURI ?? window.location.origin + window.location.pathname + window.location.search; - window.location.href = `${BASE}/api/login?redirect_uri=${redirectURI}`; + window.location.href = `${window.location.origin}/api/login?redirect_uri=${redirectURI}`; }; export const signOut = async (redirectURI?: string) => { redirectURI = redirectURI ?? window.location.pathname + window.location.search; - window.location.href = `${BASE}/api/logout?redirect_uri=${redirectURI}`; + window.location.href = `${window.location.origin}/api/logout?redirect_uri=${redirectURI}`; }; diff --git a/apps/staff-frontend/src/main.tsx b/apps/staff-frontend/src/main.tsx index 51358803e..88c84aedb 100644 --- a/apps/staff-frontend/src/main.tsx +++ b/apps/staff-frontend/src/main.tsx @@ -1,6 +1,20 @@ +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { ApolloProvider } from "@apollo/client/react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./main.scss"; -createRoot(document.getElementById("root") as HTMLElement).render(); +const client = new ApolloClient({ + link: new HttpLink({ + uri: "/api/graphql", + credentials: "include", + }), + cache: new InMemoryCache(), +}); + +createRoot(document.getElementById("root") as HTMLElement).render( + + + +); diff --git a/apps/staff-frontend/src/mongoose.ts b/apps/staff-frontend/src/mongoose.ts deleted file mode 100644 index 7ec8a675b..000000000 --- a/apps/staff-frontend/src/mongoose.ts +++ /dev/null @@ -1,14 +0,0 @@ -import mongoose from "mongoose"; - -import { config } from "@repo/common"; - -// Close the Mongoose default connection is the event of application termination -process.on("SIGINT", async () => { - await mongoose.connection.close(); - process.exit(0); -}); - -// Your Mongoose setup goes here -export default async (): Promise => { - return mongoose.connect(config.mongoDB.uri); -}; From d70e11b863f4931931e5882ef4dc62f907e8ba82 Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 17:39:16 -0800 Subject: [PATCH 11/52] styling --- .../src/app/Dashboard/Dashboard.module.scss | 241 ++++- .../src/app/Dashboard/StaffCard.tsx | 144 +++ .../src/app/Dashboard/index.tsx | 885 +++++++++++++++--- 3 files changed, 1149 insertions(+), 121 deletions(-) create mode 100644 apps/staff-frontend/src/app/Dashboard/StaffCard.tsx diff --git a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss index f0247b01c..d49ae93b1 100644 --- a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss +++ b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss @@ -1,10 +1,8 @@ .root { display: flex; flex-direction: column; - padding: 32px 24px; + padding: 32px 48px; flex: 1; - max-width: 600px; - margin: 0 auto; width: 100%; } @@ -15,10 +13,76 @@ margin-bottom: 16px; } +.tabsContainer { + margin-bottom: 20px; +} + +.emptyState { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 24px; + color: var(--label-color); + font-size: 14px; +} + .searchContainer { width: 100%; } +.userSearchWrapper { + max-width: 480px; +} + +.staffSearchRow { + display: flex; + align-items: center; + gap: 12px; + max-width: 480px; +} + +.searchBySelect { + width: 120px; + flex-shrink: 0; +} + +.searchGroup { + flex: 1; + border: 1px solid var(--border-color); + border-radius: 4px; + height: 48px; + background-color: var(--foreground-color); + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + + &:has(.searchInput:focus) { + outline: 4px solid color-mix(in srgb, var(--blue-500) 25%, transparent); + border-color: var(--blue-500); + } +} + +.searchIcon { + color: var(--label-color); + display: grid; + place-items: center; +} + +.searchInput { + flex: 1; + color: var(--heading-color); + font-size: 14px; + height: 100%; + border: none; + background: transparent; + outline: none; + + &::placeholder { + color: var(--paragraph-color); + } +} + .selectedUser { margin-top: 24px; padding: 20px 24px; @@ -27,6 +91,46 @@ border: 1px solid var(--border-color); } +.staffCardHeader { + margin-bottom: 16px; +} + +.staffCardName { + font-size: 18px; + font-weight: 600; + color: var(--heading-color); +} + +.staffCardEmail { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: var(--label-color); + margin-top: 4px; +} + +.modalSubtitle { + display: flex; + align-items: center; + gap: 12px; +} + +.staffList { + columns: 400px; + column-gap: 20px; + margin-top: 20px; +} + +.staffCard { + padding: 20px 24px; + border-radius: 12px; + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + break-inside: avoid; + margin-bottom: 16px; +} + .selectedUserName { font-size: 18px; font-weight: 600; @@ -54,12 +158,26 @@ .staffStatus { display: flex; align-items: center; + justify-content: space-between; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color); } +.staffStatusInfo { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.staffStatusBadges { + display: flex; + align-items: center; + gap: 8px; +} + .staffBadge, .notStaffBadge { display: inline-flex; @@ -78,18 +196,20 @@ } .personalLink { - margin-top: 8px; - - a { - font-size: 13px; - color: var(--blue-500); + font-size: 13px; + color: var(--blue-500); - &:hover { - text-decoration: underline; - } + &:hover { + text-decoration: underline; } } +.noLink { + font-size: 13px; + color: var(--label-color); + font-style: italic; +} + .semesterRoles { margin-top: 16px; padding-top: 16px; @@ -105,6 +225,7 @@ margin-bottom: 12px; } + .semesterRole { display: flex; align-items: center; @@ -136,11 +257,16 @@ } .semesterRoleInfo { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.semesterRoleMain { display: flex; align-items: center; gap: 8px; - flex-wrap: wrap; - flex: 1; } .semesterRoleTerm { @@ -160,6 +286,7 @@ padding: 2px 8px; background-color: var(--border-color); border-radius: 4px; + align-self: flex-start; } .editButton { @@ -211,3 +338,91 @@ background-color: var(--button-hover-color); } } + +.formGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + width: 100%; +} + +.formField { + display: flex; + flex-direction: column; + gap: 6px; +} + +.formFieldFull { + display: flex; + flex-direction: column; + gap: 6px; + grid-column: 1 / -1; +} + +.formLabel { + font-size: 13px; + font-weight: 500; + color: var(--heading-color); +} + +.formHint { + font-size: 12px; + color: var(--label-color); + margin-top: 6px; +} + +.checkboxField { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + color: var(--paragraph-color); +} + +.photoUpload { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + width: 180px; + height: 180px; + margin: 0 auto; + border: 2px dashed var(--border-color); + border-radius: 12px; + cursor: pointer; + overflow: hidden; + transition: border-color 0.15s ease, background-color 0.15s ease; + + &:hover { + border-color: var(--label-color); + background-color: var(--button-hover-color); + } + + &.hasPhoto { + border: none; + } +} + +.photoInput { + display: none; +} + +.photoPlaceholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--label-color); + + span { + font-size: 14px; + font-weight: 500; + } +} + +.photoPreview { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx b/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx new file mode 100644 index 000000000..329948948 --- /dev/null +++ b/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx @@ -0,0 +1,144 @@ +import { + EditPencil, + User, + UserBadgeCheck, + WarningTriangleSolid, +} from "iconoir-react"; + +import styles from "./Dashboard.module.scss"; + +export interface SemesterRole { + _id: string; + year: number; + semester: "Spring" | "Summer" | "Fall" | "Winter"; + role: string; + team?: string; + photo?: string; +} + +export interface StaffMember { + _id: string; + name: string; + email?: string; + personalLink?: string; + isAlumni: boolean; + semesterRoles: SemesterRole[]; +} + +interface StaffCardProps { + staffMember: StaffMember; + onEditStaffInfo?: () => void; + onEditRole?: (role: SemesterRole) => void; + onAddRole?: () => void; +} + +export default function StaffCard({ + staffMember, + onEditStaffInfo, + onEditRole, + onAddRole, +}: StaffCardProps) { + return ( +
+
+
{staffMember.name}
+ {staffMember.email && ( +
+ {staffMember.email} + {!staffMember.email.endsWith("@berkeley.edu") && ( + + + Unaffiliated email + + )} +
+ )} +
+ +
+
+
+ + + Staff member + + {staffMember.isAlumni && ( + Alumni + )} +
+ {staffMember.personalLink ? ( + + {staffMember.personalLink} + + ) : ( + No personal link provided + )} +
+ {onEditStaffInfo && ( + + )} +
+ +
+
+ Experience ({staffMember.semesterRoles.length}) +
+ {staffMember.semesterRoles.map((role) => ( +
+ {role.photo ? ( + {`${staffMember.name} + ) : ( +
+ +
+ )} +
+
+ + {role.semester} {role.year} + + {role.role} +
+ {role.team && ( + {role.team} + )} +
+ {onEditRole && ( + + )} +
+ ))} + {onAddRole && ( + + )} +
+
+ ); +} diff --git a/apps/staff-frontend/src/app/Dashboard/index.tsx b/apps/staff-frontend/src/app/Dashboard/index.tsx index 448973a0a..f757f8b4f 100644 --- a/apps/staff-frontend/src/app/Dashboard/index.tsx +++ b/apps/staff-frontend/src/app/Dashboard/index.tsx @@ -3,15 +3,26 @@ import { useEffect, useMemo, useState } from "react"; import { gql, useQuery } from "@apollo/client"; import { EditPencil, + MediaImagePlus, Plus, + Search, User, UserBadgeCheck, WarningTriangleSolid, } from "iconoir-react"; -import { OptionItem, Select } from "@repo/theme"; +import { + Button, + Checkbox, + Dialog, + Input, + OptionItem, + PillSwitcher, + Select, +} from "@repo/theme"; import styles from "./Dashboard.module.scss"; +import StaffCard, { SemesterRole, StaffMember } from "./StaffCard"; interface UserSearchResult { _id: string; @@ -19,23 +30,6 @@ interface UserSearchResult { email: string; } -interface SemesterRole { - _id: string; - year: number; - semester: "Spring" | "Summer" | "Fall" | "Winter"; - role: string; - team?: string; - photo?: string; -} - -interface StaffMember { - _id: string; - name: string; - personalLink?: string; - isAlumni: boolean; - semesterRoles: SemesterRole[]; -} - // Dummy data for testing UI const createDummyStaffMember = (photos: string[]): StaffMember => ({ _id: "dummy-staff-id", @@ -69,6 +63,235 @@ const createDummyStaffMember = (photos: string[]): StaffMember => ({ ], }); +// Dummy staff list for Staff Search tab +const DUMMY_STAFF_LIST: StaffMember[] = [ + { + _id: "staff-1", + name: "Alice Chen", + email: "alicechen@berkeley.edu", + personalLink: "https://alicechen.dev", + isAlumni: false, + semesterRoles: [ + { + _id: "r1", + year: 2024, + semester: "Fall", + role: "President", + team: "Leadership", + }, + { + _id: "r2", + year: 2024, + semester: "Spring", + role: "VP Engineering", + team: "Leadership", + }, + ], + }, + { + _id: "staff-2", + name: "Bob Martinez", + email: "bobm@gmail.com", + isAlumni: false, + semesterRoles: [ + { + _id: "r3", + year: 2024, + semester: "Fall", + role: "Software Engineer", + team: "Backend", + }, + ], + }, + { + _id: "staff-3", + name: "Carol Kim", + email: "carol.kim@berkeley.edu", + personalLink: "https://linkedin.com/in/carolkim", + isAlumni: true, + semesterRoles: [ + { + _id: "r4", + year: 2023, + semester: "Fall", + role: "Design Lead", + team: "Design", + }, + { + _id: "r5", + year: 2023, + semester: "Spring", + role: "Designer", + team: "Design", + }, + ], + }, + { + _id: "staff-4", + name: "David Park", + email: "dpark@berkeley.edu", + isAlumni: false, + semesterRoles: [ + { + _id: "r6", + year: 2024, + semester: "Fall", + role: "Software Engineer", + team: "Frontend", + }, + { + _id: "r7", + year: 2024, + semester: "Spring", + role: "Software Engineer", + team: "Frontend", + }, + { + _id: "r8", + year: 2023, + semester: "Fall", + role: "Junior Developer", + team: "Frontend", + }, + ], + }, + { + _id: "staff-5", + name: "Emily Zhang", + email: "emily.zhang@outlook.com", + personalLink: "https://emilyzhang.com", + isAlumni: false, + semesterRoles: [ + { + _id: "r9", + year: 2024, + semester: "Fall", + role: "VP Design", + team: "Leadership", + }, + ], + }, + { + _id: "staff-6", + name: "Frank Liu", + email: "frank@berkeley.edu", + isAlumni: true, + semesterRoles: [ + { + _id: "r10", + year: 2022, + semester: "Fall", + role: "President", + team: "Leadership", + }, + { + _id: "r11", + year: 2022, + semester: "Spring", + role: "VP Engineering", + team: "Leadership", + }, + { + _id: "r12", + year: 2021, + semester: "Fall", + role: "Engineering Lead", + team: "Backend", + }, + { + _id: "r13", + year: 2021, + semester: "Spring", + role: "Software Engineer", + team: "Backend", + }, + ], + }, + { + _id: "staff-7", + name: "Grace Wang", + email: "grace.wang@berkeley.edu", + personalLink: "https://gracewang.dev", + isAlumni: false, + semesterRoles: [ + { + _id: "r14", + year: 2024, + semester: "Fall", + role: "Designer", + team: "Design", + }, + ], + }, + { + _id: "staff-8", + name: "Henry Nguyen", + email: "hnguyen@berkeley.edu", + isAlumni: false, + semesterRoles: [ + { + _id: "r15", + year: 2024, + semester: "Fall", + role: "Software Engineer", + team: "Infrastructure", + }, + { + _id: "r16", + year: 2024, + semester: "Spring", + role: "Software Engineer", + team: "Infrastructure", + }, + ], + }, + { + _id: "staff-9", + name: "Iris Thompson", + email: "iris.t@yahoo.com", + personalLink: "https://linkedin.com/in/iris-t", + isAlumni: true, + semesterRoles: [ + { + _id: "r17", + year: 2023, + semester: "Spring", + role: "Product Manager", + team: "Product", + }, + ], + }, + { + _id: "staff-10", + name: "James Wilson", + email: "jwilson@berkeley.edu", + isAlumni: false, + semesterRoles: [ + { + _id: "r18", + year: 2024, + semester: "Fall", + role: "Data Engineer", + team: "Data", + }, + { + _id: "r19", + year: 2024, + semester: "Spring", + role: "Data Analyst", + team: "Data", + }, + { + _id: "r20", + year: 2023, + semester: "Fall", + role: "Junior Analyst", + team: "Data", + }, + ], + }, +]; + const ALL_USERS = gql` query AllUsers { allUsers { @@ -79,12 +302,162 @@ const ALL_USERS = gql` } `; +type Semester = "Spring" | "Summer" | "Fall" | "Winter"; + +interface RoleFormData { + year: string; + semester: Semester; + role: string; + team: string; + photo: string | null; +} + +interface StaffInfoFormData { + isAlumni: boolean; + personalLink: string; +} + +const SEMESTER_OPTIONS: OptionItem[] = [ + { value: "Spring", label: "Spring" }, + { value: "Summer", label: "Summer" }, + { value: "Fall", label: "Fall" }, + { value: "Winter", label: "Winter" }, +]; + +const currentYear = new Date().getFullYear(); +const YEAR_OPTIONS: OptionItem[] = Array.from( + { length: 10 }, + (_, i) => { + const year = (currentYear - i).toString(); + return { value: year, label: year }; + } +); + +const SEARCH_TABS = [ + { value: "staff", label: "Staff Search" }, + { value: "user", label: "User Search" }, +]; + +type StaffSearchBy = "name" | "role" | "semester" | "team"; + +const STAFF_SEARCH_BY_OPTIONS: OptionItem[] = [ + { value: "name", label: "Name" }, + { value: "role", label: "Role" }, + { value: "semester", label: "Semester" }, + { value: "team", label: "Team" }, +]; + export default function Dashboard() { + const [activeTab, setActiveTab] = useState("staff"); const [selectedUser, setSelectedUser] = useState( null ); const [searchQuery, setSearchQuery] = useState(""); + const [staffSearchBy, setStaffSearchBy] = useState("name"); + const [staffSearchQuery, setStaffSearchQuery] = useState(""); const [dogPhotos, setDogPhotos] = useState([]); + const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [roleForm, setRoleForm] = useState({ + year: currentYear.toString(), + semester: "Fall", + role: "", + team: "", + photo: null, + }); + const [isStaffInfoModalOpen, setIsStaffInfoModalOpen] = useState(false); + const [staffInfoForm, setStaffInfoForm] = useState({ + isAlumni: false, + personalLink: "", + }); + const [editingUser, setEditingUser] = useState<{ + name: string; + email?: string; + } | null>(null); + + const openAddModal = (user: { name: string; email?: string }) => { + setEditingUser(user); + setEditingRole(null); + setRoleForm({ + year: currentYear.toString(), + semester: "Fall", + role: "", + team: "", + photo: null, + }); + setIsRoleModalOpen(true); + }; + + const openEditModal = ( + role: SemesterRole, + user: { name: string; email?: string } + ) => { + setEditingUser(user); + setEditingRole(role); + setRoleForm({ + year: role.year.toString(), + semester: role.semester, + role: role.role, + team: role.team || "", + photo: role.photo || null, + }); + setIsRoleModalOpen(true); + }; + + const handlePhotoUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const img = new Image(); + img.onload = () => { + // Crop to square from center + const size = Math.min(img.width, img.height); + const x = (img.width - size) / 2; + const y = (img.height - size) / 2; + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, x, y, size, size, 0, 0, size, size); + setRoleForm({ + ...roleForm, + photo: canvas.toDataURL("image/jpeg", 0.9), + }); + } + }; + img.src = URL.createObjectURL(file); + } + }; + + const handleSaveRole = () => { + // TODO: Implement save logic with API call + console.log("Saving role:", { + ...roleForm, + year: parseInt(roleForm.year), + isEdit: editingRole !== null, + editingRoleId: editingRole?._id, + }); + setIsRoleModalOpen(false); + }; + + const openStaffInfoModal = ( + staffMember: StaffMember, + user: { name: string; email?: string } + ) => { + setEditingUser(user); + setStaffInfoForm({ + isAlumni: staffMember.isAlumni, + personalLink: staffMember.personalLink || "", + }); + setIsStaffInfoModalOpen(true); + }; + + const handleSaveStaffInfo = () => { + // TODO: Implement save logic with API call + console.log("Saving staff info:", staffInfoForm); + setIsStaffInfoModalOpen(false); + }; const { data, loading } = useQuery<{ allUsers: UserSearchResult[] }>( ALL_USERS @@ -124,6 +497,14 @@ export default function Dashboard() { })); }, [data?.allUsers, searchQuery]); + // Get unique past roles from the staff member's history + // TODO: Replace with actual staff member data from API + const pastRoles = useMemo(() => { + const staffMember = createDummyStaffMember(dogPhotos); + const roles = staffMember.semesterRoles.map((r) => r.role); + return [...new Set(roles)]; + }, [dogPhotos]); + const handleChange = ( value: UserSearchResult | UserSearchResult[] | null ) => { @@ -133,108 +514,396 @@ export default function Dashboard() { return (
-

Member Search

-
- { + if (value && !Array.isArray(value)) { + setStaffSearchBy(value); + } + }} + placeholder="Search by" + /> +
+
+ + setStaffSearchQuery(e.target.value)} + /> +
+
+
+ {DUMMY_STAFF_LIST.map((staff) => ( + + openStaffInfoModal(staff, { + name: staff.name, + email: staff.email, + }) + } + onEditRole={(role) => + openEditModal(role, { name: staff.name, email: staff.email }) + } + onAddRole={() => + openAddModal({ name: staff.name, email: staff.email }) + } + /> + ))} +
+ + )} -
- - - {isStaff ? "Staff member" : "Not a staff yet"} - - {isStaff && staffMember.isAlumni && ( - Alumni - )} -
+ {activeTab === "user" && ( +
+
+ + {roleForm.photo ? ( + Upload preview + ) : ( +
+ + Upload photo +
+ )} + +
+ + { + if (value && !Array.isArray(value)) { + setRoleForm({ ...roleForm, year: value }); + } + }} + placeholder="Select year" + /> +
+
+ + + setRoleForm({ ...roleForm, role: e.target.value }) + } + /> + {pastRoles.length > 0 && ( +
+ Past roles: {pastRoles.join(", ")} +
+ )} +
+
+ + + setRoleForm({ ...roleForm, team: e.target.value }) + } + /> +
+
+ + + + + + + + + + + + + {editingUser.email} + {!editingUser.email.endsWith("@berkeley.edu") && ( + + + Unaffiliated email + + )} + + ) : undefined + } + hasCloseButton + /> + +
+
+ + + setStaffInfoForm({ + ...staffInfoForm, + personalLink: e.target.value, + }) + } + /> +
+
+
- ); - })()} +
+ + + + +
+
); } From 97758cd40ad02f1b7c8286dc1eb5651052ce3e06 Mon Sep 17 00:00:00 2001 From: ARtheboss <30683624+ARtheboss@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:29:07 -0800 Subject: [PATCH 12/52] minio setup and stats --- .env.template | 6 + apps/backend/package.json | 1 - apps/backend/src/modules/index.ts | 2 + apps/backend/src/modules/stats/controller.ts | 192 ++++++++++++ apps/backend/src/modules/stats/index.ts | 9 + apps/backend/src/modules/stats/resolver.ts | 11 + apps/datapuller/package.json | 1 - apps/staff-frontend/package.json | 1 + apps/staff-frontend/src/App.tsx | 11 +- .../src/app/Stats/Stats.module.scss | 179 +++++++++++ apps/staff-frontend/src/app/Stats/index.tsx | 285 ++++++++++++++++++ docker-compose.yml | 20 +- package-lock.json | 5 +- packages/common/package.json | 4 +- packages/common/src/utils/config.ts | 10 + packages/common/tsconfig.json | 2 +- packages/gql-typedefs/index.ts | 1 + packages/gql-typedefs/stats.ts | 63 ++++ 18 files changed, 794 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/modules/stats/controller.ts create mode 100644 apps/backend/src/modules/stats/index.ts create mode 100644 apps/backend/src/modules/stats/resolver.ts create mode 100644 apps/staff-frontend/src/app/Stats/Stats.module.scss create mode 100644 apps/staff-frontend/src/app/Stats/index.tsx create mode 100644 packages/gql-typedefs/stats.ts diff --git a/.env.template b/.env.template index 2de8b1afb..78fb0b2c6 100644 --- a/.env.template +++ b/.env.template @@ -27,3 +27,9 @@ AWS_SECRET_ACCESS_KEY=_ GOOGLE_CLIENT_ID=_ GOOGLE_CLIENT_SECRET=_ SESSION_SECRET=_ + +S3_ENDPOINT=minio +S3_PORT=9000 +S3_ACCESS_KEY_ID=root +S3_SECRET_ACCESS_KEY=password +S3_STAFF_PHOTOS_BUCKET=staff-photos \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index 9a3c92257..784d53429 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -45,7 +45,6 @@ "compression": "^1.8.1", "connect-redis": "^9.0.0", "cors": "^2.8.5", - "dotenv": "^17.2.3", "express": "^5.1.0", "express-session": "^1.18.2", "graphql": "^16.11.0", diff --git a/apps/backend/src/modules/index.ts b/apps/backend/src/modules/index.ts index d5cab6f05..7dfb94db7 100644 --- a/apps/backend/src/modules/index.ts +++ b/apps/backend/src/modules/index.ts @@ -12,6 +12,7 @@ import Plan from "./plan"; import Rating from "./rating"; import Schedule from "./schedule"; import Staff from "./staff"; +import Stats from "./stats"; import Term from "./term"; import User from "./user"; @@ -24,6 +25,7 @@ const modules = [ Common, Schedule, Staff, + Stats, Term, Course, Class, diff --git a/apps/backend/src/modules/stats/controller.ts b/apps/backend/src/modules/stats/controller.ts new file mode 100644 index 000000000..998b2d3e4 --- /dev/null +++ b/apps/backend/src/modules/stats/controller.ts @@ -0,0 +1,192 @@ +import { + AggregatedMetricsModel, + PlanModel, + RatingModel, + ScheduleModel, + UserModel, +} from "@repo/common"; + +export const getStats = async () => { + // Users stats + const totalUsers = await UserModel.countDocuments({}); + + const usersLastWeek = await UserModel.countDocuments({ + createdAt: { + $gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000), + }, + }); + const usersLastMonth = await UserModel.countDocuments({ + createdAt: { + $gte: new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000), + }, + }); + + // Scheduler stats + const uniqueUsersWithSchedules = ( + await ScheduleModel.distinct("createdBy") + ).length; + + // Gradtrak stats + // Total courses across all plans + const totalCoursesResult = await PlanModel.aggregate([ + { $unwind: "$planTerms" }, + { + $group: { + _id: null, + totalCourses: { $sum: { $size: "$planTerms.courses" } }, + }, + }, + ]); + const totalCourses = totalCoursesResult[0]?.totalCourses || 0; + + // Max courses in one plan + const maxCoursesResult = await PlanModel.aggregate([ + { $unwind: "$planTerms" }, + { + $group: { + _id: "$_id", + totalCourses: { $sum: { $size: "$planTerms.courses" } }, + }, + }, + { $sort: { totalCourses: -1 } }, + { $limit: 1 }, + ]); + const maxCoursesInOnePlan = maxCoursesResult[0]?.totalCourses || 0; + + // Top 3 plans with most courses + const topPlansResult = await PlanModel.aggregate([ + { $unwind: "$planTerms" }, + { + $group: { + _id: "$_id", + totalCourses: { $sum: { $size: "$planTerms.courses" } }, + }, + }, + { $sort: { totalCourses: -1 } }, + { $limit: 3 }, + ]); + const topPlansWithMostCourses = topPlansResult.map((plan) => ({ + planId: plan._id.toString(), + totalCourses: plan.totalCourses, + })); + + // Histogram of courses + const histogramResult = await PlanModel.aggregate([ + { $unwind: "$planTerms" }, + { + $group: { + _id: "$_id", + totalCourses: { $sum: { $size: "$planTerms.courses" } }, + }, + }, + { + $bucket: { + groupBy: "$totalCourses", + boundaries: [0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40], + default: "40+", + output: { count: { $sum: 1 } }, + }, + }, + ]); + const courseHistogram = histogramResult.map((bucket) => ({ + range: + bucket._id === "40+" + ? "40+" + : `${bucket._id}-${(bucket._id as number) + 3}`, + count: bucket.count, + })); + + // Ratings stats + // Number of classes with ratings + const classesWithRatingsResult = await AggregatedMetricsModel.aggregate([ + { + $group: { + _id: { + subject: "$subject", + courseNumber: "$courseNumbers", + }, + }, + }, + { $count: "uniqueSubjectCourseCount" }, + ]); + const classesWithRatings = + classesWithRatingsResult[0]?.uniqueSubjectCourseCount || 0; + + // Course with most ratings + const courseWithMostRatingsResult = await AggregatedMetricsModel.aggregate([ + { + $group: { + _id: { + subject: "$subject", + courseNumber: "$courseNumber", + metricName: "$metricName", + }, + totalRatings: { $sum: "$categoryCount" }, + }, + }, + { $sort: { totalRatings: -1 } }, + { $limit: 1 }, + ]); + const courseWithMostRatings = courseWithMostRatingsResult[0] + ? { + subject: courseWithMostRatingsResult[0]._id.subject, + courseNumber: courseWithMostRatingsResult[0]._id.courseNumber, + totalRatings: courseWithMostRatingsResult[0].totalRatings, + } + : null; + + // Class with most ratings + const classWithMostRatingsResult = await AggregatedMetricsModel.aggregate([ + { + $group: { + _id: { + subject: "$subject", + courseNumber: "$courseNumber", + semester: "$semester", + year: "$year", + classNumber: "$classNumber", + metricName: "$metricName", + }, + totalRatings: { $sum: "$categoryCount" }, + }, + }, + { $sort: { totalRatings: -1 } }, + { $limit: 1 }, + ]); + const classWithMostRatings = classWithMostRatingsResult[0] + ? { + subject: classWithMostRatingsResult[0]._id.subject, + courseNumber: classWithMostRatingsResult[0]._id.courseNumber, + semester: classWithMostRatingsResult[0]._id.semester, + year: classWithMostRatingsResult[0]._id.year, + classNumber: classWithMostRatingsResult[0]._id.classNumber, + totalRatings: classWithMostRatingsResult[0].totalRatings, + } + : null; + + // Unique createdBy + const uniqueCreatedBy = (await RatingModel.distinct("createdBy")).length; + + return { + users: { + totalCount: totalUsers, + createdLastWeek: usersLastWeek, + createdLastMonth: usersLastMonth, + }, + scheduler: { + uniqueUsersWithSchedules, + }, + gradtrak: { + totalCourses, + maxCoursesInOnePlan, + topPlansWithMostCourses, + courseHistogram, + }, + ratings: { + classesWithRatings, + courseWithMostRatings, + classWithMostRatings, + uniqueCreatedBy, + }, + }; +}; diff --git a/apps/backend/src/modules/stats/index.ts b/apps/backend/src/modules/stats/index.ts new file mode 100644 index 000000000..6dc8c286f --- /dev/null +++ b/apps/backend/src/modules/stats/index.ts @@ -0,0 +1,9 @@ +import { statsTypeDef } from "@repo/gql-typedefs"; + +import resolver from "./resolver"; + +export default { + resolver, + typeDef: statsTypeDef, +}; + diff --git a/apps/backend/src/modules/stats/resolver.ts b/apps/backend/src/modules/stats/resolver.ts new file mode 100644 index 000000000..dcc1756e8 --- /dev/null +++ b/apps/backend/src/modules/stats/resolver.ts @@ -0,0 +1,11 @@ +import { getStats } from "./controller"; +import { StatsModule } from "./generated-types/module-types"; + +const resolvers: StatsModule.Resolvers = { + Query: { + stats: () => getStats(), + }, +}; + +export default resolvers; + diff --git a/apps/datapuller/package.json b/apps/datapuller/package.json index e6fd0b074..66a3e0a59 100644 --- a/apps/datapuller/package.json +++ b/apps/datapuller/package.json @@ -17,7 +17,6 @@ "@aws-sdk/client-s3": "^3.901.0", "@repo/common": "*", "@repo/sis-api": "*", - "dotenv": "^17.2.3", "luxon": "^3.7.2", "papaparse": "^5.5.3", "tslog": "^4.10.2" diff --git a/apps/staff-frontend/package.json b/apps/staff-frontend/package.json index e0f0e7a72..36c3ffa21 100644 --- a/apps/staff-frontend/package.json +++ b/apps/staff-frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@apollo/client": "^3.13.8", "@repo/theme": "*", + "@repo/common": "*", "classnames": "^2.5.1", "graphql": "^16.11.0", "iconoir-react": "^7.11.0", diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx index ffa2521e1..76bd4bf47 100644 --- a/apps/staff-frontend/src/App.tsx +++ b/apps/staff-frontend/src/App.tsx @@ -5,14 +5,19 @@ import { ThemeProvider } from "@repo/theme"; import Layout from "@/components/Layout"; import Dashboard from "./app/Dashboard"; +import Stats from "./app/Stats"; import { useReadUser } from "./hooks/api/users/useReadUser"; +export const BASE = import.meta.env.DEV + ? "http://localhost:3000" + : "https://beta.berkeleytime.com"; + export const signIn = (redirectURI?: string) => { redirectURI = redirectURI ?? window.location.origin + window.location.pathname + window.location.search; - window.location.href = `${window.location.origin}/api/login?redirect_uri=${redirectURI}`; + window.location.href = `${BASE}/api/login?redirect_uri=${redirectURI}`; }; const router = createBrowserRouter([ @@ -23,6 +28,10 @@ const router = createBrowserRouter([ index: true, element: , }, + { + path: "stats", + element: , + }, ], }, ]); diff --git a/apps/staff-frontend/src/app/Stats/Stats.module.scss b/apps/staff-frontend/src/app/Stats/Stats.module.scss new file mode 100644 index 000000000..7b02908d7 --- /dev/null +++ b/apps/staff-frontend/src/app/Stats/Stats.module.scss @@ -0,0 +1,179 @@ +.root { + display: flex; + flex-direction: column; + padding: 32px 24px; + flex: 1; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.title { + font-size: 24px; + font-weight: 600; + color: var(--heading-color); + margin-bottom: 32px; +} + +.loading, +.error { + padding: 24px; + text-align: center; + color: var(--paragraph-color); +} + +.error { + color: var(--red-500); +} + +.section { + margin-bottom: 48px; +} + +.sectionTitle { + font-size: 20px; + font-weight: 600; + color: var(--heading-color); + margin-bottom: 16px; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.statCard { + padding: 20px; + border-radius: 12px; + background-color: var(--foreground-color); + border: 1px solid var(--border-color); +} + +.statLabel { + font-size: 13px; + font-weight: 500; + color: var(--label-color); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.statValue { + font-size: 28px; + font-weight: 600; + color: var(--heading-color); +} + +.subsection { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +.subsectionTitle { + font-size: 16px; + font-weight: 600; + color: var(--heading-color); + margin-bottom: 16px; +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.listItem { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 8px; + background-color: var(--foreground-color); + border: 1px solid var(--border-color); +} + +.listItemNumber { + font-weight: 600; + color: var(--label-color); + min-width: 24px; +} + +.listItemText { + color: var(--paragraph-color); +} + +.histogram { + display: flex; + flex-direction: column; + gap: 12px; +} + +.histogramBar { + display: flex; + align-items: center; + gap: 12px; +} + +.histogramLabel { + font-size: 13px; + font-weight: 500; + color: var(--label-color); + min-width: 60px; + text-align: right; +} + +.histogramBarContainer { + flex: 1; + height: 24px; + background-color: var(--button-hover-color); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.histogramBarFill { + height: 100%; + background-color: var(--blue-500); + transition: width 0.3s ease; +} + +.histogramValue { + font-size: 14px; + font-weight: 500; + color: var(--paragraph-color); + min-width: 40px; + text-align: right; +} + +.detailCard { + padding: 20px; + border-radius: 12px; + background-color: var(--foreground-color); + border: 1px solid var(--border-color); +} + +.detailRow { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +} + +.detailLabel { + font-size: 14px; + font-weight: 500; + color: var(--label-color); + min-width: 120px; +} + +.detailValue { + font-size: 14px; + color: var(--paragraph-color); +} diff --git a/apps/staff-frontend/src/app/Stats/index.tsx b/apps/staff-frontend/src/app/Stats/index.tsx new file mode 100644 index 000000000..dedd2e5fc --- /dev/null +++ b/apps/staff-frontend/src/app/Stats/index.tsx @@ -0,0 +1,285 @@ +import { gql, useQuery } from "@apollo/client"; + +import styles from "./Stats.module.scss"; + +const STATS_QUERY = gql` + query Stats { + stats { + users { + totalCount + createdLastWeek + createdLastMonth + } + scheduler { + uniqueUsersWithSchedules + } + gradtrak { + totalCourses + maxCoursesInOnePlan + topPlansWithMostCourses { + planId + totalCourses + } + courseHistogram { + range + count + } + } + ratings { + classesWithRatings + courseWithMostRatings { + subject + courseNumber + totalRatings + } + classWithMostRatings { + subject + courseNumber + semester + year + classNumber + totalRatings + } + uniqueCreatedBy + } + } + } +`; + +interface StatsData { + stats: { + users: { + totalCount: number; + createdLastWeek: number; + createdLastMonth: number; + }; + scheduler: { + uniqueUsersWithSchedules: number; + }; + gradtrak: { + totalCourses: number; + maxCoursesInOnePlan: number; + topPlansWithMostCourses: Array<{ + planId: string; + totalCourses: number; + }>; + courseHistogram: Array<{ + range: string; + count: number; + }>; + }; + ratings: { + classesWithRatings: number; + courseWithMostRatings: { + subject: string; + courseNumber: string; + totalRatings: number; + }; + classWithMostRatings: { + subject: string; + courseNumber: string; + semester: string; + year: number; + classNumber: string; + totalRatings: number; + }; + uniqueCreatedBy: number; + }; + }; +} + +export default function Stats() { + const { data, loading, error } = useQuery(STATS_QUERY); + + if (loading) { + return ( +
+

Statistics

+
Loading...
+
+ ); + } + + if (error) { + return ( +
+

Statistics

+
Error loading statistics: {error.message}
+
+ ); + } + + if (!data?.stats) { + return ( +
+

Statistics

+
No data available
+
+ ); + } + + const { users, scheduler, gradtrak, ratings } = data.stats; + + return ( +
+

Statistics

+ +
+

Users

+
+
+
Total Count
+
{users.totalCount.toLocaleString()}
+
+
+
Created Last Week
+
+ {users.createdLastWeek.toLocaleString()} +
+
+
+
Created Last Month
+
+ {users.createdLastMonth.toLocaleString()} +
+
+
+
+ +
+

Scheduler

+
+
+
Unique Users with Schedules
+
+ {scheduler.uniqueUsersWithSchedules.toLocaleString()} +
+
+
+
+ +
+

Gradtrak

+
+
+
Total Courses
+
+ {gradtrak.totalCourses.toLocaleString()} +
+
+
+
Max Courses in One Plan
+
+ {gradtrak.maxCoursesInOnePlan.toLocaleString()} +
+
+
+ + {gradtrak.topPlansWithMostCourses.length > 0 && ( +
+

Top 3 Plans with Most Courses

+
+ {gradtrak.topPlansWithMostCourses.map((plan, index) => ( +
+ {index + 1}. + + Plan {plan.planId}: {plan.totalCourses} courses + +
+ ))} +
+
+ )} + + {gradtrak.courseHistogram.length > 0 && ( +
+

Course Distribution

+
+ {gradtrak.courseHistogram.map((bucket) => ( +
+
{bucket.range}
+
+
b.count))) * 100}%`, + }} + /> +
+
{bucket.count}
+
+ ))} +
+
+ )} +
+ +
+

Ratings

+
+
+
Classes with Ratings
+
+ {ratings.classesWithRatings.toLocaleString()} +
+
+
+
Unique Users Who Created Ratings
+
+ {ratings.uniqueCreatedBy.toLocaleString()} +
+
+
+ + {ratings.courseWithMostRatings && ( +
+

Course with Most Ratings

+
+
+ Course: + + {ratings.courseWithMostRatings.subject}{" "} + {ratings.courseWithMostRatings.courseNumber} + +
+
+ Total Ratings: + + {ratings.courseWithMostRatings.totalRatings.toLocaleString()} + +
+
+
+ )} + + {ratings.classWithMostRatings && ( +
+

Class with Most Ratings

+
+
+ Class: + + {ratings.classWithMostRatings.subject}{" "} + {ratings.classWithMostRatings.courseNumber} - Class{" "} + {ratings.classWithMostRatings.classNumber} + +
+
+ Term: + + {ratings.classWithMostRatings.semester} {ratings.classWithMostRatings.year} + +
+
+ Total Ratings: + + {ratings.classWithMostRatings.totalRatings.toLocaleString()} + +
+
+
+ )} +
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index a71cbb4b5..9c0d57dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,7 @@ services: networks: - bt ports: - - 8080:8080 + - 3000:8080 - 8081:8081 - 8082:8082 restart: always @@ -112,10 +112,26 @@ services: dockerfile: ./apps/docs/Dockerfile target: docs-dev ports: - - 3000:3000 + - 8080:3000 networks: - bt restart: always volumes: - ./apps/docs:/apps/docs + minio: + image: 'minio/minio:latest' + ports: + - '9000:9000' + - '9090:9090' + environment: + MINIO_ROOT_USER: 'root' + MINIO_ROOT_PASSWORD: 'password' + networks: + - bt + volumes: + - 'minio:/data/minio' + command: minio server /data/minio --console-address ":9090" +volumes: + minio: + driver: local diff --git a/package-lock.json b/package-lock.json index 814e6d0d2..3848586f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,6 @@ "compression": "^1.8.1", "connect-redis": "^9.0.0", "cors": "^2.8.5", - "dotenv": "^17.2.3", "express": "^5.1.0", "express-session": "^1.18.2", "graphql": "^16.11.0", @@ -114,7 +113,6 @@ "@aws-sdk/client-s3": "^3.901.0", "@repo/common": "*", "@repo/sis-api": "*", - "dotenv": "^17.2.3", "luxon": "^3.7.2", "papaparse": "^5.5.3", "tslog": "^4.10.2" @@ -180,6 +178,7 @@ "apps/staff-frontend": { "dependencies": { "@apollo/client": "^3.13.8", + "@repo/common": "*", "@repo/theme": "*", "classnames": "^2.5.1", "graphql": "^16.11.0", @@ -17701,10 +17700,12 @@ "packages/common": { "name": "@repo/common", "dependencies": { + "dotenv": "^17.2.3", "mongoose": "^8.19.1" }, "devDependencies": { "@repo/typescript-config": "*", + "@types/node": "^24.7.0", "typescript": "^5.9.3" } }, diff --git a/packages/common/package.json b/packages/common/package.json index e91b4caca..ec43ce381 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -6,9 +6,11 @@ }, "devDependencies": { "@repo/typescript-config": "*", + "@types/node": "^24.7.0", "typescript": "^5.9.3" }, "dependencies": { - "mongoose": "^8.19.1" + "mongoose": "^8.19.1", + "dotenv": "^17.2.3" } } diff --git a/packages/common/src/utils/config.ts b/packages/common/src/utils/config.ts index 8d5f8301e..afd981e7c 100644 --- a/packages/common/src/utils/config.ts +++ b/packages/common/src/utils/config.ts @@ -35,6 +35,11 @@ export interface Config { GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; redisUri: string; + s3Endpoint: string; + s3Port: string; + s3AccessKeyId: string; + s3SecretAccessKey: string; + s3StaffPhotosBucket: string; } // All your secrets, keys go here @@ -60,4 +65,9 @@ export const config: Config = { GOOGLE_CLIENT_ID: env("GOOGLE_CLIENT_ID"), GOOGLE_CLIENT_SECRET: env("GOOGLE_CLIENT_SECRET"), redisUri: env("REDIS_URI"), + s3Endpoint: env("S3_ENDPOINT"), + s3Port: env("S3_PORT"), + s3AccessKeyId: env("S3_ACCESS_KEY_ID"), + s3SecretAccessKey: env("S3_SECRET_ACCESS_KEY"), + s3StaffPhotosBucket: env("S3_STAFF_PHOTOS_BUCKET"), }; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 8151bd4cd..c1a9cbd60 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@repo/typescript-config/vite.json", + "extends": "@repo/typescript-config/node.json", "compilerOptions": { "allowImportingTsExtensions": true, }, diff --git a/packages/gql-typedefs/index.ts b/packages/gql-typedefs/index.ts index ce2c4eadc..580f310c6 100644 --- a/packages/gql-typedefs/index.ts +++ b/packages/gql-typedefs/index.ts @@ -12,3 +12,4 @@ export * from "./schedule"; export * from "./term"; export * from "./user"; export * from "./staff"; +export * from "./stats"; diff --git a/packages/gql-typedefs/stats.ts b/packages/gql-typedefs/stats.ts new file mode 100644 index 000000000..c6b62037e --- /dev/null +++ b/packages/gql-typedefs/stats.ts @@ -0,0 +1,63 @@ +import { gql } from "graphql-tag"; + +export const statsTypeDef = gql` + type UserStats { + totalCount: Int! + createdLastWeek: Int! + createdLastMonth: Int! + } + + type SchedulerStats { + uniqueUsersWithSchedules: Int! + } + + type PlanCourseCount { + planId: ID! + totalCourses: Int! + } + + type CourseHistogramBucket { + range: String! + count: Int! + } + + type GradtrakStats { + totalCourses: Int! + maxCoursesInOnePlan: Int! + topPlansWithMostCourses: [PlanCourseCount!]! + courseHistogram: [CourseHistogramBucket!]! + } + + type CourseWithMostRatings { + subject: String! + courseNumber: String! + totalRatings: Int! + } + + type ClassWithMostRatings { + subject: String! + courseNumber: String! + semester: String! + year: Int! + classNumber: String! + totalRatings: Int! + } + + type RatingsStats { + classesWithRatings: Int! + courseWithMostRatings: CourseWithMostRatings + classWithMostRatings: ClassWithMostRatings + uniqueCreatedBy: Int! + } + + type Stats @cacheControl(maxAge: 1) { + users: UserStats! + scheduler: SchedulerStats! + gradtrak: GradtrakStats! + ratings: RatingsStats! + } + + type Query { + stats: Stats! + } +`; From de03a47eefb128810c417644395864dc21d5ff26 Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 18:43:40 -0800 Subject: [PATCH 13/52] fixes --- apps/staff-frontend/src/App.module.scss | 30 ++++++++++ apps/staff-frontend/src/App.tsx | 77 +++++++++++++------------ docker-compose.yml | 4 +- 3 files changed, 73 insertions(+), 38 deletions(-) create mode 100644 apps/staff-frontend/src/App.module.scss diff --git a/apps/staff-frontend/src/App.module.scss b/apps/staff-frontend/src/App.module.scss new file mode 100644 index 000000000..a0dce0bfd --- /dev/null +++ b/apps/staff-frontend/src/App.module.scss @@ -0,0 +1,30 @@ +.signInContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 24px; + background-color: var(--background-color); + color: var(--text-color); +} + +.asciiArt { + font-family: monospace; + font-size: 10px; + line-height: 1.2; + color: var(--heading-color); + margin: 0; + white-space: pre; + display: none; + + @media (min-width: 600px) and (min-height: 700px) { + display: block; + } +} + +.heading { + font-size: 24px; + font-weight: 500; + color: var(--heading-color); +} diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx index 76bd4bf47..d380efe0f 100644 --- a/apps/staff-frontend/src/App.tsx +++ b/apps/staff-frontend/src/App.tsx @@ -1,15 +1,17 @@ import { RouterProvider, createBrowserRouter } from "react-router-dom"; +import { ArrowRight } from "iconoir-react"; -import { ThemeProvider } from "@repo/theme"; +import { Button, ThemeProvider } from "@repo/theme"; import Layout from "@/components/Layout"; import Dashboard from "./app/Dashboard"; import Stats from "./app/Stats"; import { useReadUser } from "./hooks/api/users/useReadUser"; +import styles from "./App.module.scss"; export const BASE = import.meta.env.DEV - ? "http://localhost:3000" + ? "http://localhost:8080" : "https://beta.berkeleytime.com"; export const signIn = (redirectURI?: string) => { @@ -42,16 +44,7 @@ export default function App() { if (userLoading) { return ( -
- Loading... -
+
Loading...
); } @@ -59,30 +52,42 @@ export default function App() { if (!user) { return ( -
-

Staff Dashboard

- +
+
{`                .,,uod8B8bou,,.
+           ..,uod8BBBBBBBBBBBBBBBBRPFT?l!i:.
+      ,=m8BBBBBBBBBBBBBBBRPFT?!||||||||||||||
+      !...:!TVBBBRPFT||||||||||!!^^""'   ||||
+      !.......:!?|||||!!^^""'            ||||
+      !.........||||                     ||||
+      !.........||||  $                  ||||
+      !.........||||                     ||||
+      !.........||||                     ||||
+      !.........||||                     ||||
+      !.........||||                     ||||
+       \`........||||                    ,||||
+        .;.......||||               _.-!!|||||
+ .,uodWBBBBb.....||||       _.-!!|||||||||!:'
+!YBBBBBBBBBBBBBBb..!|||:..-!!|||||||!iof68BBBBBb....
+!..YBBBBBBBBBBBBBBb!!||||||||!iof68BBBBBBRPFT?!::   \`.
+!....YBBBBBBBBBBBBBBbaaitf68BBBBBBRPFT?!:::::::::     \`.
+!......YBBBBBBBBBBBBBBBBBBBRPFT?!::::::;:!^"\`;:::       \`.
+!........YBBBBBBBBBBRPFT?!::::::::::^''...::::::;         iBBbo.
+\`..........YBRPFT?!::::::::::::::::::::::::;iof68bo.      WBBBBbo.
+  \`..........:::::::::::::::::::::::;iof688888888888b.     \`YBBBP^'
+    \`........::::::::::::::::;iof688888888888888888888b.     \`
+      \`......:::::::::;iof688888888888888888888888888888b.
+        \`....:::;iof688888888888888888888888888888888899fT!
+          \`..::!8888888888888888888888888888888899fT|!^"'
+            \`' !!988888888888888888888888899fT|!^"'
+                \`!!8888888888888888899fT|!^"'
+                  \`!988888888899fT|!^"'
+                    \`!9899fT|!^"'
+                      \`!^"'`}
+

Staff Dashboard

+
); diff --git a/docker-compose.yml b/docker-compose.yml index 9c0d57dfa..205fa99d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,7 @@ services: networks: - bt ports: - - 3000:8080 + - 8080:8080 - 8081:8081 - 8082:8082 restart: always @@ -112,7 +112,7 @@ services: dockerfile: ./apps/docs/Dockerfile target: docs-dev ports: - - 8080:3000 + - 3000:3000 networks: - bt restart: always From fe45451fde374d9d2528f3192772d73da9cf489a Mon Sep 17 00:00:00 2001 From: PineND Date: Fri, 19 Dec 2025 19:00:45 -0800 Subject: [PATCH 14/52] backend support --- apps/backend/src/modules/staff/controller.ts | 130 ++++++++- apps/backend/src/modules/staff/resolver.ts | 36 +++ apps/staff-frontend/src/App.tsx | 4 +- .../src/app/Dashboard/Dashboard.module.scss | 62 ++++- .../src/app/Dashboard/StaffCard.tsx | 13 +- .../src/app/Dashboard/index.tsx | 262 +++++++++++++----- packages/common/src/models/semester-role.ts | 10 +- packages/common/src/models/staff-member.ts | 10 + packages/gql-typedefs/staff.ts | 65 ++++- 9 files changed, 510 insertions(+), 82 deletions(-) diff --git a/apps/backend/src/modules/staff/controller.ts b/apps/backend/src/modules/staff/controller.ts index 757a6054c..34ed4bca2 100644 --- a/apps/backend/src/modules/staff/controller.ts +++ b/apps/backend/src/modules/staff/controller.ts @@ -1,6 +1,10 @@ import { SemesterRoleModel, StaffMemberModel, UserModel } from "@repo/common"; -import { Semester } from "../../generated-types/graphql"; +import { + Semester, + UpdateStaffInfoInput, + UpsertSemesterRoleInput, +} from "../../generated-types/graphql"; export const getStaffBySemester = async (year: number, semester: Semester) => { const roles = await SemesterRoleModel.find({ year, semester }) @@ -36,3 +40,127 @@ export const getAllUsers = async () => { return users; }; + +export const getStaffMemberByUserId = async (userId: string) => { + const member = await StaffMemberModel.findOne({ userId }).lean(); + return member; +}; + +export const ensureStaffMember = async ( + userId: string, + addedBy?: string | null +) => { + // Check if StaffMember already exists for this userId + const existingMember = await StaffMemberModel.findOne({ userId }).lean(); + + if (existingMember) { + return existingMember; + } + + // Get user info to create staff member + const user = await UserModel.findById(userId).lean(); + if (!user) { + throw new Error(`User with ID ${userId} not found`); + } + + // Create new StaffMember + const newMember = await StaffMemberModel.create({ + userId, + name: user.name, + isAlumni: false, + isLeadership: false, + addedBy: addedBy || undefined, + }); + + // Set user.staff = true + await UserModel.findByIdAndUpdate(userId, { staff: true }); + + return newMember.toObject(); +}; + +export const upsertSemesterRole = async ( + memberId: string, + input: UpsertSemesterRoleInput +) => { + // Use findOneAndUpdate with upsert to create or update + const role = await SemesterRoleModel.findOneAndUpdate( + { + memberId, + year: input.year, + semester: input.semester, + }, + { + $set: { + role: input.role, + team: input.team || null, + photo: input.photo || null, + altPhoto: input.altPhoto || null, + isLeadership: input.isLeadership ?? false, + }, + $setOnInsert: { + memberId, + year: input.year, + semester: input.semester, + }, + }, + { + upsert: true, + new: true, + lean: true, + } + ); + + return role; +}; + +export const deleteSemesterRole = async (roleId: string) => { + const result = await SemesterRoleModel.findByIdAndDelete(roleId); + return result !== null; +}; + +export const updateStaffInfo = async ( + memberId: string, + input: UpdateStaffInfoInput +) => { + const updateData: Record = {}; + + if (input.personalLink !== undefined) { + updateData.personalLink = input.personalLink; + } + if (input.isAlumni !== undefined) { + updateData.isAlumni = input.isAlumni; + } + + const member = await StaffMemberModel.findByIdAndUpdate( + memberId, + { $set: updateData }, + { new: true, lean: true } + ); + + if (!member) { + throw new Error(`Staff member with ID ${memberId} not found`); + } + + return member; +}; + +export const deleteStaffMember = async (memberId: string) => { + // Get staff member to find userId + const member = await StaffMemberModel.findById(memberId).lean(); + if (!member) { + return false; + } + + // Delete all SemesterRoles for this member + await SemesterRoleModel.deleteMany({ memberId }); + + // Delete the StaffMember + await StaffMemberModel.findByIdAndDelete(memberId); + + // If userId exists, set user.staff = false + if (member.userId) { + await UserModel.findByIdAndUpdate(member.userId, { staff: false }); + } + + return true; +}; diff --git a/apps/backend/src/modules/staff/resolver.ts b/apps/backend/src/modules/staff/resolver.ts index d091a1373..542fe970d 100644 --- a/apps/backend/src/modules/staff/resolver.ts +++ b/apps/backend/src/modules/staff/resolver.ts @@ -1,9 +1,19 @@ import { + UpdateStaffInfoInput, + UpsertSemesterRoleInput, +} from "../../generated-types/graphql"; +import { + deleteSemesterRole, + deleteStaffMember, + ensureStaffMember, getAllUsers, getMemberRoles, getRoleMember, getStaffBySemester, getStaffMember, + getStaffMemberByUserId, + updateStaffInfo, + upsertSemesterRole, } from "./controller"; const resolvers = { @@ -15,9 +25,35 @@ const resolvers = { staffMember: (_: unknown, { id }: { id: string }) => getStaffMember(id), + staffMemberByUserId: (_: unknown, { userId }: { userId: string }) => + getStaffMemberByUserId(userId), + allUsers: () => getAllUsers(), }, + Mutation: { + ensureStaffMember: ( + _: unknown, + { userId, addedBy }: { userId: string; addedBy?: string | null } + ) => ensureStaffMember(userId, addedBy), + + upsertSemesterRole: ( + _: unknown, + { memberId, input }: { memberId: string; input: UpsertSemesterRoleInput } + ) => upsertSemesterRole(memberId, input), + + deleteSemesterRole: (_: unknown, { roleId }: { roleId: string }) => + deleteSemesterRole(roleId), + + updateStaffInfo: ( + _: unknown, + { memberId, input }: { memberId: string; input: UpdateStaffInfoInput } + ) => updateStaffInfo(memberId, input), + + deleteStaffMember: (_: unknown, { memberId }: { memberId: string }) => + deleteStaffMember(memberId), + }, + StaffMember: { id: (parent: any) => parent._id?.toString() ?? parent.id, roles: (parent: any) => getMemberRoles(parent._id?.toString() ?? parent.id), diff --git a/apps/staff-frontend/src/App.tsx b/apps/staff-frontend/src/App.tsx index d380efe0f..a7fe3487f 100644 --- a/apps/staff-frontend/src/App.tsx +++ b/apps/staff-frontend/src/App.tsx @@ -1,14 +1,14 @@ -import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { ArrowRight } from "iconoir-react"; +import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { Button, ThemeProvider } from "@repo/theme"; import Layout from "@/components/Layout"; +import styles from "./App.module.scss"; import Dashboard from "./app/Dashboard"; import Stats from "./app/Stats"; import { useReadUser } from "./hooks/api/users/useReadUser"; -import styles from "./App.module.scss"; export const BASE = import.meta.env.DEV ? "http://localhost:8080" diff --git a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss index d49ae93b1..dcada3136 100644 --- a/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss +++ b/apps/staff-frontend/src/app/Dashboard/Dashboard.module.scss @@ -280,13 +280,18 @@ color: var(--paragraph-color); } +.semesterRoleTags { + display: flex; + align-items: center; + gap: 6px; +} + .semesterRoleTeam { font-size: 12px; color: var(--label-color); padding: 2px 8px; background-color: var(--border-color); border-radius: 4px; - align-self: flex-start; } .editButton { @@ -317,6 +322,15 @@ border-radius: 4px; } +.leadBadge { + font-size: 12px; + font-weight: 500; + color: var(--yellow-500); + padding: 2px 8px; + background-color: color-mix(in srgb, var(--yellow-500) 20%, transparent); + border-radius: 4px; +} + .addButton { display: flex; align-items: center; @@ -380,14 +394,52 @@ color: var(--paragraph-color); } -.photoUpload { +.photoUploadRow { grid-column: 1 / -1; + display: flex; + gap: 16px; + justify-content: center; +} + +.photoUploadContainer { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + max-width: 160px; +} + +.photoUploadLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--heading-color); +} + +.photoRemoveButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--red-500); + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--red-400); + } +} + +.photoUpload { display: flex; align-items: center; justify-content: center; - width: 180px; - height: 180px; - margin: 0 auto; + width: 100%; + aspect-ratio: 1; border: 2px dashed var(--border-color); border-radius: 12px; cursor: pointer; diff --git a/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx b/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx index 329948948..e655a8e8c 100644 --- a/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx +++ b/apps/staff-frontend/src/app/Dashboard/StaffCard.tsx @@ -14,6 +14,8 @@ export interface SemesterRole { role: string; team?: string; photo?: string; + altPhoto?: string; + isLeadership: boolean; } export interface StaffMember { @@ -114,9 +116,14 @@ export default function StaffCard({ {role.role}
- {role.team && ( - {role.team} - )} +
+ {role.isLeadership && ( + Lead + )} + {role.team && ( + {role.team} + )} +
{onEditRole && ( + ) : ( + )}
-
-
Experience
- {isStaff && - staffMember?.semesterRoles.map((role) => ( + {staffMember && ( +
+
+ Experience +
+ {staffMember.semesterRoles.map((role) => (
{role.photo ? ( )}
- - {role.semester} {role.year} - - - {role.role} - - {role.team && ( - - {role.team} +
+ + {role.semester} {role.year} - )} + + {role.role} + +
+
+ {role.isLeadership && ( + Lead + )} + {role.team && ( + + {role.team} + + )} +
))} - -
+ +
+ )} ); })()} @@ -747,28 +819,80 @@ export default function Dashboard() { />
-