Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84a1319362 | ||
|
|
f8eaa51d9d | ||
|
|
7bc3cf4e73 | ||
|
|
b55e7f97c9 | ||
|
|
fd85fd894b | ||
|
|
2effe3a25f | ||
|
|
d3cc4c077c | ||
|
|
fd11fa59b1 | ||
|
|
31cc376e4f | ||
|
|
3f56f788d1 | ||
|
|
c75e412051 |
@@ -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.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Design System Inspired by Kraken
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
Kraken's website is a clean, trustworthy crypto exchange that uses purple as its commanding brand color. The design operates on white backgrounds with Kraken Purple (`#7132f5`, `#5741d8`, `#5b1ecf`) creating a distinctive, professional crypto identity. The proprietary Kraken-Brand font handles display headings with bold (700) weight and negative tracking, while Kraken-Product (with IBM Plex Sans fallback) serves as the UI workhorse.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Kraken Purple (`#7132f5`) as primary brand with darker variants (`#5741d8`, `#5b1ecf`)
|
||||||
|
- Kraken-Brand (display) + Kraken-Product (UI) dual font system
|
||||||
|
- Near-black (`#101114`) text with cool blue-gray neutral scale
|
||||||
|
- 12px radius buttons (rounded but not pill)
|
||||||
|
- Subtle shadows (`rgba(0,0,0,0.03) 0px 4px 24px`) — whisper-level
|
||||||
|
- Green accent (`#149e61`) for positive/success states
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
- **Kraken Purple** (`#7132f5`): Primary CTA, brand accent, links
|
||||||
|
- **Purple Dark** (`#5741d8`): Button borders, outlined variants
|
||||||
|
- **Purple Deep** (`#5b1ecf`): Deepest purple
|
||||||
|
- **Purple Subtle** (`rgba(133,91,251,0.16)`): Purple at 16% — subtle button backgrounds
|
||||||
|
- **Near Black** (`#101114`): Primary text
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- **Cool Gray** (`#686b82`): Primary neutral, borders at 24% opacity
|
||||||
|
- **Silver Blue** (`#9497a9`): Secondary text, muted elements
|
||||||
|
- **White** (`#ffffff`): Primary surface
|
||||||
|
- **Border Gray** (`#dedee5`): Divider borders
|
||||||
|
|
||||||
|
### Semantic
|
||||||
|
- **Green** (`#149e61`): Success/positive at 16% opacity for badges
|
||||||
|
- **Green Dark** (`#026b3f`): Badge text
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
### Font Families
|
||||||
|
- **Display**: `Kraken-Brand`, fallbacks: `IBM Plex Sans, Helvetica, Arial`
|
||||||
|
- **UI / Body**: `Kraken-Product`, fallbacks: `Helvetica Neue, Helvetica, Arial`
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Role | Font | Size | Weight | Line Height | Letter Spacing |
|
||||||
|
|------|------|------|--------|-------------|----------------|
|
||||||
|
| Display Hero | Kraken-Brand | 48px | 700 | 1.17 | -1px |
|
||||||
|
| Section Heading | Kraken-Brand | 36px | 700 | 1.22 | -0.5px |
|
||||||
|
| Sub-heading | Kraken-Brand | 28px | 700 | 1.29 | -0.5px |
|
||||||
|
| Feature Title | Kraken-Product | 22px | 600 | 1.20 | normal |
|
||||||
|
| Body | Kraken-Product | 16px | 400 | 1.38 | normal |
|
||||||
|
| Body Medium | Kraken-Product | 16px | 500 | 1.38 | normal |
|
||||||
|
| Button | Kraken-Product | 16px | 500–600 | 1.38 | normal |
|
||||||
|
| Caption | Kraken-Product | 14px | 400–700 | 1.43–1.71 | normal |
|
||||||
|
| Small | Kraken-Product | 12px | 400–500 | 1.33 | normal |
|
||||||
|
| Micro | Kraken-Product | 7px | 500 | 1.00 | uppercase |
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Primary Purple**
|
||||||
|
- Background: `#7132f5`
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Padding: 13px 16px
|
||||||
|
- Radius: 12px
|
||||||
|
|
||||||
|
**Purple Outlined**
|
||||||
|
- Background: `#ffffff`
|
||||||
|
- Text: `#5741d8`
|
||||||
|
- Border: `1px solid #5741d8`
|
||||||
|
- Radius: 12px
|
||||||
|
|
||||||
|
**Purple Subtle**
|
||||||
|
- Background: `rgba(133,91,251,0.16)`
|
||||||
|
- Text: `#7132f5`
|
||||||
|
- Padding: 8px
|
||||||
|
- Radius: 12px
|
||||||
|
|
||||||
|
**White Button**
|
||||||
|
- Background: `#ffffff`
|
||||||
|
- Text: `#101114`
|
||||||
|
- Radius: 10px
|
||||||
|
- Shadow: `rgba(0,0,0,0.03) 0px 4px 24px`
|
||||||
|
|
||||||
|
**Secondary Gray**
|
||||||
|
- Background: `rgba(148,151,169,0.08)`
|
||||||
|
- Text: `#101114`
|
||||||
|
- Radius: 12px
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
- Success: `rgba(20,158,97,0.16)` bg, `#026b3f` text, 6px radius
|
||||||
|
- Neutral: `rgba(104,107,130,0.12)` bg, `#484b5e` text, 8px radius
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
### Spacing: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 13px, 15px, 16px, 20px, 24px, 25px
|
||||||
|
### Border Radius: 3px, 6px, 8px, 10px, 12px, 16px, 9999px, 50%
|
||||||
|
|
||||||
|
## 6. Depth & Elevation
|
||||||
|
- Subtle: `rgba(0,0,0,0.03) 0px 4px 24px`
|
||||||
|
- Micro: `rgba(16,24,40,0.04) 0px 1px 4px`
|
||||||
|
|
||||||
|
## 7. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Use Kraken Purple (#7132f5) for CTAs and links
|
||||||
|
- Apply 12px radius on all buttons
|
||||||
|
- Use Kraken-Brand for headings, Kraken-Product for body
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't use pill buttons — 12px is the max radius for buttons
|
||||||
|
- Don't use other purples outside the defined scale
|
||||||
|
|
||||||
|
## 8. Responsive Behavior
|
||||||
|
Breakpoints: 375px, 425px, 640px, 768px, 1024px, 1280px, 1536px
|
||||||
|
|
||||||
|
## 9. Agent Prompt Guide
|
||||||
|
|
||||||
|
### Quick Color Reference
|
||||||
|
- Brand: Kraken Purple (`#7132f5`)
|
||||||
|
- Dark variant: `#5741d8`
|
||||||
|
- Text: Near Black (`#101114`)
|
||||||
|
- Secondary text: `#9497a9`
|
||||||
|
- Background: White (`#ffffff`)
|
||||||
|
|
||||||
|
### Example Component Prompts
|
||||||
|
- "Create hero: white background. Kraken-Brand 48px weight 700, letter-spacing -1px. Purple CTA (#7132f5, 12px radius, 13px 16px padding)."
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>aplp.frontend.lms</title>
|
<title>APLP LMS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+1975
-6
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -10,11 +10,27 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8",
|
||||||
|
"react-icons": "^5.7.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
|||||||
+405
-164
@@ -1,184 +1,425 @@
|
|||||||
.counter {
|
@import 'tailwindcss';
|
||||||
font-size: 16px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
&:hover {
|
@theme {
|
||||||
border-color: var(--accent-border);
|
--color-background: var(--color-app-bg);
|
||||||
}
|
--color-foreground: var(--color-app-text);
|
||||||
&:focus-visible {
|
--color-card: var(--color-app-surface);
|
||||||
outline: 2px solid var(--accent);
|
--color-card-foreground: var(--color-app-text);
|
||||||
outline-offset: 2px;
|
--color-primary: var(--color-app-primary);
|
||||||
}
|
--color-primary-foreground: var(--color-app-primary-contrast);
|
||||||
|
--color-secondary: var(--color-app-surface-muted);
|
||||||
|
--color-secondary-foreground: var(--color-app-text);
|
||||||
|
--color-muted: var(--color-app-surface-muted);
|
||||||
|
--color-muted-foreground: var(--color-app-text-muted);
|
||||||
|
--color-accent: var(--color-app-interactive-hover);
|
||||||
|
--color-accent-foreground: var(--color-app-text);
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: var(--color-app-border);
|
||||||
|
--color-input: var(--color-app-border-input);
|
||||||
|
--color-ring: var(--color-app-focus);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--color-app-bg: #ffffff;
|
||||||
|
--color-app-surface: #ffffff;
|
||||||
|
--color-app-surface-muted: rgba(148,151,169,0.08);
|
||||||
|
--color-app-border: #dedee5;
|
||||||
|
--color-app-border-input: #dedee5;
|
||||||
|
--color-app-text: #101114;
|
||||||
|
--color-app-text-strong: #101114;
|
||||||
|
--color-app-text-muted: #9497a9;
|
||||||
|
--color-app-primary: #7132f5;
|
||||||
|
--color-app-primary-dark: #5741d8;
|
||||||
|
--color-app-primary-soft: rgba(133,91,251,0.16);
|
||||||
|
--color-app-primary-contrast: #ffffff;
|
||||||
|
--color-app-interactive-hover: rgba(148,151,169,0.08);
|
||||||
|
--color-app-interactive-active: rgba(133,91,251,0.16);
|
||||||
|
--color-app-focus: #7132f5;
|
||||||
|
--color-app-success: rgba(20,158,97,0.16);
|
||||||
|
--color-app-success-text: #026b3f;
|
||||||
|
--color-app-neutral-badge: rgba(104,107,130,0.12);
|
||||||
|
--color-app-neutral-badge-text: #484b5e;
|
||||||
|
--shadow-app-panel: rgba(0,0,0,0.03) 0px 4px 24px;
|
||||||
|
--shadow-app-micro: rgba(16,24,40,0.04) 0px 1px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.app-shell {
|
||||||
position: relative;
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||||
.base,
|
min-height: 100vh;
|
||||||
.framework,
|
transition: grid-template-columns 180ms ease;
|
||||||
.vite {
|
|
||||||
inset-inline: 0;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.base {
|
|
||||||
width: 170px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework,
|
|
||||||
.vite {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.framework {
|
|
||||||
z-index: 1;
|
|
||||||
top: 34px;
|
|
||||||
height: 28px;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
|
||||||
scale(1.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vite {
|
|
||||||
z-index: 0;
|
|
||||||
top: 107px;
|
|
||||||
height: 26px;
|
|
||||||
width: auto;
|
|
||||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
|
||||||
scale(0.8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#center {
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 25px;
|
padding: 24px 16px;
|
||||||
place-content: center;
|
border-right: none;
|
||||||
place-items: center;
|
background: var(--color-app-primary);
|
||||||
flex-grow: 1;
|
overflow: hidden;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 32px 20px 24px;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#next-steps {
|
.sidebar__brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-top: 1px solid var(--border);
|
align-items: center;
|
||||||
text-align: left;
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
& > div {
|
border: none;
|
||||||
flex: 1 1 0;
|
border-radius: 10px;
|
||||||
padding: 32px;
|
margin-bottom: 20px;
|
||||||
@media (max-width: 1024px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#docs {
|
.sidebar__logo {
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#next-steps ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 32px 0 0;
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--text-h);
|
|
||||||
font-size: 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--social-bg);
|
|
||||||
display: flex;
|
|
||||||
padding: 6px 12px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: box-shadow 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.button-icon {
|
|
||||||
height: 18px;
|
|
||||||
width: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
li {
|
|
||||||
flex: 1 1 calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#spacer {
|
|
||||||
height: 88px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticks {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-app-primary) !important;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__logo svg {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand-text,
|
||||||
|
.sidebar__link-text {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 1;
|
||||||
|
transition:
|
||||||
|
opacity 140ms ease,
|
||||||
|
width 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand strong,
|
||||||
|
.sidebar__brand span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand strong {
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__brand span {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link:focus-visible {
|
||||||
|
outline: 2px solid var(--color-app-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
&::before,
|
.sidebar__footer-btn:hover {
|
||||||
&::after {
|
color: #ffffff;
|
||||||
content: '';
|
background: rgba(255,255,255,0.1);
|
||||||
position: absolute;
|
}
|
||||||
top: -4.5px;
|
|
||||||
border: 5px solid transparent;
|
.sidebar__footer-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--color-app-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link:hover .sidebar__link-icon {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--active:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--active:hover .sidebar__link-icon {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--active .sidebar__link-icon {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-app-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu */
|
||||||
|
.sidebar__group {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--parent {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link--parent.sidebar__link {
|
||||||
|
font: inherit;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__chevron--open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__submenu {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding-left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
color: rgba(255,255,255,0.65);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
color 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
border-left-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__sublink--active {
|
||||||
|
color: #ffffff;
|
||||||
|
border-left-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--color-app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.content-panel {
|
||||||
|
border: 1px solid var(--color-app-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-app-surface);
|
||||||
|
box-shadow: var(--shadow-app-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card span {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-app-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--color-app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
.sidebar {
|
||||||
left: 0;
|
position: static;
|
||||||
border-left-color: var(--border);
|
height: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--color-app-border);
|
||||||
}
|
}
|
||||||
&::after {
|
|
||||||
right: 0;
|
.sidebar__brand {
|
||||||
border-right-color: var(--border);
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__footer-btn {
|
||||||
|
width: auto;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-115
@@ -1,122 +1,45 @@
|
|||||||
import { useState } from 'react'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import reactLogo from './assets/react.svg'
|
import MainLayout from './layouts/MainLayout.jsx';
|
||||||
import viteLogo from './assets/vite.svg'
|
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
||||||
import heroImg from './assets/hero.png'
|
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
||||||
import './App.css'
|
import CourseFormPage from './pages/Course/CourseFormPage.jsx';
|
||||||
|
import LessonPage from './pages/lesson/LessonPage.jsx';
|
||||||
|
import LessonFormPage from './pages/lesson/LessonFormPage.jsx';
|
||||||
|
import MCQPage from './pages/question-bank/MCQPage.jsx';
|
||||||
|
import ParagraphPage from './pages/question-bank/ParagraphPage.jsx';
|
||||||
|
import UsersPage from './pages/user/UsersPage.jsx';
|
||||||
|
import MasterdataPage from './pages/settings/MasterdataPage.jsx';
|
||||||
|
import RolePage from './pages/settings/RolePage.jsx';
|
||||||
|
import PermissionPage from './pages/settings/PermissionPage.jsx';
|
||||||
|
import { Toaster } from './components/ui/sonner.jsx';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section id="center">
|
<BrowserRouter>
|
||||||
<div className="hero">
|
<Routes>
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<Route element={<MainLayout />}>
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
<Route index element={<Navigate to="/courses" replace />} />
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
<Route path="/courses" element={<CoursesPage />} />
|
||||||
</div>
|
<Route path="/courses/new" element={<CourseFormPage />} />
|
||||||
<div>
|
<Route path="/courses/:courseId/detail" element={<CourseDetail />} />
|
||||||
<h1>Get started</h1>
|
<Route path="/courses/:courseId/edit" element={<CourseFormPage />} />
|
||||||
<p>
|
<Route path="/courses/:courseId/lessons/new" element={<LessonFormPage />} />
|
||||||
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
|
<Route path="/courses/:courseId/lessons/:lessonId/edit" element={<LessonFormPage />} />
|
||||||
</p>
|
<Route path="/lessons" element={<LessonPage />} />
|
||||||
</div>
|
<Route path="/question-bank/mcq" element={<MCQPage />} />
|
||||||
<button
|
<Route path="/question-bank/paragraph" element={<ParagraphPage />} />
|
||||||
type="button"
|
<Route path="/users" element={<UsersPage />} />
|
||||||
className="counter"
|
<Route path="/settings/masterdata" element={<MasterdataPage />} />
|
||||||
onClick={() => setCount((count) => count + 1)}
|
<Route path="/settings/role" element={<RolePage />} />
|
||||||
>
|
<Route path="/settings/permission" element={<PermissionPage />} />
|
||||||
Count is {count}
|
</Route>
|
||||||
</button>
|
</Routes>
|
||||||
</section>
|
</BrowserRouter>
|
||||||
|
<Toaster />
|
||||||
<div className="ticks"></div>
|
|
||||||
|
|
||||||
<section id="next-steps">
|
|
||||||
<div id="docs">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Documentation</h2>
|
|
||||||
<p>Your questions, answered</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://vite.dev/" target="_blank">
|
|
||||||
<img className="logo" src={viteLogo} alt="" />
|
|
||||||
Explore Vite
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://react.dev/" target="_blank">
|
|
||||||
<img className="button-icon" src={reactLogo} alt="" />
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div id="social">
|
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
|
||||||
<use href="/icons.svg#social-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<h2>Connect with us</h2>
|
|
||||||
<p>Join the Vite community</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#github-icon"></use>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://chat.vite.dev/" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#discord-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://x.com/vite_js" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#x-icon"></use>
|
|
||||||
</svg>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
|
||||||
<svg
|
|
||||||
className="button-icon"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use href="/icons.svg#bluesky-icon"></use>
|
|
||||||
</svg>
|
|
||||||
Bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="ticks"></div>
|
|
||||||
<section id="spacer"></section>
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Card } from '../components/ui/card';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { HiOutlinePencil, HiOutlineEllipsisVertical } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
Active: 'bg-app-success text-app-success-text',
|
||||||
|
Inactive: 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
|
};
|
||||||
|
|
||||||
|
function CourseCard({ course, image, handleClick, onEdit }) {
|
||||||
|
const { code, title, description, status, lastUpdate } = course;
|
||||||
|
|
||||||
|
const formattedDate = new Date(lastUpdate).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="w-full max-w-100 overflow-hidden rounded-xl border border-app-border bg-white"
|
||||||
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="relative h-48 cursor-pointer overflow-hidden border-b border-app-border">
|
||||||
|
<img src={image} alt={title} className="h-full w-full object-cover" />
|
||||||
|
|
||||||
|
<div className="absolute left-4 top-4 rounded-md border border-app-border bg-white px-3 py-1.5">
|
||||||
|
<span className="text-lg font-semibold text-app-text">{code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="cursor-pointer px-5 py-5">
|
||||||
|
<h3 className="mb-3 text-xl font-bold leading-tight text-app-text">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="line-clamp-2 text-md leading-8 text-app-text-muted">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className="mx-5 flex items-center justify-between border-t border-app-border px-0 py-5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`rounded-[6px] px-2.5 py-1 text-sm font-medium ${
|
||||||
|
statusStyles[status] ?? 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-sm text-app-text-muted">Updated {formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
aria-label={`Edit ${title}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit && onEdit(); }}
|
||||||
|
>
|
||||||
|
<HiOutlinePencil className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
aria-label={`More actions for ${title}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<HiOutlineEllipsisVertical className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourseCard;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Button = React.forwardRef(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { HiOutlineMagnifyingGlass } from 'react-icons/hi2';
|
||||||
|
import { Input } from './input';
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
wrapper: {
|
||||||
|
overflow: 'auto',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #dedee5',
|
||||||
|
boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px',
|
||||||
|
background: '#ffffff',
|
||||||
|
maxHeight: 'calc(100vh - 325px)',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||||
|
},
|
||||||
|
thead: {
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
background: '#f8f9fa',
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
padding: '14px 16px',
|
||||||
|
textAlign: 'left',
|
||||||
|
borderBottom: '1px solid #dedee5',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#9497a9',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
background: 'rgba(148,151,169,0.08)',
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid #dedee5',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#101114',
|
||||||
|
lineHeight: 1.43,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function DataTable({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
searchKeys,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
perPage = 10,
|
||||||
|
emptyText = 'No results found.',
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const handleSearch = (e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = data.filter((row) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return searchKeys.some((key) => {
|
||||||
|
const val = row[key];
|
||||||
|
return val != null && String(val).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
|
||||||
|
const paged = filtered.slice((page - 1) * perPage, page * perPage);
|
||||||
|
const startIdx = filtered.length === 0 ? 0 : (page - 1) * perPage + 1;
|
||||||
|
const endIdx = Math.min(page * perPage, filtered.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{searchKeys && searchKeys.length > 0 && (
|
||||||
|
<div style={{ position: 'relative', width: 280, marginBottom: 12 }}>
|
||||||
|
<HiOutlineMagnifyingGlass style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#9497a9', width: 16, height: 16 }} />
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
style={{ paddingLeft: 34, borderRadius: 10, border: '1px solid #dedee5', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={styles.wrapper}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead style={styles.thead}>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col.key} style={{ ...styles.th, width: col.width }}>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((row, idx) => (
|
||||||
|
<tr
|
||||||
|
key={row.id ?? idx}
|
||||||
|
style={{
|
||||||
|
transition: 'background 120ms ease',
|
||||||
|
background: idx % 2 === 1 ? 'rgba(148,151,169,0.04)' : '#ffffff',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(113,50,245,0.06)'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = idx % 2 === 1 ? 'rgba(148,151,169,0.04)' : '#ffffff'; }}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} style={{ ...styles.td, ...(col.tdStyle || {}) }}>
|
||||||
|
{col.render
|
||||||
|
? col.render(row, (page - 1) * perPage + idx + 1)
|
||||||
|
: row[col.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{paged.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} style={{ ...styles.td, textAlign: 'center', color: '#9497a9', padding: 24 }}>
|
||||||
|
{emptyText}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginTop: 16, fontSize: 14, color: '#686b82' }}>
|
||||||
|
<span>
|
||||||
|
{startIdx}–{endIdx} of {filtered.length}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #dedee5',
|
||||||
|
background: page <= 1 ? 'rgba(148,151,169,0.08)' : '#ffffff',
|
||||||
|
color: page <= 1 ? '#9497a9' : '#101114',
|
||||||
|
cursor: page <= 1 ? 'default' : 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i + 1}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage(i + 1)}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
background: page === i + 1 ? '#7132f5' : 'transparent',
|
||||||
|
color: page === i + 1 ? '#ffffff' : '#686b82',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #dedee5',
|
||||||
|
background: page >= totalPages ? 'rgba(148,151,169,0.08)' : '#ffffff',
|
||||||
|
color: page >= totalPages ? '#9497a9' : '#101114',
|
||||||
|
cursor: page >= totalPages ? 'default' : 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataTable };
|
||||||
@@ -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,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef(({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef(
|
||||||
|
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const navigationItems = [
|
||||||
|
{
|
||||||
|
label: 'Course',
|
||||||
|
shortLabel: 'CO',
|
||||||
|
path: '/courses',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lesson',
|
||||||
|
shortLabel: 'LE',
|
||||||
|
path: '/lessons',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Question Bank',
|
||||||
|
shortLabel: 'QB',
|
||||||
|
path: '/question-bank',
|
||||||
|
children: [
|
||||||
|
{ label: 'MCQ', path: '/question-bank/mcq' },
|
||||||
|
{ label: 'Paragraph', path: '/question-bank/paragraph' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Users',
|
||||||
|
shortLabel: 'US',
|
||||||
|
path: '/users',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
shortLabel: 'ST',
|
||||||
|
path: '/settings',
|
||||||
|
children: [
|
||||||
|
{ label: 'Masterdata', path: '/settings/masterdata' },
|
||||||
|
{ label: 'Role', path: '/settings/role' },
|
||||||
|
{ label: 'Permission', path: '/settings/permission' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
+21
-94
@@ -1,111 +1,38 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
--sidebar-width: 264px;
|
||||||
--text-h: #08060d;
|
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
font-family:
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
sans-serif;
|
||||||
|
color: var(--color-app-text);
|
||||||
font: 18px/145% var(--sans);
|
background: var(--color-app-bg);
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
body {
|
||||||
h2 {
|
min-width: 320px;
|
||||||
font-family: var(--heading);
|
min-height: 100vh;
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
a {
|
||||||
.counter {
|
color: inherit;
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
button,
|
||||||
font-size: 15px;
|
input,
|
||||||
line-height: 135%;
|
textarea,
|
||||||
padding: 4px 8px;
|
select {
|
||||||
background: var(--code-bg);
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
HiChevronRight,
|
||||||
|
HiAcademicCap,
|
||||||
|
HiBookOpen,
|
||||||
|
HiDocumentText,
|
||||||
|
HiQuestionMarkCircle,
|
||||||
|
HiUserGroup,
|
||||||
|
HiCog6Tooth,
|
||||||
|
HiOutlineUserCircle,
|
||||||
|
} from 'react-icons/hi2'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { navigationItems } from '../config/navigation.js'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from '../components/ui/dropdown-menu.jsx'
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
'/courses': HiBookOpen,
|
||||||
|
'/lessons': HiDocumentText,
|
||||||
|
'/question-bank': HiQuestionMarkCircle,
|
||||||
|
'/users': HiUserGroup,
|
||||||
|
'/settings': HiCog6Tooth,
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarItem({ item }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const hasChildren = item.children && item.children.length > 0
|
||||||
|
const location = useLocation()
|
||||||
|
const isChildActive = hasChildren && item.children.some((c) => location.pathname === c.path)
|
||||||
|
const Icon = iconMap[item.path]
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'sidebar__link sidebar__link--active' : 'sidebar__link'
|
||||||
|
}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<span className="sidebar__link-icon">
|
||||||
|
{Icon ? <Icon size={18} /> : item.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span className="sidebar__link-text">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'sidebar__group' + (open || isChildActive ? ' sidebar__group--open' : '')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={'sidebar__link sidebar__link--parent'}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<span className="sidebar__link-icon">
|
||||||
|
{Icon ? <Icon size={18} /> : item.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span className="sidebar__link-text">{item.label}</span>
|
||||||
|
<HiChevronRight className={'sidebar__chevron' + (open ? ' sidebar__chevron--open' : '')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="sidebar__submenu">
|
||||||
|
{item.children.map((child) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.path}
|
||||||
|
to={child.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'sidebar__sublink sidebar__sublink--active' : 'sidebar__sublink'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="sidebar__sublink-text">{child.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar__brand">
|
||||||
|
<span className="sidebar__logo"><HiAcademicCap size={22} /></span>
|
||||||
|
<div className="sidebar__brand-text">
|
||||||
|
<strong>APLP LMS</strong>
|
||||||
|
<span>Learning platform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar__divider" />
|
||||||
|
|
||||||
|
<nav className="sidebar__nav" aria-label="Main navigation">
|
||||||
|
{navigationItems.map((item) => (
|
||||||
|
<SidebarItem key={item.path} item={item} />
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainLayout
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const payload = response.data;
|
||||||
|
if (payload && typeof payload === 'object' && payload.success === true && 'data' in payload) {
|
||||||
|
response.data = payload.data;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
const message = error.response?.data?.message || error.message || 'Something went wrong';
|
||||||
|
console.error('API Error:', message);
|
||||||
|
toast.error(message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Label } from '../../components/ui/label';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../../components/ui/tabs';
|
||||||
|
import { Separator } from '../../components/ui/separator';
|
||||||
|
import {
|
||||||
|
HiOutlinePlus,
|
||||||
|
HiOutlinePencil,
|
||||||
|
HiOutlineTrash,
|
||||||
|
HiOutlineEye,
|
||||||
|
HiOutlineChevronRight,
|
||||||
|
HiOutlineBookOpen,
|
||||||
|
HiOutlineVideoCamera,
|
||||||
|
HiOutlineDocumentText,
|
||||||
|
HiOutlineQuestionMarkCircle,
|
||||||
|
HiOutlineCodeBracket,
|
||||||
|
HiOutlineArrowUpTray,
|
||||||
|
HiOutlineFunnel,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import courseService from '@/services/courseService';
|
||||||
|
import lessonService from '@/services/lessonService';
|
||||||
|
import sectionService from '@/services/sectionService';
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
video: { label: 'Video', icon: HiOutlineVideoCamera, color: 'bg-blue-100 text-blue-700' },
|
||||||
|
text: { label: 'Text', icon: HiOutlineDocumentText, color: 'bg-purple-100 text-purple-700' },
|
||||||
|
quiz: { label: 'Quiz', icon: HiOutlineQuestionMarkCircle, color: 'bg-orange-100 text-orange-700' },
|
||||||
|
embed: { label: 'Embed', icon: HiOutlineCodeBracket, color: 'bg-gray-100 text-gray-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
Published: 'bg-app-success text-app-success-text',
|
||||||
|
Draft: 'bg-app-neutral-badge text-app-neutral-badge-text',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds) return '00:00';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCourse(c) {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
code: c.courseCode,
|
||||||
|
title: c.title,
|
||||||
|
description: c.description,
|
||||||
|
status: c.isPublished ? 'Active' : 'Inactive',
|
||||||
|
lastUpdate: c.updatedAt,
|
||||||
|
image: c.image,
|
||||||
|
isPublished: c.isPublished,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CourseDetail() {
|
||||||
|
const { courseId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [course, setCourse] = useState(null);
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
|
const handleTogglePublish = async () => {
|
||||||
|
try {
|
||||||
|
await courseService.update(courseId, {
|
||||||
|
courseCode: course.code,
|
||||||
|
title: course.title,
|
||||||
|
description: course.description,
|
||||||
|
image: course.image,
|
||||||
|
isPublished: !course.isPublished,
|
||||||
|
});
|
||||||
|
setCourse((prev) => ({ ...prev, isPublished: !prev.isPublished, status: prev.isPublished ? 'Inactive' : 'Active' }));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to update course');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLesson = async (lessonId) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this lesson?')) return;
|
||||||
|
try {
|
||||||
|
await lessonService.delete(lessonId);
|
||||||
|
setLessons((prev) => prev.filter((l) => l.id !== lessonId));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete lesson');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const courseData = await courseService.getById(courseId);
|
||||||
|
setCourse(mapCourse(courseData));
|
||||||
|
|
||||||
|
const allLessons = await lessonService.getAll();
|
||||||
|
const courseLessons = allLessons.filter((l) => l.courseId === Number(courseId));
|
||||||
|
|
||||||
|
const lessonsWithSections = await Promise.all(
|
||||||
|
courseLessons.map(async (lesson) => {
|
||||||
|
const sections = await sectionService.getByLessonId(lesson.id);
|
||||||
|
const firstSection = sections[0];
|
||||||
|
return {
|
||||||
|
id: lesson.id,
|
||||||
|
title: lesson.title,
|
||||||
|
position: lesson.position,
|
||||||
|
type: firstSection?.type || 'text',
|
||||||
|
duration: firstSection?.type === 'video'
|
||||||
|
? formatDuration(firstSection.durationSeconds)
|
||||||
|
: firstSection?.type === 'text'
|
||||||
|
? `~${Math.max(1, Math.ceil((firstSection.content || '').split(/\s+/).length / 200))} min read`
|
||||||
|
: firstSection?.type === 'quiz'
|
||||||
|
? `${firstSection.durationSeconds ? Math.floor(firstSection.durationSeconds / 60) : 10}:00`
|
||||||
|
: firstSection?.type === 'embed'
|
||||||
|
? 'iFrame'
|
||||||
|
: '00:00',
|
||||||
|
status: lesson.isPublished ? 'Published' : 'Draft',
|
||||||
|
isFreePreview: firstSection?.isFreePreview || false,
|
||||||
|
updatedAt: lesson.updatedAt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setLessons(lessonsWithSections);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load course');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !course) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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" />
|
||||||
|
<p className="text-lg font-medium">{error || 'Course not found'}</p>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="mt-2 text-app-primary"
|
||||||
|
onClick={() => navigate('/courses')}
|
||||||
|
>
|
||||||
|
Back to Courses
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedCount = lessons.filter((l) => l.status === 'Published').length;
|
||||||
|
const draftCount = lessons.filter((l) => l.status === 'Draft').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/courses')}
|
||||||
|
className="hover:text-app-primary transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HiOutlineBookOpen className="w-4 h-4" />
|
||||||
|
Courses
|
||||||
|
</button>
|
||||||
|
<HiOutlineChevronRight className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-medium text-app-text">{course.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-row items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-xl font-bold text-app-text">{course.title}</h2>
|
||||||
|
<Badge
|
||||||
|
className={`rounded-[6px] text-xs font-medium ${
|
||||||
|
course.status === 'Active'
|
||||||
|
? 'bg-app-success text-app-success-text'
|
||||||
|
: 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{course.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
onClick={() => navigate(`/courses/${courseId}/edit`)}
|
||||||
|
>
|
||||||
|
<HiOutlinePencil className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
onClick={handleTogglePublish}
|
||||||
|
>
|
||||||
|
<HiOutlineArrowUpTray className="w-4 h-4 mr-2" />
|
||||||
|
{course.isPublished ? 'Inactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||||
|
onClick={handleTogglePublish}
|
||||||
|
>
|
||||||
|
{course.isPublished ? 'Unpublish' : 'Publish'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="mb-6" />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="overview" className="gap-2">
|
||||||
|
<HiOutlineBookOpen className="w-4 h-4" />
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="lessons" className="gap-2">
|
||||||
|
<HiOutlineDocumentText className="w-4 h-4" />
|
||||||
|
Lessons
|
||||||
|
<Badge className="ml-1 rounded-full bg-app-primary-soft text-app-primary text-[10px] px-1.5 py-0">
|
||||||
|
{lessons.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Overview Tab */}
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<Card
|
||||||
|
className="w-full max-w-2xl overflow-hidden rounded-xl border border-app-border bg-white"
|
||||||
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
|
>
|
||||||
|
<div className="p-5 border-b border-app-border">
|
||||||
|
<h4 className="font-semibold text-app-text">Course Details</h4>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 pb-5 pt-4">
|
||||||
|
<div className="mb-4 w-full max-w-sm">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Course Code
|
||||||
|
</Label>
|
||||||
|
<Input value={course.code} readOnly className="bg-app-surface-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</Label>
|
||||||
|
<Badge
|
||||||
|
className={`rounded-[6px] text-xs font-medium ${
|
||||||
|
course.status === 'Active'
|
||||||
|
? 'bg-app-success text-app-success-text'
|
||||||
|
: 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{course.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 w-full max-w-sm">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
className="max-h-40 bg-app-surface-muted"
|
||||||
|
placeholder="Course description..."
|
||||||
|
rows={4}
|
||||||
|
value={course.description}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-sm text-app-text-muted">
|
||||||
|
<span>
|
||||||
|
Last updated:{' '}
|
||||||
|
<span className="font-medium text-app-text">
|
||||||
|
{course.lastUpdate ? new Date(course.lastUpdate).toLocaleDateString('en-GB') : '-'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Total lessons:{' '}
|
||||||
|
<span className="font-medium text-app-text">{lessons.length}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Lessons Tab */}
|
||||||
|
<TabsContent value="lessons">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-app-text">Lessons</h3>
|
||||||
|
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-xs">
|
||||||
|
{lessons.length} items
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-app-text-muted mt-0.5">
|
||||||
|
{publishedCount} published, {draftCount} drafts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
>
|
||||||
|
<HiOutlineFunnel className="w-4 h-4 mr-1.5" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
|
||||||
|
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
|
||||||
|
>
|
||||||
|
<HiOutlinePlus className="w-4 h-4 mr-1.5" />
|
||||||
|
Add Lesson
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lessons Table */}
|
||||||
|
{lessons.length === 0 ? (
|
||||||
|
<Card
|
||||||
|
className="rounded-xl border border-app-border bg-white p-12 flex flex-col items-center justify-center text-center"
|
||||||
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-app-surface-muted flex items-center justify-center mb-4">
|
||||||
|
<HiOutlineBookOpen className="w-8 h-8 text-app-text-muted/40" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-medium text-app-text mb-1">No lessons yet</p>
|
||||||
|
<p className="text-sm text-app-text-muted mb-4">
|
||||||
|
Start building your course by adding the first lesson.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||||
|
onClick={() => navigate(`/courses/${courseId}/lessons/new`)}
|
||||||
|
>
|
||||||
|
<HiOutlinePlus className="w-4 h-4 mr-2" />
|
||||||
|
Add First Lesson
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
className="rounded-xl border border-app-border bg-white overflow-hidden"
|
||||||
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
|
>
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="grid grid-cols-12 gap-4 px-5 py-3 bg-app-surface-muted/50 border-b border-app-border text-xs font-semibold text-app-text-muted uppercase tracking-wider">
|
||||||
|
<div className="col-span-1">#</div>
|
||||||
|
<div className="col-span-1">Type</div>
|
||||||
|
<div className="col-span-4">Title</div>
|
||||||
|
<div className="col-span-2">Duration</div>
|
||||||
|
<div className="col-span-2">Status</div>
|
||||||
|
<div className="col-span-1">Preview</div>
|
||||||
|
<div className="col-span-1 text-right">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Rows */}
|
||||||
|
{lessons.map((lesson) => {
|
||||||
|
const tc = typeConfig[lesson.type] || typeConfig.text;
|
||||||
|
const TypeIcon = tc.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lesson.id}
|
||||||
|
className="grid grid-cols-12 gap-4 px-5 py-3.5 border-b border-app-border/50 last:border-b-0 items-center hover:bg-app-surface-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="col-span-1 text-sm text-app-text-muted font-mono">
|
||||||
|
{lesson.position}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-semibold uppercase ${tc.color}`}
|
||||||
|
>
|
||||||
|
<TypeIcon className="w-3 h-3" />
|
||||||
|
{tc.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-4">
|
||||||
|
<span className="text-sm font-medium text-app-text truncate block">
|
||||||
|
{lesson.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-app-text-muted font-mono">
|
||||||
|
{lesson.duration}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Badge
|
||||||
|
className={`rounded-[6px] text-[11px] font-medium ${
|
||||||
|
statusStyles[lesson.status]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lesson.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
{lesson.isFreePreview && (
|
||||||
|
<Badge className="rounded-full bg-blue-50 text-blue-600 text-[10px]">
|
||||||
|
Free
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/courses/${courseId}/lessons/${lesson.id}/edit`)
|
||||||
|
}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<HiOutlinePencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-primary hover:bg-app-primary-soft transition-colors"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<HiOutlineEye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLesson(lesson.id)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<HiOutlineTrash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourseDetail;
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Label } from '../../components/ui/label';
|
||||||
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
|
import { Switch } from '../../components/ui/switch';
|
||||||
|
import { Separator } from '../../components/ui/separator';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
HiOutlineArrowLeft,
|
||||||
|
HiOutlineBookOpen,
|
||||||
|
HiOutlineChevronRight,
|
||||||
|
HiOutlinePhoto,
|
||||||
|
HiOutlineTrash,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import courseService from '@/services/courseService';
|
||||||
|
import fileService from '@/services/fileService';
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
function CourseFormPage() {
|
||||||
|
const { courseId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEditing = Boolean(courseId);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
courseCode: '',
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
isPublished: false,
|
||||||
|
});
|
||||||
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(isEditing);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
try {
|
||||||
|
const data = await courseService.getById(courseId);
|
||||||
|
setForm({
|
||||||
|
courseCode: data.courseCode || '',
|
||||||
|
title: data.title || '',
|
||||||
|
slug: data.slug || '',
|
||||||
|
description: data.description || '',
|
||||||
|
image: data.image || '',
|
||||||
|
isPublished: data.isPublished || false,
|
||||||
|
});
|
||||||
|
setImageUrl(data.imageUrl || data.image || '');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load course');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCourse();
|
||||||
|
}, [courseId, isEditing]);
|
||||||
|
|
||||||
|
const handleChange = (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 slug = form.title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/đ/g, 'd')
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim();
|
||||||
|
handleChange('slug', slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (publish) => {
|
||||||
|
if (!form.courseCode.trim() || !form.title.trim()) {
|
||||||
|
setError('Course code and title are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = { ...form, isPublished: publish };
|
||||||
|
if (isEditing) {
|
||||||
|
await courseService.update(courseId, payload);
|
||||||
|
} else {
|
||||||
|
const created = await courseService.create(payload);
|
||||||
|
navigate(`/courses/${created.id}/detail`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/courses/${courseId}/detail`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to save course');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/courses')}
|
||||||
|
className="hover:text-app-primary transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HiOutlineBookOpen className="w-4 h-4" />
|
||||||
|
Courses
|
||||||
|
</button>
|
||||||
|
<HiOutlineChevronRight className="w-3.5 h-3.5" />
|
||||||
|
<span className="font-medium text-app-text">
|
||||||
|
{isEditing ? 'Edit Course' : 'New Course'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-row items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-app-text">
|
||||||
|
{isEditing ? 'Edit Course' : 'Create New Course'}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
onClick={() => navigate(isEditing ? `/courses/${courseId}/detail` : '/courses')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
onClick={() => handleSave(false)}
|
||||||
|
disabled={saving || uploading}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Draft'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||||
|
onClick={() => handleSave(true)}
|
||||||
|
disabled={saving || uploading}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="mb-6" />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="rounded-xl border border-app-border bg-white overflow-hidden"
|
||||||
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
|
>
|
||||||
|
<div className="p-5 border-b border-app-border">
|
||||||
|
<h4 className="font-semibold text-app-text">Course Details</h4>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Course Code <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. LDR-101"
|
||||||
|
value={form.courseCode}
|
||||||
|
onChange={(e) => handleChange('courseCode', e.target.value)}
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Title <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Course title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => handleChange('title', e.target.value)}
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Label className="block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Slug
|
||||||
|
</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generateSlug}
|
||||||
|
className="text-app-primary hover:underline text-xs font-medium"
|
||||||
|
>
|
||||||
|
Auto-generate from title
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
|
||||||
|
<span className="px-4 py-2 text-app-text-muted font-mono text-sm bg-app-border/30 flex items-center select-none">
|
||||||
|
/courses/
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) => handleChange('slug', e.target.value)}
|
||||||
|
className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Course description..."
|
||||||
|
rows={4}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Image
|
||||||
|
</Label>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Upload course image"
|
||||||
|
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 className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-sm text-app-text">Published</span>
|
||||||
|
<span className="text-xs text-app-text-muted">Make this course visible to students</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.isPublished}
|
||||||
|
onCheckedChange={(checked) => handleChange('isPublished', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CourseFormPage;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import CourseCard from '../../components/CourseCard.jsx';
|
||||||
|
import { Button } from '../../components/ui/button.jsx';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs.jsx';
|
||||||
|
import { Input } from '../../components/ui/input.jsx';
|
||||||
|
import { Separator } from '../../components/ui/separator.jsx';
|
||||||
|
import { HiOutlineFunnel, HiOutlinePlus, HiOutlineMagnifyingGlass } from 'react-icons/hi2';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import courseService from '@/services/courseService';
|
||||||
|
|
||||||
|
function mapCourse(c) {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
code: c.courseCode,
|
||||||
|
title: c.title,
|
||||||
|
description: c.description,
|
||||||
|
status: c.isPublished ? 'Active' : 'Inactive',
|
||||||
|
lastUpdate: c.updatedAt,
|
||||||
|
image: c.imageUrl || c.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoursePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [courses, setCourses] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
try {
|
||||||
|
const data = await courseService.getAll();
|
||||||
|
setCourses(data.map(mapCourse));
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load courses');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCourses();
|
||||||
|
}, [reloadKey]);
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
setReloadKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = courses.filter((c) => {
|
||||||
|
const matchTab =
|
||||||
|
activeTab === 'all' ||
|
||||||
|
(activeTab === 'active' && c.status === 'Active') ||
|
||||||
|
(activeTab === 'inactive' && c.status === 'Inactive');
|
||||||
|
const matchSearch =
|
||||||
|
!search ||
|
||||||
|
c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.code.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchTab && matchSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (course) => {
|
||||||
|
navigate(`/courses/${course.id}/detail`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-[28px] font-bold leading-[1.29] tracking-tight text-app-text">
|
||||||
|
Courses
|
||||||
|
</h3>
|
||||||
|
<p className="text-app-text-muted">
|
||||||
|
Manage and organize your learning content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
>
|
||||||
|
<HiOutlineFunnel className="mr-2 h-5 w-5" />
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||||
|
onClick={() => navigate('/courses/new')}
|
||||||
|
>
|
||||||
|
<HiOutlinePlus className="mr-2 h-5 w-5" />
|
||||||
|
New Course
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="my-3 flex flex-row items-center justify-between rounded-xl border border-app-border bg-white p-3">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-fit">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
|
<TabsTrigger value="inactive">Inactive</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div className="relative w-100">
|
||||||
|
<HiOutlineMagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-app-text-muted" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search course by title or code..."
|
||||||
|
className="pl-9"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading courses...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-app-border bg-white p-4">
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
<Button variant="link" className="text-app-primary" onClick={handleRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{filtered.map((course) => (
|
||||||
|
<CourseCard
|
||||||
|
key={course.id}
|
||||||
|
course={course}
|
||||||
|
image={course.image}
|
||||||
|
handleClick={() => handleClick(course)}
|
||||||
|
onEdit={() => navigate(`/courses/${course.id}/edit`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="col-span-full text-center py-20 text-app-text-muted">No courses found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoursePage;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import ActionBar from './components/ActionBar';
|
||||||
|
import LessonMetadataForm from './components/LessonMetadataForm';
|
||||||
|
import SectionManager from './components/SectionManager';
|
||||||
|
import LessonSidebar from './components/LessonSidebar';
|
||||||
|
import lessonService from '@/services/lessonService';
|
||||||
|
import sectionService from '@/services/sectionService';
|
||||||
|
|
||||||
|
const emptyMetadata = {
|
||||||
|
title: '',
|
||||||
|
position: 1,
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapSectionFromBE(s) {
|
||||||
|
const base = {
|
||||||
|
id: `section_${s.id}`,
|
||||||
|
serverId: s.id,
|
||||||
|
type: s.type,
|
||||||
|
title: s.title,
|
||||||
|
isFreePreview: s.isFreePreview,
|
||||||
|
position: s.position,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (s.type) {
|
||||||
|
case 'video':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
url: s.content || '',
|
||||||
|
duration: s.durationSeconds
|
||||||
|
? `${String(Math.floor(s.durationSeconds / 60)).padStart(2, '0')}:${String(s.durationSeconds % 60).padStart(2, '0')}`
|
||||||
|
: '',
|
||||||
|
thumbnail: '',
|
||||||
|
};
|
||||||
|
case 'text':
|
||||||
|
return { ...base, content: s.content || '' };
|
||||||
|
case 'quiz': {
|
||||||
|
let parsed = {};
|
||||||
|
try { parsed = JSON.parse(s.content || '{}'); } catch { /* ignore */ }
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
questionCount: parsed.questionCount || 5,
|
||||||
|
timeLimit: parsed.timeLimit || 10,
|
||||||
|
passScore: parsed.passScore || 80,
|
||||||
|
maxAttempts: parsed.maxAttempts || 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'embed':
|
||||||
|
return { ...base, embedUrl: s.content || '' };
|
||||||
|
default:
|
||||||
|
return { ...base, content: s.content || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSectionToBE(section, lessonId) {
|
||||||
|
const base = {
|
||||||
|
lessonId,
|
||||||
|
title: section.title,
|
||||||
|
type: section.type,
|
||||||
|
position: section.position,
|
||||||
|
isFreePreview: section.isFreePreview,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (section.type) {
|
||||||
|
case 'video': {
|
||||||
|
const parts = (section.duration || '00:00').split(':').map(Number);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
content: section.url || '',
|
||||||
|
durationSeconds: (parts[0] || 0) * 60 + (parts[1] || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'text':
|
||||||
|
return { ...base, content: section.content || '' };
|
||||||
|
case 'quiz':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
content: JSON.stringify({
|
||||||
|
questionCount: section.questionCount,
|
||||||
|
timeLimit: section.timeLimit,
|
||||||
|
passScore: section.passScore,
|
||||||
|
maxAttempts: section.maxAttempts,
|
||||||
|
}),
|
||||||
|
durationSeconds: (section.timeLimit || 10) * 60,
|
||||||
|
};
|
||||||
|
case 'embed':
|
||||||
|
return { ...base, content: section.embedUrl || '' };
|
||||||
|
default:
|
||||||
|
return { ...base, content: section.content || '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LessonFormPage() {
|
||||||
|
const { courseId, lessonId } = useParams();
|
||||||
|
const isEditing = Boolean(lessonId);
|
||||||
|
|
||||||
|
const [metadata, setMetadata] = useState(emptyMetadata);
|
||||||
|
const [sections, setSections] = useState([]);
|
||||||
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(isEditing);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
|
||||||
|
const fetchLesson = async () => {
|
||||||
|
try {
|
||||||
|
const lesson = await lessonService.getById(lessonId);
|
||||||
|
setMetadata({
|
||||||
|
title: lesson.title,
|
||||||
|
position: lesson.position,
|
||||||
|
slug: lesson.slug || '',
|
||||||
|
description: lesson.description || '',
|
||||||
|
});
|
||||||
|
setIsPublished(lesson.isPublished);
|
||||||
|
|
||||||
|
const sectionData = await sectionService.getByLessonId(lessonId);
|
||||||
|
setSections(sectionData.sort((a, b) => a.position - b.position).map(mapSectionFromBE));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load lesson');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchLesson();
|
||||||
|
}, [lessonId, isEditing]);
|
||||||
|
|
||||||
|
const handleSave = async (publish) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const lessonPayload = {
|
||||||
|
courseId: Number(courseId),
|
||||||
|
title: metadata.title,
|
||||||
|
slug: metadata.slug,
|
||||||
|
description: metadata.description,
|
||||||
|
position: Number(metadata.position) || 1,
|
||||||
|
isPublished: publish,
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedLesson;
|
||||||
|
if (isEditing) {
|
||||||
|
savedLesson = await lessonService.update(lessonId, lessonPayload);
|
||||||
|
} else {
|
||||||
|
savedLesson = await lessonService.create(lessonPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedLessonId = savedLesson.id;
|
||||||
|
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
const section = sections[i];
|
||||||
|
const sectionPayload = mapSectionToBE({ ...section, position: i + 1 }, savedLessonId);
|
||||||
|
|
||||||
|
if (section.serverId) {
|
||||||
|
await sectionService.update(section.serverId, sectionPayload);
|
||||||
|
} else {
|
||||||
|
await sectionService.create(sectionPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(isEditing ? 'Lesson updated successfully!' : 'Lesson created successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to save lesson');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading lesson...</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<ActionBar
|
||||||
|
courseId={courseId}
|
||||||
|
isPublished={isPublished}
|
||||||
|
onTogglePublish={() => handleSave(true)}
|
||||||
|
onSaveDraft={() => handleSave(false)}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||||
|
<div className="lg:col-span-8 flex flex-col gap-8">
|
||||||
|
<LessonMetadataForm metadata={metadata} onChange={setMetadata} />
|
||||||
|
<SectionManager sections={sections} onChange={setSections} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<LessonSidebar
|
||||||
|
isPublished={isPublished}
|
||||||
|
onTogglePublish={() => setIsPublished(!isPublished)}
|
||||||
|
sections={sections}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonFormPage;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { DataTable } from '../../components/ui/data-table';
|
||||||
|
import lessonService from '@/services/lessonService';
|
||||||
|
|
||||||
|
function mapLesson(l) {
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
code: `LES${String(l.id).padStart(3, '0')}`,
|
||||||
|
name: l.title,
|
||||||
|
description: l.description || '',
|
||||||
|
courseId: l.courseId,
|
||||||
|
position: l.position,
|
||||||
|
isPublished: l.isPublished,
|
||||||
|
createdAt: l.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionBtn = {
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #5741d8',
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#5741d8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
transition: 'background 160ms ease, color 160ms ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBtnDelete = {
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: '1px solid #dedee5',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#9497a9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: 8,
|
||||||
|
transition: 'background 160ms ease, color 160ms ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LessonPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLessons = async () => {
|
||||||
|
try {
|
||||||
|
const data = await lessonService.getAll();
|
||||||
|
setLessons(data.map(mapLesson));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to load lessons');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchLessons();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this lesson?')) return;
|
||||||
|
try {
|
||||||
|
await lessonService.delete(id);
|
||||||
|
setLessons((prev) => prev.filter((l) => l.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete lesson');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: '#', label: '#', render: (_row, idx) => idx },
|
||||||
|
{
|
||||||
|
key: 'code',
|
||||||
|
label: 'LessonCode',
|
||||||
|
tdStyle: { fontFamily: "'SF Mono', Consolas, monospace", fontSize: 13, color: '#5741d8', fontWeight: 500 },
|
||||||
|
},
|
||||||
|
{ key: 'name', label: 'LessonName', tdStyle: { fontWeight: 500 } },
|
||||||
|
{ key: 'description', label: 'Description', tdStyle: { color: '#686b82' } },
|
||||||
|
{
|
||||||
|
key: 'isPublished',
|
||||||
|
label: 'Status',
|
||||||
|
render: (row) => (
|
||||||
|
<span style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: row.isPublished ? 'rgba(20,158,97,0.12)' : 'rgba(148,151,169,0.12)',
|
||||||
|
color: row.isPublished ? '#026b3f' : '#686b82',
|
||||||
|
}}>
|
||||||
|
{row.isPublished ? 'Published' : 'Draft'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: 'Created At',
|
||||||
|
render: (row) => row.createdAt ? new Date(row.createdAt).toLocaleDateString('en-GB') : '-',
|
||||||
|
tdStyle: { color: '#9497a9', fontSize: 13 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Action',
|
||||||
|
render: (row) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={actionBtn}
|
||||||
|
onClick={() => navigate(`/courses/${row.courseId}/lessons/${row.id}/edit`)}
|
||||||
|
onMouseEnter={(e) => { e.target.style.background = '#7132f5'; e.target.style.color = '#ffffff'; }}
|
||||||
|
onMouseLeave={(e) => { e.target.style.background = '#ffffff'; e.target.style.color = '#5741d8'; }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={actionBtnDelete}
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
onMouseEnter={(e) => { e.target.style.background = 'rgba(20,158,97,0.16)'; e.target.style.color = '#026b3f'; e.target.style.borderColor = '#149e61'; }}
|
||||||
|
onMouseLeave={(e) => { e.target.style.background = 'transparent'; e.target.style.color = '#9497a9'; e.target.style.borderColor = '#dedee5'; }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: '#101114' }}>Lesson list</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: 'none',
|
||||||
|
background: '#7132f5',
|
||||||
|
color: '#ffffff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
const firstCourseId = lessons.length > 0 ? lessons[0].courseId : null;
|
||||||
|
if (firstCourseId) {
|
||||||
|
navigate(`/courses/${firstCourseId}/lessons/new`);
|
||||||
|
} else {
|
||||||
|
alert('Please create a course first before adding lessons.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Lesson
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#9497a9' }}>Loading lessons...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#ef4444' }}>{error}</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={lessons}
|
||||||
|
searchKeys={['code', 'name', 'description']}
|
||||||
|
searchPlaceholder="Search lessons..."
|
||||||
|
perPage={10}
|
||||||
|
emptyText="No lessons found."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Button } from '../../../components/ui/button';
|
||||||
|
import {
|
||||||
|
HiOutlineArrowLeft,
|
||||||
|
HiOutlineBookmark,
|
||||||
|
HiOutlineRocketLaunch
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
function ActionBar({ courseId, isPublished, onTogglePublish, onSaveDraft, saving }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
to="/courses"
|
||||||
|
className="hover:text-primary transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HiOutlineArrowLeft className="w-4 h-4" />
|
||||||
|
<span>Courses</span>
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link
|
||||||
|
to={`/courses/${courseId}/detail`}
|
||||||
|
className="hover:text-primary transition-colors truncate max-w-[200px]"
|
||||||
|
>
|
||||||
|
{courseId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-medium text-foreground">New Lesson</span>
|
||||||
|
<span className="ml-2 px-2.5 py-0.5 rounded-full bg-app-primary-soft text-app-primary text-xs font-medium flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-app-primary animate-pulse" />
|
||||||
|
Editing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<HiOutlineBookmark className="w-4 h-4 mr-2" />
|
||||||
|
{saving ? 'Saving...' : 'Save Draft'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
|
||||||
|
onClick={onTogglePublish}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<HiOutlineRocketLaunch className="w-4 h-4 mr-2" />
|
||||||
|
{saving ? 'Saving...' : isPublished ? 'Update & Publish' : 'Save & Publish'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionBar;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Input } from '../../../components/ui/input';
|
||||||
|
import { Label } from '../../../components/ui/label';
|
||||||
|
import { HiOutlineCodeBracket, HiOutlineEye, HiOutlineArrowTopRightOnSquare } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function EmbedSection({ section, onChange }) {
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onChange({ ...section, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-3 bg-white">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold">
|
||||||
|
Embed Code or Share URL (Figma / CodePen / Miro)
|
||||||
|
</Label>
|
||||||
|
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
|
||||||
|
<span className="px-4 py-2 text-app-text-muted bg-app-border/30 flex items-center">
|
||||||
|
<HiOutlineCodeBracket className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={section.embedUrl || ''}
|
||||||
|
onChange={(e) => handleChange('embedUrl', e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="border-0 bg-transparent font-mono text-sm focus:ring-0 focus:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 rounded-xl bg-app-surface-muted p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-app-border/30 flex items-center justify-center text-app-primary">
|
||||||
|
<HiOutlineEye className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm text-app-text block">Embed Preview</span>
|
||||||
|
<p className="text-xs text-app-text-muted">Default size: 100% width, 540px height</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={section.embedUrl || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-app-primary hover:underline flex items-center gap-1 font-medium"
|
||||||
|
>
|
||||||
|
Check original
|
||||||
|
<HiOutlineArrowTopRightOnSquare className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmbedSection;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Card } from '../../../components/ui/card';
|
||||||
|
import { Input } from '../../../components/ui/input';
|
||||||
|
import { Label } from '../../../components/ui/label';
|
||||||
|
import { Textarea } from '../../../components/ui/textarea';
|
||||||
|
import { HiOutlineDocumentText, HiOutlineHashtag, HiOutlineArrowPath } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function LessonMetadataForm({ metadata, onChange }) {
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onChange({ ...metadata, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateSlug = () => {
|
||||||
|
const slug = metadata.title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/đ/g, 'd')
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim();
|
||||||
|
handleChange('slug', slug);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center justify-between pb-4 mb-4 border-b border-app-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HiOutlineDocumentText className="w-5 h-5 text-app-primary" />
|
||||||
|
<h2 className="text-lg font-semibold text-app-text">Lesson Metadata</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-app-text-muted uppercase tracking-wider bg-app-surface-muted px-2 py-0.5 rounded">
|
||||||
|
schema: lessons
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||||
|
<div className="md:col-span-4">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Lesson Title <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="VD: 1.1 Khái niệm cơ bản..."
|
||||||
|
value={metadata.title}
|
||||||
|
onChange={(e) => handleChange('title', e.target.value)}
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Position
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineHashtag className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={metadata.position}
|
||||||
|
onChange={(e) => handleChange('position', e.target.value)}
|
||||||
|
className="pl-9 rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<Label className="block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Slug
|
||||||
|
</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generateSlug}
|
||||||
|
className="text-app-primary hover:underline text-xs font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HiOutlineArrowPath className="w-3.5 h-3.5" />
|
||||||
|
Auto-generate from title
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
|
||||||
|
<span className="px-4 py-2 text-app-text-muted font-mono text-sm bg-app-border/30 flex items-center select-none">
|
||||||
|
/lessons/
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={metadata.slug}
|
||||||
|
onChange={(e) => handleChange('slug', e.target.value)}
|
||||||
|
className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-6">
|
||||||
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Description & Learning Objectives
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Mô tả tóm tắt nội dung bài học, kết quả mong đợi của học viên..."
|
||||||
|
rows={3}
|
||||||
|
value={metadata.description}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonMetadataForm;
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { Card } from '../../../components/ui/card';
|
||||||
|
import { Switch } from '../../../components/ui/switch';
|
||||||
|
import { Badge } from '../../../components/ui/badge';
|
||||||
|
import { HiOutlineLockOpen } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function PublishCard({ isPublished, onTogglePublish, courseTitle, moduleTitle }) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-app-border">
|
||||||
|
<h3 className="font-semibold text-app-text">Publish Status</h3>
|
||||||
|
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-xs flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-app-primary" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 rounded-xl bg-app-surface-muted flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-sm text-app-text">Publish Lesson</span>
|
||||||
|
<span className="text-xs text-app-text-muted">Allow students to access</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={isPublished} onCheckedChange={onTogglePublish} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-app-text-muted px-1">
|
||||||
|
<span>Linked Course:</span>
|
||||||
|
<span className="font-medium text-app-text truncate max-w-[170px]">{courseTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-sm text-app-text-muted px-1">
|
||||||
|
<span>Module:</span>
|
||||||
|
<span className="font-medium text-app-text">{moduleTitle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentStatsCard({ sections }) {
|
||||||
|
const typeCounts = sections.reduce((acc, s) => {
|
||||||
|
acc[s.type] = (acc[s.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const totalDuration = sections
|
||||||
|
.filter((s) => s.type === 'video')
|
||||||
|
.reduce((acc, s) => {
|
||||||
|
if (!s.duration) return acc;
|
||||||
|
const parts = s.duration.split(':').map(Number);
|
||||||
|
return acc + (parts[0] || 0) * 60 + (parts[1] || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const formatDuration = (secs) => {
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalItems = sections.length;
|
||||||
|
const freePreviewCount = sections.filter((s) => s.isFreePreview).length;
|
||||||
|
|
||||||
|
const typeColors = {
|
||||||
|
video: 'bg-app-primary',
|
||||||
|
text: 'bg-purple-400',
|
||||||
|
quiz: 'bg-orange-400',
|
||||||
|
embed: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
video: 'Video',
|
||||||
|
text: 'Text',
|
||||||
|
quiz: 'Quiz',
|
||||||
|
embed: 'Embed',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">Content Overview</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||||
|
<div className="p-3 rounded-xl bg-app-surface-muted flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-app-text-muted uppercase">Total Duration</span>
|
||||||
|
<span className="text-xl font-bold text-app-primary mt-1">{formatDuration(totalDuration)}</span>
|
||||||
|
<span className="text-[11px] text-app-text-muted mt-0.5">~{Math.ceil(totalDuration / 60)} min learning</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-app-surface-muted flex flex-col">
|
||||||
|
<span className="text-xs font-semibold text-app-text-muted uppercase">Sections</span>
|
||||||
|
<span className="text-xl font-bold text-app-text mt-1">{String(totalItems).padStart(2, '0')}</span>
|
||||||
|
<span className="text-[11px] text-app-text-muted mt-0.5">
|
||||||
|
{Object.entries(typeCounts)
|
||||||
|
.map(([t, c]) => `${c} ${typeLabels[t]}`)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalItems > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5 pt-4">
|
||||||
|
<div className="flex items-center justify-between text-xs text-app-text-muted">
|
||||||
|
<span>Format distribution</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2.5 rounded-full overflow-hidden flex bg-app-border/30">
|
||||||
|
{Object.entries(typeCounts).map(([type, count]) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className={`${typeColors[type]} h-full`}
|
||||||
|
style={{ width: `${(count / totalItems) * 100}%` }}
|
||||||
|
title={`${typeLabels[type]} ${Math.round((count / totalItems) * 100)}%`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-app-text-muted pt-1">
|
||||||
|
{Object.entries(typeCounts).map(([type]) => (
|
||||||
|
<span key={type} className="flex items-center gap-1">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${typeColors[type]}`} />
|
||||||
|
{typeLabels[type]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{freePreviewCount > 0 && (
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-app-primary-soft flex items-start gap-3">
|
||||||
|
<HiOutlineLockOpen className="w-5 h-5 text-app-primary mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm text-app-text block">{freePreviewCount} free preview items</span>
|
||||||
|
<p className="text-xs text-app-text-muted mt-0.5">
|
||||||
|
Non-enrolled students can view these to encourage sign-ups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeoCard({ title, slug, description }) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">SEO & Search</h3>
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-app-surface-muted flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-mono text-app-primary truncate">
|
||||||
|
academy.edu.vn > courses > {slug || 'lesson-slug'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm text-app-primary hover:underline cursor-pointer">
|
||||||
|
{title || 'Lesson Title'} - LMS Enterprise
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-app-text-muted line-clamp-2">
|
||||||
|
{description || 'Lesson description will appear here...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditCard({ lessonId, courseId, createdAt, updatedAt, authorId }) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-app-border bg-white overflow-hidden" style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}>
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="font-semibold text-app-text pb-3 border-b border-app-border">System Info (Audit)</h3>
|
||||||
|
<div className="mt-4 flex flex-col gap-2 font-mono text-xs text-app-text-muted">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>lesson_id:</span>
|
||||||
|
<span className="text-app-text">{lessonId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>course_id:</span>
|
||||||
|
<span className="text-app-text">{courseId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>created_at:</span>
|
||||||
|
<span className="text-app-text">{createdAt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>updated_at:</span>
|
||||||
|
<span className="text-app-text">{updatedAt}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>author_id:</span>
|
||||||
|
<span className="text-app-text">{authorId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LessonSidebar({
|
||||||
|
isPublished,
|
||||||
|
onTogglePublish,
|
||||||
|
sections,
|
||||||
|
metadata,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PublishCard
|
||||||
|
isPublished={isPublished}
|
||||||
|
onTogglePublish={onTogglePublish}
|
||||||
|
courseTitle="Advanced UI/UX 2024"
|
||||||
|
moduleTitle="Module 1: Grid Fundamentals"
|
||||||
|
/>
|
||||||
|
<ContentStatsCard sections={sections} />
|
||||||
|
<SeoCard
|
||||||
|
title={metadata.title}
|
||||||
|
slug={metadata.slug}
|
||||||
|
description={metadata.description}
|
||||||
|
/>
|
||||||
|
<AuditCard
|
||||||
|
lessonId="lsn_new"
|
||||||
|
courseId="crs_7109cb44"
|
||||||
|
createdAt="Just now"
|
||||||
|
updatedAt="Just now"
|
||||||
|
authorId="usr_admin_01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonSidebar;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { HiOutlineQuestionMarkCircle, HiOutlineClock, HiOutlineAcademicCap, HiOutlinePencilSquare } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function QuizSection({ section }) {
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-4 bg-white">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
|
||||||
|
<HiOutlineQuestionMarkCircle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm text-app-text block">{section.questionCount || 5} Questions</span>
|
||||||
|
<span className="text-xs text-app-text-muted">Multiple choice & True/False</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
|
||||||
|
<HiOutlineClock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm text-app-text block">Time: {section.timeLimit || 10} min</span>
|
||||||
|
<span className="text-xs text-app-text-muted">{(section.timeLimit || 10) * 60} seconds countdown</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-app-surface-muted p-4 rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-app-primary shadow-sm">
|
||||||
|
<HiOutlineAcademicCap className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm text-app-text block">Pass: {section.passScore || 80}%</span>
|
||||||
|
<span className="text-xs text-app-text-muted">Max {section.maxAttempts || 3} attempts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between bg-app-surface-muted p-3 rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-app-text-muted">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-app-primary" />
|
||||||
|
<span>1. What problem does the 8-point grid system solve?</span>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-1.5 rounded-xl bg-white hover:bg-app-surface-muted text-app-primary text-xs font-medium flex items-center gap-1 transition-colors border border-app-border">
|
||||||
|
<HiOutlinePencilSquare className="w-3.5 h-3.5" />
|
||||||
|
Edit Questions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuizSection;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Button } from '../../../components/ui/button';
|
||||||
|
import { Badge } from '../../../components/ui/badge';
|
||||||
|
import {
|
||||||
|
HiOutlineBars3,
|
||||||
|
HiOutlineVideoCamera,
|
||||||
|
HiOutlineDocumentText,
|
||||||
|
HiOutlineQuestionMarkCircle,
|
||||||
|
HiOutlineCodeBracket,
|
||||||
|
HiOutlineChevronUp,
|
||||||
|
HiOutlineChevronDown,
|
||||||
|
HiOutlineDocumentDuplicate,
|
||||||
|
HiOutlineTrash,
|
||||||
|
HiOutlinePlus,
|
||||||
|
HiOutlineChevronDown as HiOutlineExpand,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import VideoSection from './VideoSection';
|
||||||
|
import TextSection from './TextSection';
|
||||||
|
import QuizSection from './QuizSection';
|
||||||
|
import EmbedSection from './EmbedSection';
|
||||||
|
|
||||||
|
const SECTION_TYPES = {
|
||||||
|
video: {
|
||||||
|
label: 'Video',
|
||||||
|
icon: HiOutlineVideoCamera,
|
||||||
|
color: 'bg-blue-100 text-blue-700',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
label: 'Text',
|
||||||
|
icon: HiOutlineDocumentText,
|
||||||
|
color: 'bg-purple-100 text-purple-700',
|
||||||
|
},
|
||||||
|
quiz: {
|
||||||
|
label: 'Quiz',
|
||||||
|
icon: HiOutlineQuestionMarkCircle,
|
||||||
|
color: 'bg-orange-100 text-orange-700',
|
||||||
|
},
|
||||||
|
embed: {
|
||||||
|
label: 'Embed',
|
||||||
|
icon: HiOutlineCodeBracket,
|
||||||
|
color: 'bg-gray-100 text-gray-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionEditors = {
|
||||||
|
video: VideoSection,
|
||||||
|
text: TextSection,
|
||||||
|
quiz: QuizSection,
|
||||||
|
embed: EmbedSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
function SectionItem({ section, index, total, onUpdate, onRemove, onDuplicate, onMoveUp, onMoveDown }) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const typeConfig = SECTION_TYPES[section.type];
|
||||||
|
const Icon = typeConfig.icon;
|
||||||
|
const Editor = sectionEditors[section.type];
|
||||||
|
|
||||||
|
const getMetaText = () => {
|
||||||
|
switch (section.type) {
|
||||||
|
case 'video':
|
||||||
|
return section.duration || '00:00';
|
||||||
|
case 'text':
|
||||||
|
return `~${Math.max(1, Math.ceil((section.content || '').split(/\s+/).length / 200))} min read`;
|
||||||
|
case 'quiz':
|
||||||
|
return `${section.timeLimit || 10}:00 \u2022 ${section.questionCount || 5} questions`;
|
||||||
|
case 'embed':
|
||||||
|
return 'iFrame';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative bg-white rounded-xl shadow-sm hover:shadow-md transition-all overflow-hidden border border-app-border/50">
|
||||||
|
<div className="p-4 flex items-center justify-between gap-3 bg-app-surface-muted/50">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<HiOutlineBars3 className="w-5 h-5 text-app-text-muted cursor-grab active:cursor-grabbing hover:text-app-primary" />
|
||||||
|
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold uppercase tracking-wider flex items-center gap-1 ${typeConfig.color}`}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{typeConfig.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm text-app-text truncate">{section.title || `Section ${index + 1}`}</span>
|
||||||
|
{section.isFreePreview && (
|
||||||
|
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted text-[11px]">Free Preview</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-app-text-muted font-mono mr-2">{getMetaText()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? <HiOutlineChevronUp className="w-4 h-4" /> : <HiOutlineChevronDown className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<HiOutlineChevronUp className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<HiOutlineChevronDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDuplicate}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-app-text hover:bg-app-border/30 transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlineDocumentDuplicate className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-app-text-muted hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<HiOutlineTrash className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && Editor && (
|
||||||
|
<Editor
|
||||||
|
section={section}
|
||||||
|
onChange={(updated) => onUpdate(index, updated)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionManager({ sections, onChange }) {
|
||||||
|
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||||
|
const counterRef = useRef(0);
|
||||||
|
|
||||||
|
const addSection = (type) => {
|
||||||
|
counterRef.current += 1;
|
||||||
|
const newSection = {
|
||||||
|
id: `section_${counterRef.current}`,
|
||||||
|
type,
|
||||||
|
title: '',
|
||||||
|
isFreePreview: false,
|
||||||
|
...(type === 'video' && { url: '', duration: '', thumbnail: '' }),
|
||||||
|
...(type === 'text' && { content: '' }),
|
||||||
|
...(type === 'quiz' && { questionCount: 5, timeLimit: 10, passScore: 80, maxAttempts: 3 }),
|
||||||
|
...(type === 'embed' && { embedUrl: '' }),
|
||||||
|
};
|
||||||
|
onChange([...sections, newSection]);
|
||||||
|
setShowAddMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSection = (index, updated) => {
|
||||||
|
const newSections = [...sections];
|
||||||
|
newSections[index] = updated;
|
||||||
|
onChange(newSections);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSection = (index) => {
|
||||||
|
onChange(sections.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicateSection = (index) => {
|
||||||
|
counterRef.current += 1;
|
||||||
|
const duplicate = { ...sections[index], id: `section_${counterRef.current}` };
|
||||||
|
const newSections = [...sections];
|
||||||
|
newSections.splice(index + 1, 0, duplicate);
|
||||||
|
onChange(newSections);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveSection = (from, to) => {
|
||||||
|
if (to < 0 || to >= sections.length) return;
|
||||||
|
const newSections = [...sections];
|
||||||
|
const [moved] = newSections.splice(from, 1);
|
||||||
|
newSections.splice(to, 0, moved);
|
||||||
|
onChange(newSections);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-app-text">Sections</h3>
|
||||||
|
<Badge className="rounded-full bg-app-surface-muted text-app-text-muted">{sections.length} items</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-app-text-muted mt-0.5">
|
||||||
|
Drag to reorder. Each section supports different multimedia content types.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark shadow-sm"
|
||||||
|
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||||
|
>
|
||||||
|
<HiOutlinePlus className="w-4 h-4 mr-2" />
|
||||||
|
Add Section
|
||||||
|
<HiOutlineExpand className="w-4 h-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
{showAddMenu && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-app-border z-10 py-1">
|
||||||
|
{Object.entries(SECTION_TYPES).map(([type, config]) => {
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addSection(type)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-app-text hover:bg-app-surface-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
|
||||||
|
Add {config.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<SectionItem
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
index={index}
|
||||||
|
total={sections.length}
|
||||||
|
onUpdate={updateSection}
|
||||||
|
onRemove={() => removeSection(index)}
|
||||||
|
onDuplicate={() => duplicateSection(index)}
|
||||||
|
onMoveUp={() => moveSection(index, index - 1)}
|
||||||
|
onMoveDown={() => moveSection(index, index + 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-xl bg-white shadow-sm border border-app-border/50 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="text-xs text-app-text-muted uppercase tracking-wider pl-2">Quick add:</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{Object.entries(SECTION_TYPES).map(([type, config]) => {
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addSection(type)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-app-surface-muted hover:bg-app-border/40 text-app-text text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${config.color.split(' ')[1]}`} />
|
||||||
|
+ {config.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionManager;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Textarea } from '../../../components/ui/textarea';
|
||||||
|
import {
|
||||||
|
HiOutlineBold,
|
||||||
|
HiOutlineItalic,
|
||||||
|
HiOutlineListBullet,
|
||||||
|
HiOutlineListBullet as HiOutlineNumberedList,
|
||||||
|
HiOutlineChatBubbleLeftRight,
|
||||||
|
HiOutlineCodeBracket,
|
||||||
|
HiOutlinePhoto,
|
||||||
|
HiOutlineTableCells,
|
||||||
|
HiOutlineExclamationCircle,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function TextSection({ section, onChange }) {
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onChange({ ...section, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-3 bg-white">
|
||||||
|
<div className="flex flex-wrap items-center gap-1 p-1.5 rounded-xl bg-app-surface-muted text-app-text-muted">
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 text-app-text">
|
||||||
|
<HiOutlineBold className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<HiOutlineItalic className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path d="M4 6h16M4 12h10M4 18h14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-5 bg-app-border mx-1" />
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<HiOutlineListBullet className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<HiOutlineNumberedList className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<HiOutlineChatBubbleLeftRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40">
|
||||||
|
<HiOutlineCodeBracket className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-5 bg-app-border mx-1" />
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs">
|
||||||
|
<HiOutlinePhoto className="w-4 h-4" />
|
||||||
|
Image
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs">
|
||||||
|
<HiOutlineTableCells className="w-4 h-4" />
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-1.5 rounded hover:bg-app-border/40 flex items-center gap-1 text-xs text-app-primary">
|
||||||
|
<HiOutlineExclamationCircle className="w-4 h-4" />
|
||||||
|
Callout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-white min-h-[160px] p-4">
|
||||||
|
<Textarea
|
||||||
|
value={section.content || ''}
|
||||||
|
onChange={(e) => handleChange('content', e.target.value)}
|
||||||
|
placeholder="Write your lesson content here..."
|
||||||
|
rows={8}
|
||||||
|
className="border-0 bg-transparent focus:ring-0 focus:bg-transparent resize-none text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-app-border">
|
||||||
|
<span className="text-xs text-app-text-muted">Last saved at 14:32 (Auto)</span>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs text-app-text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={section.isFreePreview || false}
|
||||||
|
onChange={(e) => handleChange('isFreePreview', e.target.checked)}
|
||||||
|
className="w-3.5 h-3.5 rounded text-app-primary focus:ring-0"
|
||||||
|
/>
|
||||||
|
<span>Mark as free preview</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextSection;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Input } from '../../../components/ui/input';
|
||||||
|
import { Label } from '../../../components/ui/label';
|
||||||
|
import { HiOutlineVideoCamera, HiOutlineLink, HiOutlineClock, HiOutlineCheckBadge } from 'react-icons/hi2';
|
||||||
|
|
||||||
|
function VideoSection({ section, onChange }) {
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
onChange({ ...section, [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-4 bg-white">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||||
|
<div className="md:col-span-5 flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold text-app-text-muted uppercase">Video Preview</span>
|
||||||
|
<div className="relative rounded-xl overflow-hidden bg-app-surface-muted aspect-video flex items-center justify-center group shadow-inner">
|
||||||
|
{section.thumbnail ? (
|
||||||
|
<div
|
||||||
|
className="bg-cover bg-center w-full h-full absolute inset-0"
|
||||||
|
style={{ backgroundImage: `url('${section.thumbnail}')` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<HiOutlineVideoCamera className="w-12 h-12 text-app-text-muted/40" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
<button className="w-12 h-12 rounded-full bg-white/90 text-app-primary flex items-center justify-center shadow-lg hover:scale-110 transition-transform">
|
||||||
|
<svg className="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{section.duration && (
|
||||||
|
<span className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-0.5 rounded text-xs font-mono">
|
||||||
|
{section.duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-app-text-muted">
|
||||||
|
<span>H.264 • 1080p • 60fps</span>
|
||||||
|
<button className="text-app-primary hover:underline flex items-center gap-1">
|
||||||
|
<HiOutlineLink className="w-3.5 h-3.5" />
|
||||||
|
Change video
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-7 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-app-text text-xs font-semibold">Video Title</Label>
|
||||||
|
<Input
|
||||||
|
value={section.title || ''}
|
||||||
|
onChange={(e) => handleChange('title', e.target.value)}
|
||||||
|
placeholder="Enter video title..."
|
||||||
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-app-text text-xs font-semibold">Video Source (Vimeo, YouTube or S3)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineLink className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
|
||||||
|
<Input
|
||||||
|
value={section.url || ''}
|
||||||
|
onChange={(e) => handleChange('url', e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-app-text text-xs font-semibold">Duration</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<HiOutlineClock className="absolute left-3 top-2.5 w-4 h-4 text-app-text-muted" />
|
||||||
|
<Input
|
||||||
|
value={section.duration || ''}
|
||||||
|
onChange={(e) => handleChange('duration', e.target.value)}
|
||||||
|
placeholder="08:45"
|
||||||
|
className="pl-9 rounded-xl bg-app-surface-muted border-0 font-mono text-sm focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block text-app-text text-xs font-semibold">Free Preview</Label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={section.isFreePreview || false}
|
||||||
|
onChange={(e) => handleChange('isFreePreview', e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded text-app-primary focus:ring-0"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-app-text">Allow preview</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-app-surface-muted p-2.5 rounded-xl flex items-center justify-between text-xs text-app-text-muted">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<HiOutlineCheckBadge className="w-4 h-4 text-app-primary" />
|
||||||
|
<span>Video encoded and ready for streaming.</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono">JSONB: OK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoSection;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function MCQPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>MCQ Questions</h2>
|
||||||
|
<p>MCQ content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCQPage
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function ParagraphPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Paragraph Questions</h2>
|
||||||
|
<p>Paragraph content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParagraphPage
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function MasterdataPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Master Data</h2>
|
||||||
|
<p>Master data content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MasterdataPage
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function PermissionPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Permissions</h2>
|
||||||
|
<p>Permission management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionPage
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function RolePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Roles</h2>
|
||||||
|
<p>Role management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RolePage
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p>User management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPage
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
const BASE_PATH = '/api/courses';
|
||||||
|
|
||||||
|
const courseService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await api.get(BASE_PATH);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id) => {
|
||||||
|
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data) => {
|
||||||
|
const response = await api.post(BASE_PATH, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, data) => {
|
||||||
|
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
await api.delete(`${BASE_PATH}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default courseService;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
const BASE_PATH = '/api/lessons';
|
||||||
|
|
||||||
|
const lessonService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await api.get(BASE_PATH);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id) => {
|
||||||
|
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data) => {
|
||||||
|
const response = await api.post(BASE_PATH, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, data) => {
|
||||||
|
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
await api.delete(`${BASE_PATH}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default lessonService;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
const BASE_PATH = '/api/sections';
|
||||||
|
|
||||||
|
const sectionService = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await api.get(BASE_PATH);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id) => {
|
||||||
|
const response = await api.get(`${BASE_PATH}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getByLessonId: async (lessonId) => {
|
||||||
|
const response = await api.get(`${BASE_PATH}/by-lesson/${lessonId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data) => {
|
||||||
|
const response = await api.post(BASE_PATH, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id, data) => {
|
||||||
|
const response = await api.put(`${BASE_PATH}/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id) => {
|
||||||
|
await api.delete(`${BASE_PATH}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sectionService;
|
||||||
+11
-3
@@ -1,7 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import path from 'path'
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user