fix: top menu

This commit is contained in:
2026-09-15 23:37:48 +07:00
parent f8eaa51d9d
commit 84a1319362
26 changed files with 785 additions and 239 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://lms-api.koda.id.vn
+1
View File
@@ -24,3 +24,4 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.serena
+34
View File
@@ -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/<name>Service.js` (`courseService`, `lessonService`, `sectionService`). Follow that shape: `api` + `BASE_PATH = '/api/<resource>'` + `getAll/getById/create/update/delete`. Add a new service rather than calling `axios`/`api` directly from a page.
- Pages: `src/pages/<feature>/`. 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.
+435 -3
View File
@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-collapsible": "^1.1.20", "@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-label": "^2.1.15",
"@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
@@ -21,6 +22,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-icons": "^5.7.0", "react-icons": "^5.7.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
@@ -439,6 +441,44 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -593,6 +633,29 @@
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT" "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": { "node_modules/@radix-ui/react-collapsible": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", "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": { "node_modules/@radix-ui/react-id": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", "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": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.10", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", "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": { "node_modules/@radix-ui/react-use-size": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", "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" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2204,6 +2495,12 @@
"node": ">=8" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2676,6 +2973,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -3516,6 +3822,53 @@
"react": "*" "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": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
@@ -3554,6 +3907,28 @@
"react-dom": ">=18" "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": { "node_modules/rolldown": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
@@ -3632,6 +4007,22 @@
"node": ">=8" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3693,9 +4084,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -3751,6 +4140,49 @@
"punycode": "^2.1.0" "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": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+2
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-collapsible": "^1.1.20", "@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-label": "^2.1.15",
"@radix-ui/react-separator": "^1.1.15", "@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
@@ -23,6 +24,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-icons": "^5.7.0", "react-icons": "^5.7.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },
+56 -122
View File
@@ -49,14 +49,12 @@
transition: grid-template-columns 180ms ease; transition: grid-template-columns 180ms ease;
} }
.app-shell--collapsed {
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
}
.sidebar { .sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
padding: 24px 16px; padding: 24px 16px;
border-right: none; border-right: none;
background: var(--color-app-primary); background: var(--color-app-primary);
@@ -116,33 +114,6 @@
font-size: 13px; 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 { .sidebar__link:focus-visible {
outline: 2px solid var(--color-app-focus); outline: 2px solid var(--color-app-focus);
outline-offset: 2px; outline-offset: 2px;
@@ -151,6 +122,46 @@
.sidebar__nav { .sidebar__nav {
display: grid; display: grid;
gap: 6px; 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 { .sidebar__divider {
@@ -276,68 +287,6 @@
border-left-color: #ffffff; 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 { .main-content {
min-width: 0; min-width: 0;
min-height: 100vh; min-height: 100vh;
@@ -414,10 +363,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-shell--collapsed {
grid-template-columns: 1fr;
}
.sidebar { .sidebar {
position: static; position: static;
height: auto; height: auto;
@@ -430,26 +375,6 @@
padding: 0 0 16px; 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 { .sidebar__nav {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -457,13 +382,22 @@
padding-bottom: 2px; padding-bottom: 2px;
} }
.sidebar__link { .sidebar__footer {
flex: 0 0 auto; display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
} }
.app-shell--collapsed .sidebar__link { .sidebar__footer-btn {
justify-content: flex-start; width: auto;
padding-inline: 12px; min-height: 36px;
padding: 8px 12px;
}
.sidebar__link {
flex: 0 0 auto;
} }
.main-content { .main-content {
+3 -1
View File
@@ -11,6 +11,7 @@ import UsersPage from './pages/user/UsersPage.jsx';
import MasterdataPage from './pages/settings/MasterdataPage.jsx'; import MasterdataPage from './pages/settings/MasterdataPage.jsx';
import RolePage from './pages/settings/RolePage.jsx'; import RolePage from './pages/settings/RolePage.jsx';
import PermissionPage from './pages/settings/PermissionPage.jsx'; import PermissionPage from './pages/settings/PermissionPage.jsx';
import { Toaster } from './components/ui/sonner.jsx';
import './App.css'; import './App.css';
function App() { function App() {
@@ -22,7 +23,7 @@ function App() {
<Route index element={<Navigate to="/courses" replace />} /> <Route index element={<Navigate to="/courses" replace />} />
<Route path="/courses" element={<CoursesPage />} /> <Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/new" element={<CourseFormPage />} /> <Route path="/courses/new" element={<CourseFormPage />} />
<Route path="/courses/:courseId" element={<CourseDetail />} /> <Route path="/courses/:courseId/detail" element={<CourseDetail />} />
<Route path="/courses/:courseId/edit" element={<CourseFormPage />} /> <Route path="/courses/:courseId/edit" element={<CourseFormPage />} />
<Route path="/courses/:courseId/lessons/new" element={<LessonFormPage />} /> <Route path="/courses/:courseId/lessons/new" element={<LessonFormPage />} />
<Route path="/courses/:courseId/lessons/:lessonId/edit" element={<LessonFormPage />} /> <Route path="/courses/:courseId/lessons/:lessonId/edit" element={<LessonFormPage />} />
@@ -36,6 +37,7 @@ function App() {
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
<Toaster />
</> </>
); );
} }
-35
View File
@@ -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 (
<>
<div className="flex items-center justify-between rounded-xl bg-white px-3 py-1.5">
<button
type="button"
onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100"
aria-label="Toggle sidebar"
>
<HiOutlineBars3 className="h-6 w-6 text-app-text-muted" />
</button>
<div className="flex items-center gap-3">
<HiOutlineBell className="h-6 w-6 text-app-text-muted" />
<HiOutlineQuestionMarkCircle className="h-6 w-6 text-app-text-muted" />
<HiOutlineUserCircle className="h-6 w-6 text-app-text-muted" />
</div>
</div>
<Separator className="my-2" />
</>
);
}
export default PageHeader;
+35
View File
@@ -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) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-xl border border-app-border bg-white p-1 text-app-text shadow-app-panel',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-app-interactive-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
+22
View File
@@ -0,0 +1,22 @@
import { Toaster as Sonner } from 'sonner';
const Toaster = (props) => (
<Sonner
theme="light"
position="top-right"
closeButton
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:rounded-xl group-[.toaster]:border-app-border group-[.toaster]:bg-card group-[.toaster]:text-app-text group-[.toaster]:shadow-app-panel',
description: 'group-[.toast]:text-app-text-muted',
actionButton: 'group-[.toast]:bg-app-primary group-[.toast]:text-white',
cancelButton: 'group-[.toast]:bg-app-surface-muted group-[.toast]:text-app-text-muted',
},
}}
{...props}
/>
);
export { Toaster };
-1
View File
@@ -1,6 +1,5 @@
:root { :root {
--sidebar-width: 264px; --sidebar-width: 264px;
--sidebar-collapsed-width: 88px;
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
+33 -12
View File
@@ -1,4 +1,4 @@
import { useState, createContext, useContext } from 'react' import { useState } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom' import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { import {
HiChevronRight, HiChevronRight,
@@ -8,14 +8,16 @@ import {
HiQuestionMarkCircle, HiQuestionMarkCircle,
HiUserGroup, HiUserGroup,
HiCog6Tooth, HiCog6Tooth,
HiOutlineUserCircle,
} from 'react-icons/hi2' } from 'react-icons/hi2'
import { toast } from 'sonner'
import { navigationItems } from '../config/navigation.js' import { navigationItems } from '../config/navigation.js'
import {
const SidebarContext = createContext() DropdownMenu,
DropdownMenuTrigger,
export function useSidebar() { DropdownMenuContent,
return useContext(SidebarContext) DropdownMenuItem,
} } from '../components/ui/dropdown-menu.jsx'
const iconMap = { const iconMap = {
'/courses': HiBookOpen, '/courses': HiBookOpen,
@@ -83,11 +85,8 @@ function SidebarItem({ item }) {
} }
function MainLayout() { function MainLayout() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
return ( return (
<SidebarContext.Provider value={{ isSidebarCollapsed, toggleSidebar: () => setIsSidebarCollapsed(v => !v) }}> <div className="app-shell">
<div className={isSidebarCollapsed ? 'app-shell app-shell--collapsed' : 'app-shell'}>
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar__brand"> <div className="sidebar__brand">
<span className="sidebar__logo"><HiAcademicCap size={22} /></span> <span className="sidebar__logo"><HiAcademicCap size={22} /></span>
@@ -104,13 +103,35 @@ function MainLayout() {
<SidebarItem key={item.path} item={item} /> <SidebarItem key={item.path} item={item} />
))} ))}
</nav> </nav>
<div className="sidebar__footer">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="sidebar__footer-btn"
aria-label="Account menu"
title="Account"
>
<HiOutlineUserCircle size={20} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end">
<DropdownMenuItem onSelect={() => toast('Profile — coming soon')}>
Profile
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast('Logout — coming soon')}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
</SidebarContext.Provider>
) )
} }
+9 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { toast } from 'sonner';
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080', baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
@@ -8,10 +9,17 @@ const api = axios.create({
}); });
api.interceptors.response.use( 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) => { (error) => {
const message = error.response?.data?.message || error.message || 'Something went wrong'; const message = error.response?.data?.message || error.message || 'Something went wrong';
console.error('API Error:', message); console.error('API Error:', message);
toast.error(message);
return Promise.reject(error); return Promise.reject(error);
} }
); );
-4
View File
@@ -8,7 +8,6 @@ import { Input } from '../../components/ui/input';
import { Textarea } from '../../components/ui/textarea'; import { Textarea } from '../../components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
import { Separator } from '../../components/ui/separator'; import { Separator } from '../../components/ui/separator';
import PageHeader from '../../components/PageHeader';
import { import {
HiOutlinePlus, HiOutlinePlus,
HiOutlinePencil, HiOutlinePencil,
@@ -140,7 +139,6 @@ function CourseDetail() {
if (loading) { if (loading) {
return ( return (
<> <>
<PageHeader />
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div> <div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
</> </>
); );
@@ -149,7 +147,6 @@ function CourseDetail() {
if (error || !course) { if (error || !course) {
return ( return (
<> <>
<PageHeader />
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted"> <div className="flex flex-col items-center justify-center py-20 text-app-text-muted">
<HiOutlineBookOpen className="w-16 h-16 mb-4 opacity-30" /> <HiOutlineBookOpen className="w-16 h-16 mb-4 opacity-30" />
<p className="text-lg font-medium">{error || 'Course not found'}</p> <p className="text-lg font-medium">{error || 'Course not found'}</p>
@@ -170,7 +167,6 @@ function CourseDetail() {
return ( return (
<> <>
<PageHeader />
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4"> <div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
+98 -15
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import PageHeader from '../../components/PageHeader';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -8,17 +7,24 @@ import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea'; import { Textarea } from '../../components/ui/textarea';
import { Switch } from '../../components/ui/switch'; import { Switch } from '../../components/ui/switch';
import { Separator } from '../../components/ui/separator'; import { Separator } from '../../components/ui/separator';
import { toast } from 'sonner';
import { import {
HiOutlineArrowLeft, HiOutlineArrowLeft,
HiOutlineBookOpen, HiOutlineBookOpen,
HiOutlineChevronRight, HiOutlineChevronRight,
HiOutlinePhoto,
HiOutlineTrash,
} from 'react-icons/hi2'; } from 'react-icons/hi2';
import courseService from '@/services/courseService'; import courseService from '@/services/courseService';
import fileService from '@/services/fileService';
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
function CourseFormPage() { function CourseFormPage() {
const { courseId } = useParams(); const { courseId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const isEditing = Boolean(courseId); const isEditing = Boolean(courseId);
const fileInputRef = useRef(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
courseCode: '', courseCode: '',
@@ -28,6 +34,8 @@ function CourseFormPage() {
image: '', image: '',
isPublished: false, isPublished: false,
}); });
const [imageUrl, setImageUrl] = useState('');
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(isEditing); const [loading, setLoading] = useState(isEditing);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -45,6 +53,7 @@ function CourseFormPage() {
image: data.image || '', image: data.image || '',
isPublished: data.isPublished || false, isPublished: data.isPublished || false,
}); });
setImageUrl(data.imageUrl || data.image || '');
} catch (err) { } catch (err) {
setError(err.message || 'Failed to load course'); setError(err.message || 'Failed to load course');
} finally { } finally {
@@ -58,6 +67,35 @@ function CourseFormPage() {
setForm((prev) => ({ ...prev, [field]: value })); 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 generateSlug = () => {
const slug = form.title const slug = form.title
.toLowerCase() .toLowerCase()
@@ -84,10 +122,10 @@ function CourseFormPage() {
await courseService.update(courseId, payload); await courseService.update(courseId, payload);
} else { } else {
const created = await courseService.create(payload); const created = await courseService.create(payload);
navigate(`/courses/${created.id}`); navigate(`/courses/${created.id}/detail`);
return; return;
} }
navigate(`/courses/${courseId}`); navigate(`/courses/${courseId}/detail`);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to save course'); setError(err.message || 'Failed to save course');
} finally { } finally {
@@ -98,7 +136,6 @@ function CourseFormPage() {
if (loading) { if (loading) {
return ( return (
<> <>
<PageHeader />
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div> <div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
</> </>
); );
@@ -106,7 +143,6 @@ function CourseFormPage() {
return ( return (
<> <>
<PageHeader />
<div className="max-w-4xl mx-auto px-6 py-8"> <div className="max-w-4xl mx-auto px-6 py-8">
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4"> <div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
@@ -132,7 +168,7 @@ function CourseFormPage() {
<Button <Button
variant="outline" variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover" className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
onClick={() => navigate(isEditing ? `/courses/${courseId}` : '/courses')} onClick={() => navigate(isEditing ? `/courses/${courseId}/detail` : '/courses')}
disabled={saving} disabled={saving}
> >
<HiOutlineArrowLeft className="w-4 h-4 mr-2" /> <HiOutlineArrowLeft className="w-4 h-4 mr-2" />
@@ -142,14 +178,14 @@ function CourseFormPage() {
variant="outline" variant="outline"
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover" className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
onClick={() => handleSave(false)} onClick={() => handleSave(false)}
disabled={saving} disabled={saving || uploading}
> >
{saving ? 'Saving...' : 'Save Draft'} {saving ? 'Saving...' : 'Save Draft'}
</Button> </Button>
<Button <Button
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark" className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
onClick={() => handleSave(true)} onClick={() => handleSave(true)}
disabled={saving} disabled={saving || uploading}
> >
{saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'} {saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'}
</Button> </Button>
@@ -237,15 +273,62 @@ function CourseFormPage() {
<div> <div>
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider"> <Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
Image URL Image
</Label> </Label>
<Input <div
placeholder="https://..." role="button"
value={form.image} tabIndex={0}
onChange={(e) => handleChange('image', e.target.value)} aria-label="Upload course image"
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary" onClick={() => !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 ? (
<>
<img src={imageUrl} alt="Course" className="h-full w-full object-cover" />
{!uploading && (
<div className="absolute inset-0 hidden items-center justify-center bg-black/40 text-sm font-medium text-white group-hover:flex">
Replace
</div>
)}
<button
type="button"
aria-label="Remove image"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage();
}}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-lg bg-white/90 text-red-500 shadow-sm hover:bg-white"
>
<HiOutlineTrash className="h-4 w-4" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-app-text-muted">
<HiOutlinePhoto className="h-8 w-8" />
<span className="text-sm font-medium text-app-text">Click to upload image</span>
<span className="text-xs">PNG, JPG, WEBP max 5MB</span>
</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 text-sm font-medium text-white">
Uploading...
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImagePick}
/> />
</div> </div>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted"> <div className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted">
<div className="flex flex-col"> <div className="flex flex-col">
+14 -8
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import CourseCard from '../../components/CourseCard.jsx'; import CourseCard from '../../components/CourseCard.jsx';
import PageHeader from '../../components/PageHeader.jsx';
import { Button } from '../../components/ui/button.jsx'; import { Button } from '../../components/ui/button.jsx';
import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs.jsx'; import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs.jsx';
import { Input } from '../../components/ui/input.jsx'; import { Input } from '../../components/ui/input.jsx';
@@ -17,7 +16,7 @@ function mapCourse(c) {
description: c.description, description: c.description,
status: c.isPublished ? 'Active' : 'Inactive', status: c.isPublished ? 'Active' : 'Inactive',
lastUpdate: c.updatedAt, lastUpdate: c.updatedAt,
image: c.image, image: c.imageUrl || c.image,
}; };
} }
@@ -28,12 +27,14 @@ function CoursePage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { useEffect(() => {
const fetchCourses = async () => { const fetchCourses = async () => {
try { try {
const data = await courseService.getAll(); const data = await courseService.getAll();
setCourses(data.map(mapCourse)); setCourses(data.map(mapCourse));
setError(null);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to load courses'); setError(err.message || 'Failed to load courses');
} finally { } finally {
@@ -41,7 +42,13 @@ function CoursePage() {
} }
}; };
fetchCourses(); fetchCourses();
}, []); }, [reloadKey]);
const handleRetry = () => {
setError(null);
setLoading(true);
setReloadKey((k) => k + 1);
};
const filtered = courses.filter((c) => { const filtered = courses.filter((c) => {
const matchTab = const matchTab =
@@ -56,12 +63,11 @@ function CoursePage() {
}); });
const handleClick = (course) => { const handleClick = (course) => {
navigate(`/courses/${course.id}`); navigate(`/courses/${course.id}/detail`);
}; };
return ( return (
<> <>
<PageHeader />
<div className="mb-4 flex flex-row items-center justify-between"> <div className="mb-4 flex flex-row items-center justify-between">
<div> <div>
<h3 className="mb-2 text-[28px] font-bold leading-[1.29] tracking-tight text-app-text"> <h3 className="mb-2 text-[28px] font-bold leading-[1.29] tracking-tight text-app-text">
@@ -110,9 +116,9 @@ function CoursePage() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading courses...</div> <div className="flex items-center justify-center py-20 text-app-text-muted">Loading courses...</div>
) : error ? ( ) : error ? (
<div className="flex flex-col items-center justify-center py-20 text-app-text-muted"> <div className="flex items-center justify-between gap-3 rounded-xl border border-app-border bg-white p-4">
<p className="text-red-500 mb-2">{error}</p> <p className="text-sm text-red-500">{error}</p>
<Button variant="link" className="text-app-primary" onClick={() => window.location.reload()}> <Button variant="link" className="text-app-primary" onClick={handleRetry}>
Retry Retry
</Button> </Button>
</div> </div>
-3
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import PageHeader from '../../components/PageHeader';
import ActionBar from './components/ActionBar'; import ActionBar from './components/ActionBar';
import LessonMetadataForm from './components/LessonMetadataForm'; import LessonMetadataForm from './components/LessonMetadataForm';
import SectionManager from './components/SectionManager'; import SectionManager from './components/SectionManager';
@@ -173,7 +172,6 @@ function LessonFormPage() {
if (loading) { if (loading) {
return ( return (
<> <>
<PageHeader />
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading lesson...</div> <div className="flex items-center justify-center py-20 text-app-text-muted">Loading lesson...</div>
</> </>
); );
@@ -181,7 +179,6 @@ function LessonFormPage() {
return ( return (
<> <>
<PageHeader />
<div className="max-w-7xl mx-auto px-6 py-8"> <div className="max-w-7xl mx-auto px-6 py-8">
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<ActionBar <ActionBar
-2
View File
@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PageHeader from '../../components/PageHeader';
import { DataTable } from '../../components/ui/data-table'; import { DataTable } from '../../components/ui/data-table';
import lessonService from '@/services/lessonService'; import lessonService from '@/services/lessonService';
@@ -133,7 +132,6 @@ function LessonPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: '#101114' }}>Lesson list</h2> <h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: '#101114' }}>Lesson list</h2>
+1 -1
View File
@@ -19,7 +19,7 @@ function ActionBar({ courseId, isPublished, onTogglePublish, onSaveDraft, saving
</Link> </Link>
<span>/</span> <span>/</span>
<Link <Link
to={`/courses/${courseId}`} to={`/courses/${courseId}/detail`}
className="hover:text-primary transition-colors truncate max-w-[200px]" className="hover:text-primary transition-colors truncate max-w-[200px]"
> >
{courseId} {courseId}
-2
View File
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader'
function MCQPage() { function MCQPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>MCQ Questions</h2> <h2>MCQ Questions</h2>
<p>MCQ content will appear here.</p> <p>MCQ content will appear here.</p>
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader'
function ParagraphPage() { function ParagraphPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>Paragraph Questions</h2> <h2>Paragraph Questions</h2>
<p>Paragraph content will appear here.</p> <p>Paragraph content will appear here.</p>
-2
View File
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader'
function MasterdataPage() { function MasterdataPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>Master Data</h2> <h2>Master Data</h2>
<p>Master data content will appear here.</p> <p>Master data content will appear here.</p>
-2
View File
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader'
function PermissionPage() { function PermissionPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>Permissions</h2> <h2>Permissions</h2>
<p>Permission management content will appear here.</p> <p>Permission management content will appear here.</p>
-2
View File
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader'
function RolePage() { function RolePage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>Roles</h2> <h2>Roles</h2>
<p>Role management content will appear here.</p> <p>Role management content will appear here.</p>
-2
View File
@@ -1,9 +1,7 @@
import PageHeader from '../../components/PageHeader';
function UsersPage() { function UsersPage() {
return ( return (
<> <>
<PageHeader />
<div className="content-panel"> <div className="content-panel">
<h2>Users</h2> <h2>Users</h2>
<p>User management content will appear here.</p> <p>User management content will appear here.</p>
+22
View File
@@ -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;