fix: top menu
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://lms-api.koda.id.vn
|
||||||
@@ -24,3 +24,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.serena
|
||||||
@@ -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.
|
||||||
Generated
+435
-3
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 };
|
||||||
@@ -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,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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user