diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 00000000..367ca152 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,40 @@ +name: Checks + +on: + pull_request: + branches: [dev, prod] + push: + branches: [dev] + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: "package-lock.json" + + - name: Pin npm + run: npm install --global npm@11.17.0 + + - name: Install workspace dependencies + run: npm ci + + - name: Build Client + run: npm run build:student + + - name: Build Admin + run: npm run build:admin + + - name: Check Server Runtime + run: npm run preflight diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fb40380c..873443f1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,55 +1,49 @@ name: Deploy to Production on: - push: - branches: - - prod + push: + branches: + - prod jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Install cloudflared - run: | - curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb - sudo dpkg -i cloudflared.deb - - - name: Setup SSH key - run: | - mkdir -p ~/.ssh - echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - cat >> ~/.ssh/config < ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + cat >> ~/.ssh/config <React 18 · Vite · Redux"] - Admin["Admin Portal
React 19 · Vite · Tailwind CSS"] - API["CourseHub API
Node.js · Express"] + Student["Student Web App
React 19 · Vite 8 · TanStack Query"] + Admin["Admin Portal
React 19 · Vite 8 · SCSS Modules"] + API["CourseHub API
Node 24 · Express 5 · Mongoose 9"] Auth["Identity
Microsoft OAuth · JWT"] DB["MongoDB
Users · Courses · Folders · Files"] Storage["Microsoft Graph · OneDrive
Original Files"] - Media["ImageKit
Permanent Thumbnails"] - Jobs["node-cron
Course Cache Sync"] + Media["ImageKit
Existing Thumbnails"] + Jobs["Background Workers
Saved Operation Plans"] + Academic["IITG Academic Portal
Course Registrations"] + Schedule["node-cron
Monthly Refresh"] Student --> API Admin --> API @@ -131,15 +135,16 @@ graph LR API --> DB API --> Storage API --> Media + API --> Jobs + Schedule --> Jobs Jobs --> DB Jobs --> Storage + Jobs --> Academic ```

React Vite - Redux - Tailwind CSS Node.js Express MongoDB @@ -148,20 +153,20 @@ graph LR ### Student Client -The student-facing application is built with React, Vite, Redux, React Router, and SCSS. +The student-facing application is built with React, Vite, TanStack Query, React Router, and SCSS. It provides the course dashboard, nested file browser, search, favourites, profile, and contribution workflows. -Course trees are cached in the browser to make repeated navigation faster and reduce redundant API requests. +Course trees use an actor-scoped query cache in memory. URLs select courses and folders, and revalidation keeps shared content current. ### Administration Portal -The separate administration portal uses React, Vite, and Tailwind CSS. +The separate administration portal uses React, Vite and SCSS modules. Both frontends share CourseHub typography, tokens and accessible controls through `@coursehub/ui`; their routes, sessions and deployment boundaries remain independent. It supports student and BR management, course dashboards, bulk course imports, course linking, contribution moderation, and course-cache synchronization. ### Backend The Node.js and Express API owns authentication, authorization, course and file metadata, contribution review, and administration workflows. MongoDB stores the application data, while Microsoft Graph and OneDrive provide file storage and delivery. -ImageKit stores permanent thumbnails instead of relying on expiring OneDrive preview URLs. +The API refreshes Graph thumbnails and continues serving existing ImageKit thumbnails after authorization. Course allotments are cached in MongoDB after they are resolved from IITG's academic data. A scheduled job synchronizes the shared course cache each month. @@ -179,80 +184,78 @@ This keeps the library useful without requiring the core team to organise every ```text coursehub/ -├── client/ # Student-facing React application -├── admin/ # Administration portal -├── server/ # Express API, jobs, integrations, and data models -├── docs/ # Architecture and implementation notes -└── deploy.sh # Production deployment helper +├── client/ # Student React app, browser requests and navigation state +├── admin/ # Administrator React app and management screens +├── server/ # API, models, permission services and workers +├── packages/ # Shared domain, browser/session code and React UI boundary +├── docs/ # Explanations, operating procedures and implementation guides +└── .github/workflows/ # Current CI and deployment workflows ``` -## Local Setup +`@coursehub/domain` supplies shared course-code, upload and CSV validation contracts. +`@coursehub/browser` supplies the common HTTP transport, session queries and course cache behavior. +`@coursehub/ui` supplies shared tokens, fonts, accessible controls and controlled dialogs. +See [Shared UI](docs/shared-ui.md) for component examples and the local gallery. -### Prerequisites +## Local Setup -- Node.js 22 or newer -- npm -- MongoDB -- A Microsoft Entra application and Microsoft Graph access -- A OneDrive folder for course material -- ImageKit credentials for permanent thumbnails +### 1. Prepare the Environment -### 1. Backend +Use Node.js 24, npm 11 and a reachable MongoDB instance. Microsoft sign-in needs an Entra application with its registered callback. Live file operations also need the configured OneDrive storage account. -The API must be running before either frontend can load live data. +From the repository root, create private environment files if they do not already exist: -```bash -cd server -cp .env.example .env -npm ci -npm run preflight -npm run dev +```sh +cp server/.env.example server/.env +cp client/.env.example client/.env +cp admin/.env.example admin/.env ``` -Configure the values documented in `server/.env.example`. +Edit the files before starting the apps. -### 2. Student Client +### 2. Install the Workspace -```bash -cd client -cp .env.example .env +Run this once from the repository root. It installs all application and shared-package dependencies using the root lockfile: + +```sh npm ci -npm run dev ``` -Set `VITE_API_BASE_URL` to the local API origin. +### 3. Start the API, Then the Frontends -### 3. Administration Portal +Use three terminals, each opened at the repository root: -```bash -cd admin -cp .env.example .env -npm ci -npm run dev +```sh +# Terminal 1: API and background workers +npm run preflight +npm run dev:server ``` -Set `VITE_API_BASE_URL` to the same API origin used by the student client. +```sh +# Terminal 2: student app +npm run dev:student -- --port 5173 --strictPort +``` -## Verification +```sh +# Terminal 3: administrator app +npm run dev:admin -- --port 5174 --strictPort +``` -Run the relevant checks before opening a pull request: +Open the student app at `http://localhost:5173` and the admin app at `http://localhost:5174/admin/`. +Provision an administrator with `npm --prefix server run admin` after configuring its credentials. -```bash -# Backend -cd server -npm run preflight -npm test +## Verification -# Student client -cd client -npm run build +Run these commands from the repository root: -# Administration portal -cd admin +```sh +npm run preflight npm run lint npm run build ``` +`npm run verify` runs all three checks in sequence. + ## Workflow - **`dev`** is the active development branch. Open feature and fix pull requests against it. @@ -260,9 +263,25 @@ npm run build ## Further Reading -- [Usage Guide](https://codingclub.in/blog/meet-coursehub-find-share-and-organise-course-material) -- [Course linking and shared-folder model](./docs/course_link_logic.md) -- [Frontend caching](./docs/frontend-caching.md) +Start with the guide that matches what you are trying to understand: + +| Guide | What it explains | +| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| [Runtime and Database Models](docs/runtime-and-models.md) | Request errors, shutdown, validated references and compatibility with existing data. | +| [Course Linking & Shared Folders](docs/course_link_logic.md) | Why folders are shared, how populated years are preserved, and how unlinking differs from file deletion. | +| [Shared Course Trees: Server Implementation](docs/shared-course-trees.md) | Reachable membership, linking API results, locks, recovery and tree limits. | +| [Data Sources and Maintenance](docs/data-sources.md) | What is scraped, what needs manual input, refresh triggers, and the semester checklist. | +| [Academic Synchronization](docs/academic-synchronization.md) | Current/history registrations, ordinary versus force refresh, empty data and failure behavior. | +| [Course and BR CSV Imports](docs/csv-imports.md) | Preview, shared parsing, row results and recoverable retries. | +| [Course References and Data Maintenance](docs/data-maintenance.md) | Renames, old bookmarks, inventory, staged imports and reviewed migration recovery. | +| [Authentication and Sessions](docs/authentication.md) | Student/admin login, permissions, cookies, CSRF and environment configuration. | +| [Storage, Uploads and Cleanup](docs/storage-operations.md) | File lifecycle, partial success, cancellation, authenticated delivery and recoverable deletion. | +| [Frontend Sessions](docs/frontend-sessions.md) | Session restoration, sign-in destinations, request errors and retries. | +| [Frontend Caching](docs/frontend-caching.md) | Query caches, URL selection, shared invalidation and freshness checks. | +| [Favourites and Sharing](docs/favourites-and-sharing.md) | Saving files, current paths, shared destinations, access checks and unavailable resources. | +| [Exam Schedules](docs/exam-schedules.md) | Course timetables, countdowns, missing dates, and semester rollover. | + +The [public usage guide](https://codingclub.in/blog/meet-coursehub-find-share-and-organise-course-material) provides a broader introduction to CourseHub. --- diff --git a/admin/.env.example b/admin/.env.example index 6b47c0cb..a78da3c9 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,2 +1,2 @@ # Public client-side env vars only (never put secrets in VITE_* values) -VITE_API_BASE_URL= +VITE_API_BASE_URL=http://localhost:8080 diff --git a/admin/components.json b/admin/components.json deleted file mode 100644 index ebf7e6ed..00000000 --- a/admin/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": false, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} \ No newline at end of file diff --git a/admin/eslint.config.js b/admin/eslint.config.js deleted file mode 100644 index cee1e2c7..00000000 --- a/admin/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs['recommended-latest'], - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, - }, - rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - }, - }, -]) diff --git a/admin/favicon.svg b/admin/favicon.svg new file mode 100644 index 00000000..d6c24616 --- /dev/null +++ b/admin/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/admin/index.html b/admin/index.html index dedfb038..b4ccc875 100644 --- a/admin/index.html +++ b/admin/index.html @@ -1,13 +1,16 @@ - + - - - - - CourseHub admin - - -

- - + + + + + + + CourseHub Administration + + + +
+ + diff --git a/admin/jsconfig.json b/admin/jsconfig.json index 78eb7441..c4550b6d 100644 --- a/admin/jsconfig.json +++ b/admin/jsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/admin/package.json b/admin/package.json index 5f17f73a..471e1234 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,46 +1,33 @@ { - "name": "admin", + "name": "@coursehub/admin", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "preview": "vite preview" }, "dependencies": { - "@radix-ui/react-slot": "^1.2.3", - "@tailwindcss/vite": "^4.1.10", - "axios": "^1.10.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "csv-parser": "^3.2.0", - "jwt-decode": "^4.0.0", - "lucide-react": "^0.536.0", - "papaparse": "^5.5.3", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-icons": "^5.5.0", - "react-router-dom": "^7.7.1", - "react-toastify": "^11.1.0", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.10" + "@coursehub/browser": "1.0.0", + "@coursehub/domain": "1.0.0", + "@coursehub/ui": "1.0.0", + "axios": "1.20.0", + "jwt-decode": "4.0.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "react-icons": "5.7.0", + "react-router-dom": "7.18.3", + "react-toastify": "11.1.0" }, "devDependencies": { - "@eslint/js": "^9.25.0", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react": "^4.4.1", - "autoprefixer": "^10.4.21", - "eslint": "^9.25.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "postcss": "^8.5.6", - "sass-embedded": "^1.90.0", - "tailwindcss": "^4.1.11", - "tw-animate-css": "^1.3.6", - "vite": "^6.3.5" + "@vitejs/plugin-react": "6.1.1", + "sass": "1.104.0", + "vite": "8.2.2" + }, + "engines": { + "node": ">=24 <25", + "npm": ">=11 <12" } } diff --git a/admin/src/App.jsx b/admin/src/App.jsx index a2fb2f5d..c4571932 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -1,86 +1,65 @@ -import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +import { BrowserRouter as Router, Outlet, Route, Routes, Link } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import RouteBoundary from "./router_utils/RouteBoundary"; +import PrivateRoute from "./router_utils/PrivateRoutes"; import Sidebar from "./components/Sidebar"; -import CoursesWithoutBR from "./pages/CoursesWithoutBR"; +import OperationNotice from "./components/OperationNotice"; import Students from "./pages/Students"; import Courses from "./pages/Courses"; import CourseLinking from "./pages/CourseLinking"; -import PrivateRoute from "./router_utils/PrivateRoutes"; -import Login from "./pages/Login"; -import { ToastContainer } from "react-toastify"; -import 'react-toastify/dist/ReactToastify.css'; import CourseDashboard from "./pages/CourseDashboard"; - -function App() { +import Operations from "./pages/Operations"; +import Login from "./pages/Login"; +import styles from "./shell.module.scss"; +function Shell() { + return ( +
+ +
+ + +
+
+ ); +} +export default function App() { return ( -
-
- -
- - + + Skip to content + + + + } /> + - + - }/> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + } + > + } /> + } /> + } /> + } /> + } /> + } /> -
- Page Not Found -
-
+
+

Page not found

+ Open Students +
} /> - - -
- + + + +
); } - -export default App; \ No newline at end of file diff --git a/admin/src/apis/auth.js b/admin/src/apis/auth.js index 33041c85..9da08dde 100644 --- a/admin/src/apis/auth.js +++ b/admin/src/apis/auth.js @@ -1,30 +1,22 @@ +import { apiFetch, setCsrfToken } from "./http"; import { API_BASE_URL } from "./server.js"; - +import { session } from "../session"; export async function adminLogin({ userId, password }) { - const res = await fetch(`${API_BASE_URL}api/admin/auth/login`, { + const res = await apiFetch(API_BASE_URL + "api/admin/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: "include", body: JSON.stringify({ userId, password }), }); - if (!res.ok) throw new Error("Login failed"); - return res.json(); + const data = await res.json(); + setCsrfToken(data.csrfToken); + await session.refresh(); + return data; } - export async function adminLogout() { - const res = await fetch(`${API_BASE_URL}api/admin/auth/logout`, { - method: "POST", - credentials: "include", - }); - if (!res.ok) throw new Error("Logout failed"); - return res.json(); -} - -export async function checkAdminSession() { - const res = await fetch(`${API_BASE_URL}api/admin/`, { - credentials: "include", - headers: { Authorization: "Bearer admin-coursehub-cc23-golang" }, - }); - // Even though server uses middleware isAdmin with JWT, some older routes may still accept header; cookie is primary. - return res.ok; + try { + await apiFetch(API_BASE_URL + "api/admin/auth/logout", { method: "POST" }); + } catch (error) { + if (error.status !== 401) throw error; + } + session.clear(); } diff --git a/admin/src/apis/br.js b/admin/src/apis/br.js index 156ffd44..287dae24 100644 --- a/admin/src/apis/br.js +++ b/admin/src/apis/br.js @@ -1,35 +1,10 @@ +import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; -// Fetch all branch representatives -export const fetchBRs = async () => { - try { - const response = await fetch(`${API_BASE_URL}api/br/allBRs`, { - credentials: "include", - }); - return await response.json(); - } catch (error) { - console.error("Error fetching BRs:", error); - throw error; - } -}; - -// Fetch all courses that don't have a branch representative -export const fetchCoursesWithoutBR = async () => { - try { - const response = await fetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { - credentials: "include", - }); - return await response.json(); - } catch (error) { - console.error("Error fetching courses without BR:", error); - throw error; - } -}; - // Create a single BR export const createBR = async (email) => { try { - const response = await fetch(`${API_BASE_URL}api/br/create`, { + const response = await apiFetch(`${API_BASE_URL}api/br/create`, { method: "POST", credentials: "include", headers: { @@ -40,10 +15,6 @@ export const createBR = async (email) => { const result = await response.json(); - if (!response.ok) { - throw new Error(result.error || result.message || "Failed to create BR"); - } - return result; } catch (error) { console.error("Error creating single BR:", error); @@ -51,69 +22,21 @@ export const createBR = async (email) => { } }; -// Parse CSV and upload BRs -export const uploadBRs = async (file) => { +// Delete BR +export const deleteBR = async (email) => { try { - // Parse CSV file - const csvText = await file.text(); - const lines = csvText.split("\n").filter((line) => line.trim() !== ""); - - // Skip header row and extract emails - const emails = []; - for (let i = 1; i < lines.length; i++) { - const email = lines[i].trim().replace(/"/g, ""); // Remove quotes if any - if (email && email.includes("@")) { - // Basic email validation - emails.push({ email: email }); - } - } - - if (emails.length === 0) { - throw new Error("No valid emails found in the CSV file"); - } - - // Call backend API - const response = await fetch(`${API_BASE_URL}api/br/updateList`, { - method: "POST", + const response = await apiFetch(`${API_BASE_URL}api/br/delete`, { + method: "DELETE", credentials: "include", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ emails }), + body: JSON.stringify({ email: email }), }); - const result = await response.json(); - - if (!response.ok) { - throw result; - } - return result; } catch (error) { - console.error("Error uploading BRs:", error); + console.error("Error deleting single BR:", error); throw error; } }; -// Delete BR -export const deleteBR = async (email) => { - try { - const response = await - fetch(`${API_BASE_URL}api/br/delete`,{ - method: "DELETE", - credentials: "include", - headers:{ - "Content-Type":"application/json", - Authorization:"Bearer admin-coursehub-cc23-golang"}, - body: JSON.stringify({email:email}), - }); - const result = await response.json(); - if(!response.ok){ - throw new Error(result.error || result.message || "Failed to delete BR"); - } - return result ; - } - catch (error){ - console.error("Error deleting single BR:" , error); - throw error; - } -}; diff --git a/admin/src/apis/courses.js b/admin/src/apis/courses.js index 2308f46c..13cecf11 100644 --- a/admin/src/apis/courses.js +++ b/admin/src/apis/courses.js @@ -1,248 +1,118 @@ +import { apiFetch } from "./http"; import { API_BASE_URL } from "./server.js"; +import { waitForOperation } from "./operations"; -// Fetch all courses -export const fetchCourses = async () => { - try { - const response = await fetch(`${API_BASE_URL}api/admin/dbcourses`, { - credentials: "include", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, - }); - return await response.json(); - } catch (error) { - console.error("Error fetching courses:", error); - throw error; - } -}; - +export async function fetchCourses( + { + q = "", + nameless = false, + duplicates = false, + withoutBR = false, + page = 1, + pageSize = 20, + } = {}, + signal, +) { + const params = new URLSearchParams({ + q, + nameless: String(nameless), + duplicates: String(duplicates), + withoutBR: String(withoutBR), + page: String(page), + pageSize: String(pageSize), + }); + const response = await apiFetch(API_BASE_URL + "api/admin/dbcourses?" + params, { signal }); + const data = await response.json(); + if ( + !Array.isArray(data.items) || + ![data.page, data.pageSize, data.total].every(Number.isSafeInteger) + ) + throw new Error("The course list could not be read. Please try again."); + return data; +} // Update course name export const updateCourseName = async (code, newName, newCode) => { try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}`, { method: "PATCH", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ name: newName, newCode }), credentials: "include", }); - return await response.json(); + const completed = await waitForOperation(await response.json()); + return completed.course || completed; } catch (error) { console.error("Error updating course name:", error); throw error; } }; -// Create a new course -export const createCourse = async (code, name) => { - try { - const response = await fetch(`${API_BASE_URL}api/course/create/${code}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", - }, - body: JSON.stringify({ name }), - credentials: "include", - }); - return await response.json(); - } catch (error) { - console.error("Error creating course:", error); - throw error; - } -}; - -// Bulk sync courses from CSV data -export const bulkSyncCourses = async (courses, analysis, onProgress = null) => { - const results = { - created: [], - updated: [], - skipped: [], - errors: [], - }; - - // Only process courses that need changes - const coursesToProcess = []; - - // Add missing courses (need to be created) - if (analysis.missingCourses && analysis.missingCourses.length > 0) { - coursesToProcess.push(...analysis.missingCourses); - } - - // Add courses with name mismatches (need to be updated) - if (analysis.nameConflicts && analysis.nameConflicts.length > 0) { - coursesToProcess.push( - ...analysis.nameConflicts.map((conflict) => ({ - code: conflict.code, - name: conflict.csvName, - })) - ); - } - - const total = coursesToProcess.length; - let processed = 0; - - if (total === 0) { - // No courses need processing - if (onProgress) { - onProgress(0, 0); - } - return results; - } - - for (const course of coursesToProcess) { - try { - // Check if this is a missing course (needs creation) or existing course (needs update) - const isMissingCourse = - analysis.missingCourses && - analysis.missingCourses.some((c) => c.code === course.code); - - if (isMissingCourse) { - // Create new course - const createResponse = await createCourse(course.code, course.name); - if (createResponse.message === "Course created successfully") { - results.created.push(course); - } else { - results.errors.push({ course, error: "Failed to create course" }); - } - } else { - // Update existing course name - const updateResponse = await updateCourseName(course.code, course.name); - if (updateResponse && updateResponse.code) { - results.updated.push(course); - } else { - results.errors.push({ course, error: "Failed to update course name" }); - } - } - } catch (error) { - results.errors.push({ course, error: error.message }); - } - - processed++; - if (onProgress) { - onProgress(processed, total); - } - } - - return results; -}; - -export const linkLegacyCourse = async (targetCode, legacyCode) => { - try { - const safeCode = targetCode.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/link`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", - }, - body: JSON.stringify({ legacyCode }), - credentials: "include", - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || "Failed to link legacy course"); - } - - return await response.json(); - } catch (error) { - console.error("Error linking legacy course:", error); - throw error; - } -}; - // Delete a course export const deleteCourse = async (code) => { try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/delete`, { + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/delete`, { method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, credentials: "include", }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || "Failed to delete course"); - } - - return await response.json(); + return await waitForOperation(await response.json()); } catch (error) { console.error("Error deleting course:", error); throw error; } }; -export const fetchCourseDashboardData = async(code)=> -{ - try - { +export const fetchCourseDashboardData = async (code, signal) => { + try { const safeCode = code.toLowerCase().trim(); - const response = await fetch(`${API_BASE_URL}api/admin/course/${safeCode}/dashboard`, { - headers: {Authorization: "Bearer admin-coursehub-cc23-golang"}, + const response = await apiFetch(`${API_BASE_URL}api/admin/course/${safeCode}/dashboard`, { + signal, credentials: "include", }); - - if (!response.ok) { - throw new Error("Failed to get dashboard data"); - } return await response.json(); } catch (error) { console.error("Error fetching dashboard:", error); throw error; } -} +}; -export const handleContribution = async (contributionId, action) => { - try - { - const response = await fetch(`${API_BASE_URL}api/admin/contribution/action`, { +export const handleContribution = async (contributionId, action, courseCode, affectedCourses) => { + try { + const response = await apiFetch(`${API_BASE_URL}api/admin/contribution/action`, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, - body: JSON.stringify({ contributionId, action}), + body: JSON.stringify({ contributionId, action, courseCode, affectedCourses }), credentials: "include", }); - if (!response.ok) - { - throw new Error(`Failed to ${action} contribution`); - } return await response.json(); } catch (error) { + console.error("Error handling contribution:", error); throw error; } }; -export const deleteNode = async(type,id) => -{ - try - { - const response = await fetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { - method : "DELETE", +export const deleteNode = async (type, id, courseCode, affectedCourses) => { + try { + const response = await apiFetch(`${API_BASE_URL}api/admin/node/${type}/${id}`, { + method: "DELETE", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", - }, - credentials : "include", + }, + body: JSON.stringify({ courseCode, affectedCourses }), + credentials: "include", }); - if(!response.ok) - { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to delete Item"); - } - } - catch(error) - { + return await response.json(); + } catch (error) { + console.error("Error deleting node:", error); throw error; } -} - +}; diff --git a/admin/src/apis/http.js b/admin/src/apis/http.js new file mode 100644 index 00000000..1d91b8f5 --- /dev/null +++ b/admin/src/apis/http.js @@ -0,0 +1,5 @@ +import { createTransport } from "@coursehub/browser"; +import { API_BASE_URL } from "./server"; +export const transport = createTransport({ baseUrl: API_BASE_URL + "api/", role: "admin" }); +export const apiFetch = transport.request; +export const { getCsrfToken, setCsrfToken, clearCsrfToken } = transport; diff --git a/admin/src/apis/imports.js b/admin/src/apis/imports.js new file mode 100644 index 00000000..147c5510 --- /dev/null +++ b/admin/src/apis/imports.js @@ -0,0 +1,14 @@ +import { apiFetch } from "./http"; +import { API_BASE_URL } from "./server"; +const request = async (path, body, signal) => { + const response = await apiFetch(API_BASE_URL + "api/admin/imports" + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + return response.json(); +}; +export const previewImport = (type, rows, signal) => request("/preview", { type, rows }, signal); +export const submitImport = (type, rows, previewDigest, requestId) => + request("/", { type, rows, previewDigest, requestId }); diff --git a/admin/src/apis/linking.js b/admin/src/apis/linking.js new file mode 100644 index 00000000..55520082 --- /dev/null +++ b/admin/src/apis/linking.js @@ -0,0 +1,18 @@ +import { apiFetch } from "./http"; +import { API_BASE_URL } from "./server"; +export async function linkCourse(legacyCode, code) { + return ( + await apiFetch(`${API_BASE_URL}api/admin/course/${encodeURIComponent(code)}/link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ legacyCode }), + }) + ).json(); +} +export async function linkCsv(file) { + const body = new FormData(); + body.append("file", file); + return ( + await apiFetch(`${API_BASE_URL}api/admin/courses/bulk-link`, { method: "POST", body }) + ).json(); +} diff --git a/admin/src/apis/operations.js b/admin/src/apis/operations.js new file mode 100644 index 00000000..e3b09c9e --- /dev/null +++ b/admin/src/apis/operations.js @@ -0,0 +1,31 @@ +import { apiFetch } from "./http"; +import { API_BASE_URL } from "./server"; + +async function request(path = "", options) { + const response = await apiFetch(`${API_BASE_URL}api/operations${path}`, options); + return response.json(); +} +export const operationEvent = "coursehub-operation"; +export const getOperation = (id, signal) => request(`/${id}`, { signal }); +export const listOperations = (page, status, signal) => + request(`?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { signal }); +export const retryOperation = (id) => request(`/${id}/retry`, { method: "POST" }); +export async function waitForOperation(accepted) { + if (!accepted?.operationId) return accepted; + window.dispatchEvent( + new CustomEvent(operationEvent, { + detail: { kind: accepted.kind || "delete", status: "queued" }, + }), + ); + for (;;) { + const operation = await getOperation(accepted.operationId); + window.dispatchEvent(new CustomEvent(operationEvent, { detail: operation })); + if (operation.status === "completed") return operation; + if (["failed", "cancelled"].includes(operation.status)) + throw new Error( + operation.error?.message || + "The operation could not finish. Open Operations to review and retry.", + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} diff --git a/admin/src/apis/student.js b/admin/src/apis/student.js index f4f5f750..b0eb5304 100644 --- a/admin/src/apis/student.js +++ b/admin/src/apis/student.js @@ -1,49 +1,47 @@ +import { apiFetch } from "./http"; +import { waitForOperation } from "./operations"; import { API_BASE_URL } from "./server.js"; -// Fetch all students sorted by rollNumber descending. -// Pass brOnly=true to fetch only Branch Representatives. -export const fetchStudents = async (brOnly = false) => { - try { - const url = brOnly - ? `${API_BASE_URL}api/br/allBRs` - : `${API_BASE_URL}api/student/all`; - const response = await fetch(url, {credentials:"include"}); - const data = await response.json(); - return { students : data.students || data.brs || []}; } - catch (error) { - console.error("Error fetching students :" , error ); - throw error ; - } -}; +export async function fetchStudents( + { q = "", isBR = false, page = 1, pageSize = 20 } = {}, + signal, +) { + const params = new URLSearchParams({ + q, + isBR: String(isBR), + page: String(page), + pageSize: String(pageSize), + }); + const response = await apiFetch(API_BASE_URL + "api/student/all?" + params, { signal }); + const data = await response.json(); + if ( + !Array.isArray(data.items) || + !Number.isSafeInteger(data.total) || + !Number.isSafeInteger(data.page) || + !Number.isSafeInteger(data.pageSize) + ) + throw new Error("The student list could not be read. Please try again."); + return data; +} +export async function fetchStudentDetails(id, signal) { + const response = await apiFetch(API_BASE_URL + "api/student/" + encodeURIComponent(id), { + signal, + }); + const data = await response.json(); + if (!data.item?._id) throw new Error("Student details could not be read. Please try again."); + return data; +} -// Search students by name or roll number. -// Pass brOnly=true to restrict results to Branch Representatives. -export const searchStudents = async (query, brOnly = false) => { - try { - const params = new URLSearchParams({ q: query }); - if (brOnly) params.set("isBR", "true"); - const response = await fetch( - `${API_BASE_URL}api/student/search?${params.toString()}`, - { credentials: "include" } - ); - return await response.json(); - } catch (error) { - console.error("Error searching students:", error); - throw error; - } -}; - -// Refresh a single student's courses by deleting their UserUpdate record. -// Courses will be re-fetched when the student next logs in. +// Refresh from upstream and wait for persistence export const refreshStudentCourses = async (id) => { try { - const response = await fetch(`${API_BASE_URL}api/student/refresh/${id}`, { + const response = await apiFetch(`${API_BASE_URL}api/student/refresh/${id}`, { method: "PUT", credentials: "include", }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Failed to refresh courses"); - return result; + const operation = await waitForOperation(result); + return operation.synchronization || operation; } catch (error) { console.error("Error refreshing student courses:", error); throw error; @@ -53,12 +51,11 @@ export const refreshStudentCourses = async (id) => { // Delete a single student permanently. export const deleteStudent = async (id) => { try { - const response = await fetch(`${API_BASE_URL}api/student/${id}`, { + const response = await apiFetch(`${API_BASE_URL}api/student/${id}`, { method: "DELETE", credentials: "include", }); const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Failed to delete student"); return result; } catch (error) { console.error("Error deleting student:", error); @@ -66,18 +63,10 @@ export const deleteStudent = async (id) => { } }; -// Semester reset — deletes all UserUpdate records and clears courses for every student. -export const semesterReset = async () => { - try { - const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { - method: "POST", - credentials: "include", - }); - const result = await response.json(); - if (!response.ok) throw new Error(result.error || result.message || "Semester reset failed"); - return result; - } catch (error) { - console.error("Error during semester reset:", error); - throw error; - } +export const refreshAllStudentCourses = async () => { + const response = await apiFetch(`${API_BASE_URL}api/admin/sync-courses-cache`, { + method: "POST", + }); + const operation = await waitForOperation(await response.json()); + return operation.synchronization || operation; }; diff --git a/admin/src/components/AddBRs.jsx b/admin/src/components/AddBRs.jsx index 246f2ffa..17c2084b 100644 --- a/admin/src/components/AddBRs.jsx +++ b/admin/src/components/AddBRs.jsx @@ -1,174 +1,86 @@ -import React, { useState } from "react"; -import { Modal, ModalHeader, ModalBody, ModalCloseButton } from "./ui/modal"; -import { uploadBRs, createBR } from "../apis/br"; -import { FaUser, FaFileCsv } from "react-icons/fa"; - -const AddBRs = ({ onSuccess, onClose }) => { - const [tab, setTab] = useState("single"); - - const [email, setEmail] = useState(""); - const [singleLoading, setSingleLoading] = useState(false); - const [singleError, setSingleError] = useState(null); - const [singleSuccess, setSingleSuccess] = useState(null); - - const [file, setFile] = useState(null); - const [bulkLoading, setBulkLoading] = useState(false); - const [bulkError, setBulkError] = useState(null); - const [bulkSuccess, setBulkSuccess] = useState(null); - const [bulkWarnings, setBulkWarnings] = useState(null); - - const handleSingleSubmit = async (e) => { - e.preventDefault(); - setSingleError(null); - setSingleSuccess(null); - if (!email.trim()) { setSingleError("Please enter an email address."); return; } - if (!email.includes("@")) { setSingleError("Please enter a valid email address."); return; } - setSingleLoading(true); - try { - await createBR(email.trim()); - setSingleSuccess(`Successfully added ${email.trim()} as a BR.`); - setEmail(""); - if (onSuccess) onSuccess(); - } catch (err) { - setSingleError(err.message || "Failed to add BR."); - } finally { - setSingleLoading(false); +import { useId, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { Button, Dialog, FormField } from "@coursehub/ui"; +import { isEmail, normalizeEmail } from "@coursehub/domain"; +import { createBR } from "../apis/br"; +import ImportDialog from "./imports/ImportDialog"; +import styles from "@/styles/layout.module.scss"; +export default function AddBRs({ onSuccess, onClose }) { + const location = useLocation(); + const [bulk, setBulk] = useState(() => new URLSearchParams(location.search).has("import")); + const [email, setEmail] = useState(""), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [success, setSuccess] = useState(""); + const form = useId(); + const submit = async (event) => { + event.preventDefault(); + if (busy) return; + setError(""); + setSuccess(""); + const value = normalizeEmail(email); + if (!isEmail(value)) { + setError("Enter a valid email address."); + return; } - }; - - const handleBulkSubmit = async (e) => { - e.preventDefault(); - setBulkError(null); - setBulkSuccess(null); - setBulkWarnings(null); - if (!file) { setBulkError("Please select a CSV file."); return; } - if (!file.name.toLowerCase().endsWith(".csv")) { setBulkError("Please upload a CSV file."); return; } - setBulkLoading(true); + setBusy(true); try { - const response = await uploadBRs(file); - setBulkSuccess(response.message); - setFile(null); - if (onSuccess) onSuccess(); - } catch (err) { - if (err.existingEmails || err.notInUsers) { - let warning = ""; - if (err.existingEmails?.length > 0) - warning += `Already existing BRs: ${err.existingEmails.join(", ")}. `; - if (err.notInUsers?.length > 0) - warning += `Not found in users: ${err.notInUsers.join(", ")}.`; - setBulkWarnings(warning); - setBulkSuccess("Partial upload completed."); - if (onSuccess) onSuccess(); - } else { - setBulkError(err.error || err.message || "An error occurred during upload."); - } + const result = await createBR(value); + setSuccess( + `BR access saved for ${result.br?.email || value}.${result.synchronization ? " Course synchronization is scheduled; review it in Operations." : ""}`, + ); + setEmail(""); + onSuccess?.(); + } catch (failure) { + setError(failure.message || "BR access could not be saved."); } finally { - setBulkLoading(false); + setBusy(false); } }; - + if (bulk) return ; return ( - - - - Add Branch Representatives - - -
- - + + + } + > +
+ + +
- - - {tab === "single" && ( -
-

- Add a single user as a Branch Representative using their registered email. -

-
-
- - { setEmail(e.target.value); setSingleError(null); setSingleSuccess(null); }} - disabled={singleLoading} - className="w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all disabled:opacity-50" - /> -
- {singleError &&

{singleError}

} - {singleSuccess &&

{singleSuccess}

} - -
-
- )} - - {tab === "bulk" && ( -
-

- Upload a CSV file with one column named{" "} - email{" "} - to add multiple BRs at once. -

-
-
- - { setFile(e.target.files[0]); setBulkError(null); setBulkSuccess(null); setBulkWarnings(null); }} - disabled={bulkLoading} - className="block w-full text-sm text-gray-500 border border-gray-200 rounded-md cursor-pointer bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-400 file:mr-4 file:py-2 file:px-3 file:border-0 file:border-r file:border-gray-200 file:text-sm file:font-medium file:bg-white file:text-blue-700 hover:file:bg-blue-50 transition-all disabled:opacity-50" - /> - {file &&

Selected: {file.name}

} -
- {bulkError &&

{bulkError}

} - {bulkSuccess &&

{bulkSuccess}

} - {bulkWarnings &&

Warning: {bulkWarnings}

} - -
-
+
+ + setEmail(event.target.value)} + disabled={busy} + /> + + {success && ( +

+ {success} +

)} - - +
+ ); -}; - -export default AddBRs; +} diff --git a/admin/src/components/CoursesWithoutBRTable.jsx b/admin/src/components/CoursesWithoutBRTable.jsx deleted file mode 100644 index 3ad15adb..00000000 --- a/admin/src/components/CoursesWithoutBRTable.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; - -const CoursesWithoutBRTable = ({ courses }) => { - return ( -
-
-

Courses Without BR

-
- {courses.length === 0 ? ( -
No courses found.
- ) : ( -
- - - - - - - - - {courses.map((course) => ( - - - - - ))} - -
- Code - - Name -
- {course.code} - {course.name}
-
- )} -
- ); -}; - -export default CoursesWithoutBRTable; diff --git a/admin/src/components/LinkingResult.jsx b/admin/src/components/LinkingResult.jsx new file mode 100644 index 00000000..45b45072 --- /dev/null +++ b/admin/src/components/LinkingResult.jsx @@ -0,0 +1,36 @@ +import styles from "@/styles/layout.module.scss"; +export default function LinkingResult({ result, completed = true }) { + if (!result) return null; + return ( +
+

+ {result.sourceCode} → {result.targetCode} +

+

+ {completed ? "Linked" : "Planned"}: {result.linked.length}{" "} + {result.linked.length === 1 ? "year" : "years"}. Already linked:{" "} + {result.alreadyLinked.length}. Empty replacements: {result.replaced.length}. +

+ {result.conflicts.length > 0 && ( +
+

+ {result.conflicts.length} year{" "} + {result.conflicts.length === 1 ? "conflict" : "conflicts"} - content + preserved +

+
    + {result.conflicts.map((conflict, index) => ( +
  • + {conflict.year}: {conflict.reason} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/admin/src/components/ListControls.jsx b/admin/src/components/ListControls.jsx new file mode 100644 index 00000000..d659aefb --- /dev/null +++ b/admin/src/components/ListControls.jsx @@ -0,0 +1,22 @@ +import { Button, Icon } from "@coursehub/ui"; + +export function FilterButton({ active, ...props }) { + return + ); +} + +export function ResetFiltersButton(props) { + return ( + + Reset filters + + ); +} diff --git a/admin/src/components/OperationNotice.jsx b/admin/src/components/OperationNotice.jsx new file mode 100644 index 00000000..2f40b913 --- /dev/null +++ b/admin/src/components/OperationNotice.jsx @@ -0,0 +1,36 @@ +import styles from "@/styles/layout.module.scss"; +import { useEffect, useState } from "react"; +import { operationEvent } from "@/apis/operations"; + +export default function OperationNotice() { + const [operation, setOperation] = useState(null); + useEffect(() => { + const update = (event) => setOperation(event.detail || { status: "queued" }); + window.addEventListener(operationEvent, update); + return () => window.removeEventListener(operationEvent, update); + }, []); + const activity = + operation?.kind === "academic-sync" + ? "Course refresh" + : operation?.kind === "rename" + ? "Course update" + : operation?.kind === "link" + ? "Linking" + : operation?.kind === "upload" + ? "Upload" + : "Cleanup"; + if (!operation) return null; + return ( +
+ {operation && ( + + {operation.status === "completed" + ? `${activity} completed.` + : operation.status === "failed" + ? `${activity} needs attention.` + : `${activity} is in progress. You can leave this page.`} + + )} +
+ ); +} diff --git a/admin/src/components/Pagination.jsx b/admin/src/components/Pagination.jsx new file mode 100644 index 00000000..8a8da37d --- /dev/null +++ b/admin/src/components/Pagination.jsx @@ -0,0 +1,36 @@ +import { Button, FormField } from "@coursehub/ui"; +import styles from "@/styles/layout.module.scss"; +export default function Pagination({ page, pageSize, total, loading, onPage, onPageSize }) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + return ( + + ); +} diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 40b06645..76595bcb 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,86 +1,109 @@ -import React from "react"; -import { Link, useLocation } from "react-router-dom"; -import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle } from "react-icons/fa"; +import { useEffect, useRef, useState } from "react"; +import { Link, NavLink, useLocation } from "react-router-dom"; +import { Brand, Button, Dialog, IconButton } from "@coursehub/ui"; +import { FaBook, FaLayerGroup, FaLink, FaUserGraduate, FaBars } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; - -const navItems = [ - { label: "Students", to: "/admin/students", icon: FaUserGraduate }, - { label: "Courses", to: "/admin/courses", icon: FaBook }, - { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, - { label: "Courses Without BR", to: "/admin/courses-without-br", icon: FaExclamationTriangle }, +import styles from "./Sidebar.module.scss"; +const items = [ + ["Operations", "/admin/operations", FaLayerGroup], + ["Students", "/admin/students", FaUserGraduate], + ["Courses", "/admin/courses", FaBook], + ["Course Linking", "/admin/course-linking", FaLink], ]; - -const Sidebar = () => { +function Navigation({ onNavigate }) { const location = useLocation(); - - const handleLogout = async () => { + return ( + + ); +} +export default function Sidebar() { + const [open, setOpen] = useState(false), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + trigger = useRef(null); + useEffect(() => { + const media = window.matchMedia("(min-width:1025px)"); + const resize = () => { + if (media.matches) setOpen(false); + }; + media.addEventListener("change", resize); + return () => media.removeEventListener("change", resize); + }, []); + const logout = async () => { + if (busy) return; + setBusy(true); + setError(""); try { await adminLogout(); - } catch (err) { - console.error("Logout failed", err); + window.location.href = "/admin/login"; + } catch { + setError("Could not log out. Please try again."); + } finally { + setBusy(false); } - window.location.href = "/admin/login"; }; - + const footer = ( +
+ {error &&

{error}

} + +
+ ); return ( - + + + + + setOpen(false)} /> + + ); -}; - -export default Sidebar; +} diff --git a/admin/src/components/Sidebar.module.scss b/admin/src/components/Sidebar.module.scss new file mode 100644 index 00000000..c86ab1b8 --- /dev/null +++ b/admin/src/components/Sidebar.module.scss @@ -0,0 +1,126 @@ +@use "@coursehub/ui/mixins" as ui; +.sidebar { + position: sticky; + top: 0; + width: 272px; + flex-shrink: 0; + height: 100dvh; + display: flex; + flex-direction: column; + background: var(--ch-black); + color: white; +} +.brand { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1.5rem 1.25rem; + justify-content: center; + min-height: var(--ch-target); + color: inherit; + text-decoration: none; + small { + font-size: 0.875rem; + color: var(--ch-yellow); + } + @include ui.focus-ring; +} +.links { + @include ui.stack(0.5rem); + padding: 1rem; + flex: 1; + overflow-y: auto; + a { + @include ui.control; + @include ui.control-motion; + @include ui.focus-ring-inverse; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--ch-radius-control); + text-decoration: none; + color: inherit; + font-weight: 700; + svg { + flex-shrink: 0; + width: 1rem; + height: 1rem; + } + &:hover { + @include ui.tinted-hover; + } + &[aria-current], + &.active { + background: var(--ch-yellow); + color: var(--ch-ink); + } + &[aria-current]:hover, + &.active:hover { + background: var(--ch-yellow-hover); + } + } +} +.footer { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + p { + font-size: 0.875rem; + color: var(--ch-yellow); + } + button { + width: 100%; + } +} +.mobile { + display: none; +} +.drawer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: min(90vw, 320px); + height: 100dvh; + max-height: 100dvh; + margin: 0; + border-radius: 0; + background: var(--ch-black); + color: white; + > header { + background: var(--ch-yellow); + color: var(--ch-ink); + } + > footer { + border-color: #444; + } +} +.drawerBody { + padding: 0; +} +@include ui.below(tablet) { + .sidebar { + display: none; + } + .mobile { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: var(--ch-black); + color: white; + padding: 0.75rem 1rem; + .brand { + padding: 0; + } + } +} +.drawerBody { + flex: 1; +} +.drawer > footer { + display: block; + padding: 0; +} diff --git a/admin/src/components/brTable.jsx b/admin/src/components/brTable.jsx deleted file mode 100644 index d1b2dcb7..00000000 --- a/admin/src/components/brTable.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; - -const BrTable = ({ brs , onDelete }) => { - return ( -
-
-

BR Table

-
-
- - - - - - - - - - - - - {brs.map((person) => ( - - - - - - - - - - ))} - -
- Name - - Email - - Degree - - Department - - Semester - - Actions -
- {person.name} - {person.email}{person.degree} - {person.department} - - {person.semester} - - -
-
-
- ); -}; - -export default BrTable; diff --git a/admin/src/components/imports/ImportDialog.jsx b/admin/src/components/imports/ImportDialog.jsx new file mode 100644 index 00000000..c582a8d9 --- /dev/null +++ b/admin/src/components/imports/ImportDialog.jsx @@ -0,0 +1,243 @@ +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useQuery } from "@coursehub/browser"; +import { Button, Dialog, FormField, LoadingState, ErrorState } from "@coursehub/ui"; +import { parseImportCsv, importLimits } from "@coursehub/domain"; +import { previewImport, submitImport } from "../../apis/imports"; +import { getOperation, retryOperation } from "../../apis/operations"; +import { library } from "../../session"; +import ImportResults from "./ImportResults"; +import styles from "./styles.module.scss"; +const active = ["planning", "queued", "running"]; +export default function ImportDialog({ type, onClose, onSuccess }) { + const location = useLocation(), + navigate = useNavigate(), + id = new URLSearchParams(location.search).get("import"); + const [rows, setRows] = useState([]), + [preview, setPreview] = useState(null), + [errors, setErrors] = useState([]), + [busy, setBusy] = useState(false), + [reading, setReading] = useState(false); + const request = useRef(null), + sequence = useRef(0), + reported = useRef(""); + const query = useQuery({ + queryKey: [...library.key("import"), { id, type }], + enabled: !!id, + queryFn: async ({ signal }) => { + const operation = await getOperation(id, signal); + if (operation.kind !== "import" || operation.import?.type !== type) + throw new Error("This is not the requested import. Open Operations to review it."); + return operation; + }, + retry: false, + refetchInterval: (query) => (active.includes(query.state.data?.status) ? 1000 : false), + }); + useEffect( + () => () => { + sequence.current++; + request.current?.abort(); + }, + [], + ); + const operation = query.data; + useEffect(() => { + if (!operation || active.includes(operation.status)) return; + const revision = operation.id + operation.updatedAt + operation.status; + if (reported.current === revision) return; + reported.current = revision; + onSuccess?.( + `Import ${operation.status === "completed" ? "completed" : "needs attention"}. Successful rows are retained.`, + ); + }, [operation, onSuccess]); + const setOperation = (id) => { + const params = new URLSearchParams(location.search); + if (id) params.set("import", id); + else params.delete("import"); + navigate( + { pathname: location.pathname, search: params.toString(), hash: location.hash }, + { replace: true }, + ); + }; + const close = () => { + request.current?.abort(); + sequence.current++; + setOperation(null); + onClose(); + }; + const choose = async (event) => { + const file = event.target.files?.[0]; + const version = ++sequence.current; + request.current?.abort(); + request.current = new AbortController(); + setRows([]); + setPreview(null); + setErrors([]); + if (!file) return; + setReading(true); + try { + if (file.size > importLimits.fileBytes) + throw new Error("Choose a CSV file no larger than 1 MiB."); + const parsed = parseImportCsv(await file.text(), type); + if (sequence.current !== version) return; + if (parsed.errors.length) { + setErrors(parsed.errors.map((error) => `Row ${error.row}: ${error.message}`)); + return; + } + setRows(parsed.rows); + const result = await previewImport(type, parsed.rows, request.current.signal); + if (sequence.current === version) setPreview(result); + } catch (error) { + if (sequence.current === version && !request.current.signal.aborted) + setErrors([error.message]); + } finally { + if (sequence.current === version) setReading(false); + } + }; + const retryPreview = async () => { + const version = ++sequence.current; + request.current?.abort(); + request.current = new AbortController(); + setReading(true); + setErrors([]); + try { + const result = await previewImport(type, rows, request.current.signal); + if (sequence.current === version) setPreview(result); + } catch (error) { + if (sequence.current === version && !request.current.signal.aborted) + setErrors([error.message]); + } finally { + if (sequence.current === version) setReading(false); + } + }; + const submit = async () => { + if (busy || !preview) return; + setBusy(true); + setErrors([]); + try { + const accepted = await submitImport(type, rows, preview.digest, preview.requestId); + setOperation(accepted.operationId); + } catch (error) { + if (error.code === "IMPORT_PREVIEW_CHANGED") setPreview(null); + setErrors([error.message]); + } finally { + setBusy(false); + } + }; + const retry = async () => { + setBusy(true); + setErrors([]); + try { + await retryOperation(id); + await query.refetch(); + } catch (error) { + setErrors([error.message]); + } finally { + setBusy(false); + } + }; + return ( + { + if (!open) close(); + }} + title={type === "courses" ? "Import courses" : "Import BR assignments"} + description={ + id + ? "Progress is saved. You can close this dialog and return through Operations." + : `Choose a CSV with ${type === "courses" ? "code and name" : "email"} column headers. Up to 1000 rows and 1 MiB.` + } + busy={busy} + footer={ + <> + + {id ? ( + <> + {operation?.canRetry && ( + + )} + + ) : ( + + )} + + } + > + {!id && ( +
+ + + + {reading && } + {!reading && rows.length > 0 && !preview && errors.length > 0 && ( + + )} + {preview && ( + <> +

+ {preview.rows.length} rows reviewed. Matching data and repeated + identical rows will be skipped. +

+ + + )} +
+ )} + {id && query.isPending && } + {id && query.isError && ( + query.refetch()} + /> + )} + {operation && ( + <> +

+ {active.includes(operation.status) + ? "Import in progress" + : operation.status === "completed" + ? "Import completed" + : "Some rows need attention"} +

+ + {type === "brs" && ( +

+ BR results confirm saved assignments. Registration synchronization runs + separately in Operations. +

+ )} + + )} + {errors.length > 0 && ( +
+

Import could not proceed.

+
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+ )} +
+ ); +} diff --git a/admin/src/components/imports/ImportResults.jsx b/admin/src/components/imports/ImportResults.jsx new file mode 100644 index 00000000..916f18aa --- /dev/null +++ b/admin/src/components/imports/ImportResults.jsx @@ -0,0 +1,58 @@ +import { Badge } from "@coursehub/ui"; +import styles from "./styles.module.scss"; +const tone = (state) => + ["failed", "conflict"].includes(state) + ? "danger" + : ["created", "updated"].includes(state) + ? "success" + : "neutral"; +export default function ImportResults({ rows, counts, total, finished, preview = false }) { + return ( +
+ {counts && ( + <> +

+ Created {counts.created} · Updated {counts.updated} · Skipped{" "} + {counts.skipped} · Failed {counts.failed} +

+ +

+ {finished} of {total} rows finished +

+ + )} +
    + {rows.map((row, index) => ( +
  • +
    + {row.code || row.email} + + {row.state || row.action} + +
    + {row.name &&

    {row.name}

    } + {preview && row.previousName && row.previousName !== row.name && ( +

    Previously: {row.previousName}

    + )} + Row {row.row} + {(row.error?.message || row.message) && ( +

    + {row.error?.message || row.message} +

    + )} +
  • + ))} +
+
+ ); +} diff --git a/admin/src/components/imports/styles.module.scss b/admin/src/components/imports/styles.module.scss new file mode 100644 index 00000000..36101ee6 --- /dev/null +++ b/admin/src/components/imports/styles.module.scss @@ -0,0 +1,64 @@ +@use "@coursehub/ui/mixins" as ui; +.input, +.results { + @include ui.stack(1rem); + p { + margin: 0; + } +} +.results progress { + width: 100%; + accent-color: var(--ch-ink); + height: 0.75rem; +} +.rows { + @include ui.focus-ring; + list-style: none; + margin: 0; + padding: 0; + max-height: 22rem; + overflow: auto; + overscroll-behavior: contain; + border: 1px solid var(--ch-border); + border-radius: var(--ch-radius-control); + li { + padding: 1rem; + overflow-wrap: anywhere; + & + li { + border-top: 1px solid var(--ch-border); + } + p { + margin: 0.5rem 0; + } + small { + color: var(--ch-muted); + } + } +} +.rowHeader { + display: flex; + align-items: flex-start; + gap: 1rem; + justify-content: space-between; + strong { + min-width: 0; + } +} +.name { + white-space: pre-wrap; +} +.error { + color: var(--ch-danger); + overflow-wrap: anywhere; + ul { + padding-left: 1.25rem; + max-height: 16rem; + overflow: auto; + } +} +.body { + @include ui.stack(1rem); + > p { + margin: 0; + } +} diff --git a/admin/src/components/operations/OperationCard.jsx b/admin/src/components/operations/OperationCard.jsx new file mode 100644 index 00000000..cb26975f --- /dev/null +++ b/admin/src/components/operations/OperationCard.jsx @@ -0,0 +1,141 @@ +import workflow from "../../styles/workflows.module.scss"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Badge, Button } from "@coursehub/ui"; +import { retryOperation } from "../../apis/operations"; +import ImportResults from "../imports/ImportResults"; +import LinkingResult from "../LinkingResult"; +import styles from "../../styles/layout.module.scss"; +export default function OperationCard({ item, onRetried }) { + const [busy, setBusy] = useState(false), + [error, setError] = useState(null); + const retry = async () => { + setBusy(true); + setError(null); + try { + const result = await retryOperation(item.id); + await onRetried?.(result); + } catch (failure) { + setError(failure); + } finally { + setBusy(false); + } + }; + const entries = item.entries || [], + steps = item.completedSteps || 0; + const label = item.batchLinking + ? "Scheduling" + : { + "academic-sync": "Course refresh", + rename: "Course update", + link: "Linking", + delete: "Cleanup", + }[item.kind]; + return ( +
+
+

+ {item.name} + {item.courseCode && · {item.courseCode}} +

+ + {item.status} + +
+ {label && ( +

+ {item.status === "completed" + ? `${label} completed` + : ["queued", "running", "planning"].includes(item.status) + ? `${label} is in progress. You can leave this page.` + : `${label} needs attention.`} +

+ )} +

+ {item.kind === "import" + ? `CSV import · ${item.import?.finished || 0} of ${item.import?.total || 0} rows finished` + : label + ? `${label} · ${steps} ${steps === 1 ? "step" : "steps"} completed` + : `${entries.filter((entry) => entry.state === "completed").length} of ${entries.length} files uploaded`} +

+ {item.kind === "import" && item.import && ( + <> + + + Open import + + + )} + {item.batchLinking && ( + <> +

+ {item.batchLinking.scheduled} link jobs scheduled ·{" "} + {item.batchLinking.failed} could not be scheduled. Each job has its own + completion status. +

+ + Open bulk linking + + + )} + {item.kind === "link" && ( + + )} + {item.kind === "academic-sync" && item.synchronization && ( +

+ {item.status === "completed" ? "Updated" : "Planned"}:{" "} + {item.synchronization.students ?? 0} students ·{" "} + {item.synchronization.allotments ?? 0} academic allotments ·{" "} + {item.synchronization.createdCourses ?? 0} new courses. +

+ )} + {item.affectedCourses?.length > 1 && ( +

Affected courses: {item.affectedCourses.join(", ")}

+ )} + {item.error?.message &&

{item.error.message}

} + {item.kind === "delete" && item.status === "failed" && ( +

+ Content stays unavailable until cleanup finishes. Retry resumes the saved steps. +

+ )} + {entries.length > 0 && ( +
    + {entries.map((entry) => ( +
  • + {entry.name} - {entry.state} + {entry.error?.message && ( +

    {entry.error.message}

    + )} +
  • + ))} +
+ )} + {error && ( +

+ {error.message} +

+ )} + {item.canRetry && ( +
+ +
+ )} +
+ ); +} diff --git a/admin/src/components/ui/alert.jsx b/admin/src/components/ui/alert.jsx deleted file mode 100644 index 9e3a87e4..00000000 --- a/admin/src/components/ui/alert.jsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; - -const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( -
-)); -Alert.displayName = "Alert"; - -const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( -
-)); -AlertDescription.displayName = "AlertDescription"; - -const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( -
-)); -AlertTitle.displayName = "AlertTitle"; - -export { Alert, AlertDescription, AlertTitle }; diff --git a/admin/src/components/ui/badge.jsx b/admin/src/components/ui/badge.jsx deleted file mode 100644 index d725ae33..00000000 --- a/admin/src/components/ui/badge.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva } from "class-variance-authority"; - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant, - asChild = false, - ...props -}) { - const Comp = asChild ? Slot : "span" - - return ( - - ); -} - -export { Badge, badgeVariants } diff --git a/admin/src/components/ui/button.jsx b/admin/src/components/ui/button.jsx deleted file mode 100644 index 69ad71fd..00000000 --- a/admin/src/components/ui/button.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva } from "class-variance-authority"; - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", - destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}) { - const Comp = asChild ? Slot : "button" - - return ( - - ); -} - -export { Button, buttonVariants } diff --git a/admin/src/components/ui/input.jsx b/admin/src/components/ui/input.jsx deleted file mode 100644 index 1e9bbd16..00000000 --- a/admin/src/components/ui/input.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - -function Input({ - className, - type, - ...props -}) { - return ( - - ); -} - -export { Input } diff --git a/admin/src/components/ui/modal.jsx b/admin/src/components/ui/modal.jsx deleted file mode 100644 index 075323e7..00000000 --- a/admin/src/components/ui/modal.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; - -function Modal({ isOpen, onClose, children, className, ...props }) { - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()} // Prevent closing when clicking inside the modal - > - {children} -
-
- ); -} - -function ModalHeader({ children, className, ...props }) { - return ( -
- {children} -
- ); -} - -function ModalBody({ children, className, ...props }) { - return ( -
- {children} -
- ); -} - -function ModalFooter({ children, className, ...props }) { - return ( -
- {children} -
- ); -} - -function ModalCloseButton({ onClose, className, ...props }) { - return ( - - ); -} - -export { Modal, ModalHeader, ModalBody, ModalFooter, ModalCloseButton }; diff --git a/admin/src/components/ui/pagination.jsx b/admin/src/components/ui/pagination.jsx deleted file mode 100644 index 4c63be09..00000000 --- a/admin/src/components/ui/pagination.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import * as React from "react" -import { - ChevronLeftIcon, - ChevronRightIcon, - MoreHorizontalIcon, -} from "lucide-react" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button"; - -function Pagination({ - className, - ...props -}) { - return ( -