diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..70c702d --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://lms-api.koda.id.vn diff --git a/.gitignore b/.gitignore index d7de12f..797bc99 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.njsproj *.sln *.sw? +.serena \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ff0909 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +APLP LMS frontend. React 19 + Vite 8 + React Router 7 + Tailwind v4. Plain JS/JSX (no TypeScript). + +## Commands + +- `npm run dev` — Vite dev server +- `npm run lint` — ESLint (flat config, `eslint.config.js`); this is the only check +- `npm run build` — production build +- No test suite and no typecheck. Do not claim tests pass; verify with `npm run lint` and `npm run build`. + +## Architecture + +- Entry: `src/main.jsx` → `src/App.jsx`. All routes are declared in `App.jsx`; sidebar entries in `src/config/navigation.js`. Add both when adding a page. +- `@` is aliased to `src` (`vite.config.js`). Use `@/services/...`, `@/lib/...`. +- API layer: `src/lib/api.js` is a shared axios instance. Base URL = `VITE_API_BASE_URL`, default `http://localhost:8080`. `.env` is gitignored and there is no `.env.example`. +- Feature data access lives in `src/services/Service.js` (`courseService`, `lessonService`, `sectionService`). Follow that shape: `api` + `BASE_PATH = '/api/'` + `getAll/getById/create/update/delete`. Add a new service rather than calling `axios`/`api` directly from a page. +- Pages: `src/pages//`. Layout/nav shell: `src/layouts/MainLayout.jsx`. + +## Tailwind v4 + +- No `tailwind.config.js`. Config is CSS-first in the `@theme` block of `src/App.css`, which is the file that `@import 'tailwindcss'` (imported from `App.jsx`, not `index.css`). +- Use the semantic tokens, not raw Kraken hex: `bg-app-primary`, `hover:bg-app-primary-dark`, `bg-app-surface-muted`, `text-app-text`, `text-app-text-muted`, `border-app-border`, `shadow-app-panel`, `shadow-app-micro`. +- Visual rules (colors, type, radii, do/don't) are in `DESIGN.md` — read it before styling. + +## UI components + +- `src/components/ui/*` are hand-rolled shadcn-style primitives (cva + Radix + `cn()` from `src/lib/utils.js`). There is no shadcn CLI or `components.json`; extend these files by hand. +- Icons come from `react-icons/hi2`. + +## Gotchas + +- `App.jsx` imports `./pages/Course/*` but the directory on disk is `src/pages/course/`. This only works on case-insensitive macOS; use the lowercase path to stay safe on Linux/CI. +- Imports are mixed: some relative (`../../components/...`), some aliased (`@/services/...`). Match the surrounding file. diff --git a/package-lock.json b/package-lock.json index 6d8cc63..897cb97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slot": "^1.3.3", @@ -21,6 +22,7 @@ "react-dom": "^19.2.8", "react-icons": "^5.7.0", "react-router-dom": "^7.18.2", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3" }, @@ -439,6 +441,44 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -593,6 +633,29 @@ "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", @@ -694,6 +757,102 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", @@ -735,6 +894,102 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", @@ -996,6 +1251,24 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", @@ -1014,6 +1287,12 @@ } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", @@ -1964,6 +2243,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2204,6 +2495,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2676,6 +2973,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3516,6 +3822,53 @@ "react": "*" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -3554,6 +3907,28 @@ "react-dom": ">=18" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", @@ -3632,6 +4007,22 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3693,9 +4084,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -3751,6 +4140,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", diff --git a/package.json b/package.json index e31101e..142c163 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-label": "^2.1.15", "@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-slot": "^1.3.3", @@ -23,6 +24,7 @@ "react-dom": "^19.2.8", "react-icons": "^5.7.0", "react-router-dom": "^7.18.2", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3" }, diff --git a/src/App.css b/src/App.css index 14047a1..1c68570 100644 --- a/src/App.css +++ b/src/App.css @@ -49,14 +49,12 @@ transition: grid-template-columns 180ms ease; } -.app-shell--collapsed { - grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr); -} - .sidebar { position: sticky; top: 0; height: 100vh; + display: flex; + flex-direction: column; padding: 24px 16px; border-right: none; background: var(--color-app-primary); @@ -116,33 +114,6 @@ font-size: 13px; } -.sidebar__toggle { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 32px; - margin-bottom: 12px; - border: none; - border-radius: 6px; - color: var(--color-app-text-muted); - background: transparent; - cursor: pointer; - transition: - color 160ms ease, - background-color 160ms ease; -} - -.sidebar__toggle:hover { - color: var(--color-app-text); - background: var(--color-app-interactive-hover); -} - -.sidebar__toggle:active { - background: var(--color-app-interactive-active); -} - -.sidebar__toggle:focus-visible, .sidebar__link:focus-visible { outline: 2px solid var(--color-app-focus); outline-offset: 2px; @@ -151,6 +122,46 @@ .sidebar__nav { display: grid; gap: 6px; + flex: 1 1 auto; + min-height: 0; + align-content: start; + overflow-y: auto; +} + +.sidebar__footer { + display: grid; + gap: 6px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.2); +} + +.sidebar__footer-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 42px; + padding: 10px 12px; + border: none; + border-radius: 8px; + color: rgba(255,255,255,0.75); + background: transparent; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: + color 160ms ease, background 160ms ease; +} + +.sidebar__footer-btn:hover { + color: #ffffff; + background: rgba(255,255,255,0.1); +} + +.sidebar__footer-btn:focus-visible { + outline: 2px solid var(--color-app-focus); + outline-offset: 2px; } .sidebar__divider { @@ -276,68 +287,6 @@ border-left-color: #ffffff; } -.app-shell--collapsed .sidebar { - padding: 24px 0; -} - -.app-shell--collapsed .sidebar__brand { - display: flex; - justify-content: center; - padding: 0; - border: none; - margin-bottom: 12px; -} - -.app-shell--collapsed .sidebar__logo { - display: grid; - width: 38px; - height: 38px; - place-items: center; - border-radius: 10px; - background: #ffffff; - color: var(--color-app-primary) !important; -} - -.app-shell--collapsed .sidebar__brand-text, -.app-shell--collapsed .sidebar__link-text { - width: 0; - overflow: hidden; - opacity: 0; - pointer-events: none; -} - -.app-shell--collapsed .sidebar__link, -.app-shell--collapsed .sidebar__link--parent { - justify-content: center; - padding: 8px 0; -} - -.app-shell--collapsed .sidebar__link--active { - background: transparent; -} - -.app-shell--collapsed .sidebar__link--active .sidebar__link-icon { - border-radius: 8px; -} - -.app-shell--collapsed .sidebar__chevron, -.app-shell--collapsed .sidebar__submenu { - display: none; -} - -.app-shell--collapsed .sidebar__link-icon { - flex: none; - width: 30px; - height: 30px; -} - -.app-shell--collapsed .sidebar__toggle { - width: 28px; - height: 28px; - margin-inline: auto; - margin-bottom: 12px; -} - .main-content { min-width: 0; min-height: 100vh; @@ -414,10 +363,6 @@ grid-template-columns: 1fr; } - .app-shell--collapsed { - grid-template-columns: 1fr; - } - .sidebar { position: static; height: auto; @@ -430,26 +375,6 @@ padding: 0 0 16px; } - .app-shell--collapsed .sidebar { - padding: 16px; - } - - .app-shell--collapsed .sidebar__brand { - justify-content: flex-start; - padding: 0 0 16px; - } - - .app-shell--collapsed .sidebar__brand-text, - .app-shell--collapsed .sidebar__link-text { - width: auto; - opacity: 1; - pointer-events: auto; - } - - .sidebar__toggle { - display: none; - } - .sidebar__nav { display: flex; gap: 8px; @@ -457,13 +382,22 @@ padding-bottom: 2px; } - .sidebar__link { - flex: 0 0 auto; + .sidebar__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 12px; } - .app-shell--collapsed .sidebar__link { - justify-content: flex-start; - padding-inline: 12px; + .sidebar__footer-btn { + width: auto; + min-height: 36px; + padding: 8px 12px; + } + + .sidebar__link { + flex: 0 0 auto; } .main-content { diff --git a/src/App.jsx b/src/App.jsx index d631855..96a4765 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import UsersPage from './pages/user/UsersPage.jsx'; import MasterdataPage from './pages/settings/MasterdataPage.jsx'; import RolePage from './pages/settings/RolePage.jsx'; import PermissionPage from './pages/settings/PermissionPage.jsx'; +import { Toaster } from './components/ui/sonner.jsx'; import './App.css'; function App() { @@ -22,7 +23,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -36,6 +37,7 @@ function App() { + ); } diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx deleted file mode 100644 index 0fe9508..0000000 --- a/src/components/PageHeader.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Separator } from '../components/ui/separator'; -import { - HiOutlineBell, - HiOutlineQuestionMarkCircle, - HiOutlineUserCircle, - HiOutlineBars3, -} from 'react-icons/hi2'; -import { useSidebar } from '../layouts/MainLayout'; - -function PageHeader() { - const { toggleSidebar } = useSidebar(); - - return ( - <> -
- -
- - - -
-
- - - ); -} - -export default PageHeader; diff --git a/src/components/ui/dropdown-menu.jsx b/src/components/ui/dropdown-menu.jsx new file mode 100644 index 0000000..d47c5f5 --- /dev/null +++ b/src/components/ui/dropdown-menu.jsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { cn } from '../../lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 8, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }; diff --git a/src/components/ui/sonner.jsx b/src/components/ui/sonner.jsx new file mode 100644 index 0000000..08855f4 --- /dev/null +++ b/src/components/ui/sonner.jsx @@ -0,0 +1,22 @@ +import { Toaster as Sonner } from 'sonner'; + +const Toaster = (props) => ( + +); + +export { Toaster }; diff --git a/src/index.css b/src/index.css index b40f971..6516347 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,5 @@ :root { --sidebar-width: 264px; - --sidebar-collapsed-width: 88px; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', diff --git a/src/layouts/MainLayout.jsx b/src/layouts/MainLayout.jsx index 0975de1..c681b52 100644 --- a/src/layouts/MainLayout.jsx +++ b/src/layouts/MainLayout.jsx @@ -1,4 +1,4 @@ -import { useState, createContext, useContext } from 'react' +import { useState } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { HiChevronRight, @@ -8,14 +8,16 @@ import { HiQuestionMarkCircle, HiUserGroup, HiCog6Tooth, + HiOutlineUserCircle, } from 'react-icons/hi2' +import { toast } from 'sonner' import { navigationItems } from '../config/navigation.js' - -const SidebarContext = createContext() - -export function useSidebar() { - return useContext(SidebarContext) -} +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '../components/ui/dropdown-menu.jsx' const iconMap = { '/courses': HiBookOpen, @@ -83,34 +85,53 @@ function SidebarItem({ item }) { } function MainLayout() { - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) - return ( - setIsSidebarCollapsed(v => !v) }}> -
- + +
+ +
+
) } diff --git a/src/lib/api.js b/src/lib/api.js index 72a4a0e..6c3d03f 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { toast } from 'sonner'; const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080', @@ -8,10 +9,17 @@ const api = axios.create({ }); api.interceptors.response.use( - (response) => response, + (response) => { + const payload = response.data; + if (payload && typeof payload === 'object' && payload.success === true && 'data' in payload) { + response.data = payload.data; + } + return response; + }, (error) => { const message = error.response?.data?.message || error.message || 'Something went wrong'; console.error('API Error:', message); + toast.error(message); return Promise.reject(error); } ); diff --git a/src/pages/Course/CourseDetails.jsx b/src/pages/Course/CourseDetails.jsx index d6a39cb..005e5b2 100644 --- a/src/pages/Course/CourseDetails.jsx +++ b/src/pages/Course/CourseDetails.jsx @@ -8,7 +8,6 @@ import { Input } from '../../components/ui/input'; import { Textarea } from '../../components/ui/textarea'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs'; import { Separator } from '../../components/ui/separator'; -import PageHeader from '../../components/PageHeader'; import { HiOutlinePlus, HiOutlinePencil, @@ -140,7 +139,6 @@ function CourseDetail() { if (loading) { return ( <> -
Loading course...
); @@ -149,7 +147,6 @@ function CourseDetail() { if (error || !course) { return ( <> -

{error || 'Course not found'}

@@ -170,7 +167,6 @@ function CourseDetail() { return ( <> - {/* Breadcrumb */}
diff --git a/src/pages/Course/CourseFormPage.jsx b/src/pages/Course/CourseFormPage.jsx index d239435..3c3b35b 100644 --- a/src/pages/Course/CourseFormPage.jsx +++ b/src/pages/Course/CourseFormPage.jsx @@ -1,6 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import PageHeader from '../../components/PageHeader'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; import { Input } from '../../components/ui/input'; @@ -8,17 +7,24 @@ import { Label } from '../../components/ui/label'; import { Textarea } from '../../components/ui/textarea'; import { Switch } from '../../components/ui/switch'; import { Separator } from '../../components/ui/separator'; +import { toast } from 'sonner'; import { HiOutlineArrowLeft, HiOutlineBookOpen, HiOutlineChevronRight, + HiOutlinePhoto, + HiOutlineTrash, } from 'react-icons/hi2'; import courseService from '@/services/courseService'; +import fileService from '@/services/fileService'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; function CourseFormPage() { const { courseId } = useParams(); const navigate = useNavigate(); const isEditing = Boolean(courseId); + const fileInputRef = useRef(null); const [form, setForm] = useState({ courseCode: '', @@ -28,6 +34,8 @@ function CourseFormPage() { image: '', isPublished: false, }); + const [imageUrl, setImageUrl] = useState(''); + const [uploading, setUploading] = useState(false); const [loading, setLoading] = useState(isEditing); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -45,6 +53,7 @@ function CourseFormPage() { image: data.image || '', isPublished: data.isPublished || false, }); + setImageUrl(data.imageUrl || data.image || ''); } catch (err) { setError(err.message || 'Failed to load course'); } finally { @@ -58,6 +67,35 @@ function CourseFormPage() { setForm((prev) => ({ ...prev, [field]: value })); }; + const handleImagePick = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast.error('Only image files are allowed'); + return; + } + if (file.size > MAX_IMAGE_BYTES) { + toast.error('Image must not exceed 5MB'); + return; + } + setUploading(true); + try { + const uploaded = await fileService.upload(file, 'courses'); + handleChange('image', uploaded.objectKey); + setImageUrl(uploaded.url); + } catch { + // global API interceptor already surfaced the error toast + } finally { + setUploading(false); + } + }; + + const handleRemoveImage = () => { + handleChange('image', ''); + setImageUrl(''); + }; + const generateSlug = () => { const slug = form.title .toLowerCase() @@ -84,10 +122,10 @@ function CourseFormPage() { await courseService.update(courseId, payload); } else { const created = await courseService.create(payload); - navigate(`/courses/${created.id}`); + navigate(`/courses/${created.id}/detail`); return; } - navigate(`/courses/${courseId}`); + navigate(`/courses/${courseId}/detail`); } catch (err) { setError(err.message || 'Failed to save course'); } finally { @@ -98,7 +136,6 @@ function CourseFormPage() { if (loading) { return ( <> -
Loading course...
); @@ -106,7 +143,6 @@ function CourseFormPage() { return ( <> -
{/* Breadcrumb */}
@@ -132,7 +168,7 @@ function CourseFormPage() { @@ -237,14 +273,61 @@ function CourseFormPage() {
- handleChange('image', e.target.value)} - className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary" - /> +
!uploading && fileInputRef.current?.click()} + onKeyDown={(e) => { + if ((e.key === 'Enter' || e.key === ' ') && !uploading) { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + className="group relative h-48 w-full cursor-pointer overflow-hidden rounded-xl border border-dashed border-app-border bg-app-surface-muted" + > + {imageUrl ? ( + <> + Course + {!uploading && ( +
+ Replace +
+ )} + + + ) : ( +
+ + Click to upload image + PNG, JPG, WEBP — max 5MB +
+ )} + {uploading && ( +
+ Uploading... +
+ )} + +
diff --git a/src/pages/Course/CoursesPage.jsx b/src/pages/Course/CoursesPage.jsx index c47cc03..1ebdeea 100644 --- a/src/pages/Course/CoursesPage.jsx +++ b/src/pages/Course/CoursesPage.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import CourseCard from '../../components/CourseCard.jsx'; -import PageHeader from '../../components/PageHeader.jsx'; import { Button } from '../../components/ui/button.jsx'; import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs.jsx'; import { Input } from '../../components/ui/input.jsx'; @@ -17,7 +16,7 @@ function mapCourse(c) { description: c.description, status: c.isPublished ? 'Active' : 'Inactive', lastUpdate: c.updatedAt, - image: c.image, + image: c.imageUrl || c.image, }; } @@ -28,12 +27,14 @@ function CoursePage() { const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState('all'); const [search, setSearch] = useState(''); + const [reloadKey, setReloadKey] = useState(0); useEffect(() => { const fetchCourses = async () => { try { const data = await courseService.getAll(); setCourses(data.map(mapCourse)); + setError(null); } catch (err) { setError(err.message || 'Failed to load courses'); } finally { @@ -41,7 +42,13 @@ function CoursePage() { } }; fetchCourses(); - }, []); + }, [reloadKey]); + + const handleRetry = () => { + setError(null); + setLoading(true); + setReloadKey((k) => k + 1); + }; const filtered = courses.filter((c) => { const matchTab = @@ -56,12 +63,11 @@ function CoursePage() { }); const handleClick = (course) => { - navigate(`/courses/${course.id}`); + navigate(`/courses/${course.id}/detail`); }; return ( <> -

@@ -110,9 +116,9 @@ function CoursePage() { {loading ? (
Loading courses...
) : error ? ( -
-

{error}

-
diff --git a/src/pages/lesson/LessonFormPage.jsx b/src/pages/lesson/LessonFormPage.jsx index 78a228c..90b49da 100644 --- a/src/pages/lesson/LessonFormPage.jsx +++ b/src/pages/lesson/LessonFormPage.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import PageHeader from '../../components/PageHeader'; import ActionBar from './components/ActionBar'; import LessonMetadataForm from './components/LessonMetadataForm'; import SectionManager from './components/SectionManager'; @@ -173,7 +172,6 @@ function LessonFormPage() { if (loading) { return ( <> -
Loading lesson...
); @@ -181,7 +179,6 @@ function LessonFormPage() { return ( <> -
-

Lesson list

diff --git a/src/pages/lesson/components/ActionBar.jsx b/src/pages/lesson/components/ActionBar.jsx index b19c5b9..0a124a2 100644 --- a/src/pages/lesson/components/ActionBar.jsx +++ b/src/pages/lesson/components/ActionBar.jsx @@ -19,7 +19,7 @@ function ActionBar({ courseId, isPublished, onTogglePublish, onSaveDraft, saving / {courseId} diff --git a/src/pages/question-bank/MCQPage.jsx b/src/pages/question-bank/MCQPage.jsx index 0500e28..73ca050 100644 --- a/src/pages/question-bank/MCQPage.jsx +++ b/src/pages/question-bank/MCQPage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader' function MCQPage() { return ( <> -

MCQ Questions

MCQ content will appear here.

diff --git a/src/pages/question-bank/ParagraphPage.jsx b/src/pages/question-bank/ParagraphPage.jsx index 856e47f..17830d1 100644 --- a/src/pages/question-bank/ParagraphPage.jsx +++ b/src/pages/question-bank/ParagraphPage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader' function ParagraphPage() { return ( <> -

Paragraph Questions

Paragraph content will appear here.

diff --git a/src/pages/settings/MasterdataPage.jsx b/src/pages/settings/MasterdataPage.jsx index 9dd491e..34380a8 100644 --- a/src/pages/settings/MasterdataPage.jsx +++ b/src/pages/settings/MasterdataPage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader' function MasterdataPage() { return ( <> -

Master Data

Master data content will appear here.

diff --git a/src/pages/settings/PermissionPage.jsx b/src/pages/settings/PermissionPage.jsx index 2e24c80..9468565 100644 --- a/src/pages/settings/PermissionPage.jsx +++ b/src/pages/settings/PermissionPage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader' function PermissionPage() { return ( <> -

Permissions

Permission management content will appear here.

diff --git a/src/pages/settings/RolePage.jsx b/src/pages/settings/RolePage.jsx index de6c8de..133b3ad 100644 --- a/src/pages/settings/RolePage.jsx +++ b/src/pages/settings/RolePage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader' function RolePage() { return ( <> -

Roles

Role management content will appear here.

diff --git a/src/pages/user/UsersPage.jsx b/src/pages/user/UsersPage.jsx index 3e06591..ba633b6 100644 --- a/src/pages/user/UsersPage.jsx +++ b/src/pages/user/UsersPage.jsx @@ -1,9 +1,7 @@ -import PageHeader from '../../components/PageHeader'; function UsersPage() { return ( <> -

Users

User management content will appear here.

diff --git a/src/services/fileService.js b/src/services/fileService.js new file mode 100644 index 0000000..3de4909 --- /dev/null +++ b/src/services/fileService.js @@ -0,0 +1,22 @@ +import api from '@/lib/api'; + +const BASE_PATH = '/api/files'; + +const fileService = { + upload: async (file, folder) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post(BASE_PATH, formData, { + params: folder ? { folder } : undefined, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + }, + + delete: async (objectKey) => { + await api.delete(BASE_PATH, { params: { objectKey } }); + }, +}; + +export default fileService;