feat: update UI
This commit is contained in:
@@ -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)."
|
||||||
Generated
+344
-625
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -10,11 +10,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroui/react": "^3.2.4",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@heroui/styles": "^3.2.4",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"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",
|
"react-router-dom": "^7.18.2",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+210
-55
@@ -1,23 +1,45 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import '@heroui/styles';
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-app-bg: #f8fafc;
|
--color-background: var(--color-app-bg);
|
||||||
|
--color-foreground: var(--color-app-text);
|
||||||
|
--color-card: var(--color-app-surface);
|
||||||
|
--color-card-foreground: var(--color-app-text);
|
||||||
|
--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: #ffffff;
|
||||||
--color-app-surface-muted: #f1f5f9;
|
--color-app-surface-muted: rgba(148,151,169,0.08);
|
||||||
--color-app-border: #e2e8f0;
|
--color-app-border: #dedee5;
|
||||||
--color-app-border-input: #cbd5e1;
|
--color-app-border-input: #dedee5;
|
||||||
--color-app-text: #1e293b;
|
--color-app-text: #101114;
|
||||||
--color-app-text-strong: #0f172a;
|
--color-app-text-strong: #101114;
|
||||||
--color-app-text-muted: #64748b;
|
--color-app-text-muted: #9497a9;
|
||||||
--color-app-primary: #1a73e8;
|
--color-app-primary: #7132f5;
|
||||||
--color-app-primary-dark: #1558b0;
|
--color-app-primary-dark: #5741d8;
|
||||||
--color-app-primary-soft: #e8f1fd;
|
--color-app-primary-soft: rgba(133,91,251,0.16);
|
||||||
--color-app-primary-contrast: #ffffff;
|
--color-app-primary-contrast: #ffffff;
|
||||||
--color-app-interactive-hover: #f1f5f9;
|
--color-app-interactive-hover: rgba(148,151,169,0.08);
|
||||||
--color-app-interactive-active: #e8f1fd;
|
--color-app-interactive-active: rgba(133,91,251,0.16);
|
||||||
--color-app-focus: #1a73e8;
|
--color-app-focus: #7132f5;
|
||||||
--shadow-app-panel: 0 16px 42px rgb(0 0 0 / 5%);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@@ -36,8 +58,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
border-right: 1px solid var(--color-app-border);
|
border-right: none;
|
||||||
background: var(--color-app-surface);
|
background: var(--color-app-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,18 +67,27 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 8px 28px;
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__logo {
|
.sidebar__logo {
|
||||||
display: inline-grid;
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
place-items: center;
|
border-radius: 10px;
|
||||||
border-radius: 8px;
|
color: var(--color-app-primary) !important;
|
||||||
color: #ffffff;
|
background: #ffffff;
|
||||||
background: var(--color-app-primary);
|
}
|
||||||
font-weight: 800;
|
|
||||||
|
.sidebar__logo svg {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__brand-text,
|
.sidebar__brand-text,
|
||||||
@@ -76,36 +107,39 @@
|
|||||||
|
|
||||||
.sidebar__brand strong {
|
.sidebar__brand strong {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__brand span {
|
.sidebar__brand span {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: var(--color-app-text-muted);
|
color: rgba(255,255,255,0.7);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__toggle {
|
.sidebar__toggle {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 12px;
|
||||||
place-items: center;
|
border: none;
|
||||||
border: 1px solid var(--color-app-border);
|
border-radius: 6px;
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--color-app-text-muted);
|
color: var(--color-app-text-muted);
|
||||||
background: var(--color-app-surface);
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
|
||||||
transition:
|
transition:
|
||||||
border-color 160ms ease,
|
|
||||||
color 160ms ease,
|
color 160ms ease,
|
||||||
background-color 160ms ease;
|
background-color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__toggle:hover {
|
.sidebar__toggle:hover {
|
||||||
color: var(--color-app-primary);
|
color: var(--color-app-text);
|
||||||
border-color: var(--color-app-primary);
|
background: var(--color-app-interactive-hover);
|
||||||
background: var(--color-app-primary-soft);
|
}
|
||||||
|
|
||||||
|
.sidebar__toggle:active {
|
||||||
|
background: var(--color-app-interactive-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__toggle:focus-visible,
|
.sidebar__toggle:focus-visible,
|
||||||
@@ -119,74 +153,194 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar__link {
|
.sidebar__link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--color-app-text-muted);
|
color: rgba(255,255,255,0.75);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition:
|
transition:
|
||||||
background-color 160ms ease,
|
color 160ms ease, background 160ms ease;
|
||||||
color 160ms ease;
|
}
|
||||||
|
|
||||||
|
.sidebar__link:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__link-icon {
|
.sidebar__link-icon {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
flex: 0 0 28px;
|
flex: 0 0 32px;
|
||||||
width: 28px;
|
width: 32px;
|
||||||
height: 28px;
|
height: 32px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 7px;
|
border-radius: 8px;
|
||||||
color: var(--color-app-primary-dark);
|
color: #ffffff;
|
||||||
background: var(--color-app-primary-soft);
|
background: rgba(255,255,255,0.15);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__link:hover {
|
.sidebar__link:hover .sidebar__link-icon {
|
||||||
color: var(--color-app-text);
|
background: rgba(255,255,255,0.25);
|
||||||
background: var(--color-app-interactive-hover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__link--active {
|
.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);
|
color: var(--color-app-primary);
|
||||||
background: var(--color-app-interactive-active);
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell--collapsed .sidebar {
|
.app-shell--collapsed .sidebar {
|
||||||
padding-inline: 14px;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell--collapsed .sidebar__brand {
|
.app-shell--collapsed .sidebar__brand {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-inline: 0;
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed .sidebar__logo {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--color-app-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell--collapsed .sidebar__brand-text,
|
.app-shell--collapsed .sidebar__brand-text,
|
||||||
.app-shell--collapsed .sidebar__link-text {
|
.app-shell--collapsed .sidebar__link-text {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell--collapsed .sidebar__link {
|
.app-shell--collapsed .sidebar__link,
|
||||||
|
.app-shell--collapsed .sidebar__link--parent {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-inline: 8px;
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed .sidebar__link--active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed .sidebar__link--active .sidebar__link-icon {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed .sidebar__chevron,
|
||||||
|
.app-shell--collapsed .sidebar__submenu {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell--collapsed .sidebar__link-icon {
|
.app-shell--collapsed .sidebar__link-icon {
|
||||||
flex-basis: 30px;
|
flex: none;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell--collapsed .sidebar__toggle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-inline: auto;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,3 +488,4 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -2,25 +2,35 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import MainLayout from './layouts/MainLayout.jsx';
|
import MainLayout from './layouts/MainLayout.jsx';
|
||||||
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
import CoursesPage from './pages/Course/CoursesPage.jsx';
|
||||||
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
import CourseDetail from './pages/Course/CourseDetails.jsx';
|
||||||
import StudentsPage from './pages/StudentsPage.jsx';
|
import LessonPage from './pages/lesson/LessonPage.jsx';
|
||||||
import ClassesPage from './pages/ClassesPage.jsx';
|
import MCQPage from './pages/question-bank/MCQPage.jsx';
|
||||||
import ReportsPage from './pages/ReportsPage.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 './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<>
|
||||||
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route index element={<Navigate to="/courses" replace />} />
|
<Route index element={<Navigate to="/courses" replace />} />
|
||||||
<Route path="/courses" element={<CoursesPage />} />
|
<Route path="/courses" element={<CoursesPage />} />
|
||||||
<Route path="/courses/:courseId" element={<CourseDetail />} />
|
<Route path="/courses/:courseId" element={<CourseDetail />} />
|
||||||
<Route path="/students" element={<StudentsPage />} />
|
<Route path="/lessons" element={<LessonPage />} />
|
||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/question-bank/mcq" element={<MCQPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/question-bank/paragraph" element={<ParagraphPage />} />
|
||||||
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/settings/masterdata" element={<MasterdataPage />} />
|
||||||
|
<Route path="/settings/role" element={<RolePage />} />
|
||||||
|
<Route path="/settings/permission" element={<PermissionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Card, Button } from '@heroui/react';
|
import { Card } from '../components/ui/card';
|
||||||
import { PencilIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
|
import { Button } from '../components/ui/button';
|
||||||
|
import { HiOutlinePencil, HiOutlineEllipsisVertical } from 'react-icons/hi2';
|
||||||
|
|
||||||
const statusStyles = {
|
const statusStyles = {
|
||||||
Active: 'bg-green-100 text-green-700',
|
Active: 'bg-app-success text-app-success-text',
|
||||||
Inactive: 'bg-gray-100 text-gray-600'
|
Inactive: 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
};
|
};
|
||||||
|
|
||||||
function CourseCard({ course, image, handleClick }) {
|
function CourseCard({ course, image, handleClick }) {
|
||||||
@@ -17,78 +18,69 @@ function CourseCard({ course, image, handleClick }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="default"
|
className="w-full max-w-100 overflow-hidden rounded-xl border border-app-border bg-white"
|
||||||
className="w-full max-w-100 overflow-hidden rounded-lg border border-gray-300 bg-white shadow-none"
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<div className="relative h-48 cursor-pointer overflow-hidden border-b border-gray-300">
|
<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" />
|
<img src={image} alt={title} className="h-full w-full object-cover" />
|
||||||
|
|
||||||
<div className="absolute left-4 top-4 rounded-sm border border-gray-300 bg-white px-3 py-1.5">
|
<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-gray-800">{code}</span>
|
<span className="text-lg font-semibold text-app-text">{code}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Card.Content className="cursor-pointer px-5 py-5">
|
<div className="cursor-pointer px-5 py-5">
|
||||||
<h3 className="mb-3 text-xl font-bold leading-tight text-gray-900">
|
<h3 className="mb-3 text-xl font-bold leading-tight text-app-text">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="line-clamp-2 text-md leading-8 text-gray-700">
|
<p className="line-clamp-2 text-md leading-8 text-app-text-muted">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</Card.Content>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<Card.Footer
|
<div
|
||||||
className="mx-5 flex items-center justify-between border-t border-gray-300 px-0 py-5"
|
className="mx-5 flex items-center justify-between border-t border-app-border px-0 py-5"
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
// handle edit
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2.5 py-1 text-sm font-medium ${
|
className={`rounded-[6px] px-2.5 py-1 text-sm font-medium ${
|
||||||
statusStyles[status] ?? 'bg-gray-100 text-gray-600'
|
statusStyles[status] ?? 'bg-app-neutral-badge text-app-neutral-badge-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-sm text-gray-600">Updated {formattedDate}</span>
|
<span className="text-sm text-app-text-muted">Updated {formattedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
color="gray"
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
aria-label={`Edit ${title}`}
|
aria-label={`Edit ${title}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
// handle edit
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PencilIcon className="h-5 w-5" />
|
<HiOutlinePencil className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
color="gray"
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
aria-label={`More actions for ${title}`}
|
aria-label={`More actions for ${title}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
// handle edit
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
<HiOutlineEllipsisVertical className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card.Footer>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { SearchField, Separator } from '@heroui/react';
|
import { Separator } from '../components/ui/separator';
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
HiOutlineBell,
|
||||||
QuestionMarkCircleIcon,
|
HiOutlineQuestionMarkCircle,
|
||||||
UserCircleIcon
|
HiOutlineUserCircle,
|
||||||
} from '@heroicons/react/16/solid';
|
HiOutlineBars3,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import { useSidebar } from '../layouts/MainLayout';
|
||||||
|
|
||||||
function PageHeader() {
|
function PageHeader() {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 flex flex-row items-center justify-between">
|
<div className="flex items-center justify-between rounded-xl bg-white px-3 py-1.5">
|
||||||
<SearchField name="search">
|
<button
|
||||||
<SearchField.Group className="rounded-xl border border-gray-300 w-100">
|
type="button"
|
||||||
<SearchField.SearchIcon />
|
onClick={toggleSidebar}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100"
|
||||||
<SearchField.Input
|
aria-label="Toggle sidebar"
|
||||||
className="flex-1"
|
>
|
||||||
placeholder="Search LMS Author"
|
<HiOutlineBars3 className="h-6 w-6 text-app-text-muted" />
|
||||||
/>
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<SearchField.ClearButton />
|
<HiOutlineBell className="h-6 w-6 text-app-text-muted" />
|
||||||
</SearchField.Group>
|
<HiOutlineQuestionMarkCircle className="h-6 w-6 text-app-text-muted" />
|
||||||
</SearchField>
|
<HiOutlineUserCircle className="h-6 w-6 text-app-text-muted" />
|
||||||
<div className="flex flex-row items-center gap-5">
|
|
||||||
<BellIcon className="mr-1 inline h-6 w-6" />
|
|
||||||
<QuestionMarkCircleIcon className="mr-1 inline h-6 w-6" />
|
|
||||||
<UserCircleIcon className="mr-1 inline h-6 w-6" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-2" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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 };
|
||||||
+27
-13
@@ -1,22 +1,36 @@
|
|||||||
export const navigationItems = [
|
export const navigationItems = [
|
||||||
{
|
{
|
||||||
label: 'Courses',
|
label: 'Course',
|
||||||
shortLabel: 'KH',
|
shortLabel: 'CO',
|
||||||
path: '/courses'
|
path: '/courses',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hoc vien',
|
label: 'Lesson',
|
||||||
shortLabel: 'HV',
|
shortLabel: 'LE',
|
||||||
path: '/students'
|
path: '/lessons',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lop hoc',
|
label: 'Question Bank',
|
||||||
shortLabel: 'LH',
|
shortLabel: 'QB',
|
||||||
path: '/classes'
|
path: '/question-bank',
|
||||||
|
children: [
|
||||||
|
{ label: 'MCQ', path: '/question-bank/mcq' },
|
||||||
|
{ label: 'Paragraph', path: '/question-bank/paragraph' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Bao cao',
|
label: 'Users',
|
||||||
shortLabel: 'BC',
|
shortLabel: 'US',
|
||||||
path: '/reports'
|
path: '/users',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
shortLabel: 'ST',
|
||||||
|
path: '/settings',
|
||||||
|
children: [
|
||||||
|
{ label: 'Masterdata', path: '/settings/masterdata' },
|
||||||
|
{ label: 'Role', path: '/settings/role' },
|
||||||
|
{ label: 'Permission', path: '/settings/permission' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+103
-39
@@ -1,52 +1,116 @@
|
|||||||
import { useState } from 'react'
|
import { useState, createContext, useContext } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
HiChevronRight,
|
||||||
|
HiAcademicCap,
|
||||||
|
HiBookOpen,
|
||||||
|
HiDocumentText,
|
||||||
|
HiQuestionMarkCircle,
|
||||||
|
HiUserGroup,
|
||||||
|
HiCog6Tooth,
|
||||||
|
} from 'react-icons/hi2'
|
||||||
import { navigationItems } from '../config/navigation.js'
|
import { navigationItems } from '../config/navigation.js'
|
||||||
|
|
||||||
|
const SidebarContext = createContext()
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
return useContext(SidebarContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function MainLayout() {
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isSidebarCollapsed ? 'app-shell app-shell--collapsed' : 'app-shell'}>
|
<SidebarContext.Provider value={{ isSidebarCollapsed, toggleSidebar: () => setIsSidebarCollapsed(v => !v) }}>
|
||||||
<aside className="sidebar">
|
<div className={isSidebarCollapsed ? 'app-shell app-shell--collapsed' : 'app-shell'}>
|
||||||
<div className="sidebar__brand">
|
<aside className="sidebar">
|
||||||
<span className="sidebar__logo">L</span>
|
<div className="sidebar__brand">
|
||||||
<div className="sidebar__brand-text">
|
<span className="sidebar__logo"><HiAcademicCap size={22} /></span>
|
||||||
<strong>APLP LMS</strong>
|
<div className="sidebar__brand-text">
|
||||||
<span>Learning platform</span>
|
<strong>APLP LMS</strong>
|
||||||
|
<span>Learning platform</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="sidebar__divider" />
|
||||||
type="button"
|
|
||||||
className="sidebar__toggle"
|
|
||||||
aria-label={isSidebarCollapsed ? 'Mo rong sidebar' : 'Thu gon sidebar'}
|
|
||||||
title={isSidebarCollapsed ? 'Mo rong sidebar' : 'Thu gon sidebar'}
|
|
||||||
onClick={() => setIsSidebarCollapsed((current) => !current)}
|
|
||||||
>
|
|
||||||
{isSidebarCollapsed ? '>' : '<'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<nav className="sidebar__nav" aria-label="Main navigation">
|
<nav className="sidebar__nav" aria-label="Main navigation">
|
||||||
{navigationItems.map((item) => (
|
{navigationItems.map((item) => (
|
||||||
<NavLink
|
<SidebarItem key={item.path} item={item} />
|
||||||
key={item.path}
|
))}
|
||||||
to={item.path}
|
</nav>
|
||||||
className={({ isActive }) =>
|
</aside>
|
||||||
isActive ? 'sidebar__link sidebar__link--active' : 'sidebar__link'
|
|
||||||
}
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
<span className="sidebar__link-icon">{item.shortLabel}</span>
|
|
||||||
<span className="sidebar__link-text">{item.label}</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import PageHeader from '../components/PageHeader.jsx'
|
|
||||||
|
|
||||||
function ClassesPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Lop hoc"
|
|
||||||
description="Sap xep lop, lich hoc, giang vien va danh sach hoc vien."
|
|
||||||
/>
|
|
||||||
<section className="content-panel">
|
|
||||||
<h2>Lich lop hoc</h2>
|
|
||||||
<p>Khoi tao lich lop va trang thai diem danh tai day.</p>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ClassesPage
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import {
|
import { Card } from '../../components/ui/card';
|
||||||
Card,
|
import { Button } from '../../components/ui/button';
|
||||||
Button,
|
import { Badge } from '../../components/ui/badge';
|
||||||
Typography,
|
import { Label } from '../../components/ui/label';
|
||||||
TextField,
|
import { Input } from '../../components/ui/input';
|
||||||
Input,
|
import { Textarea } from '../../components/ui/textarea';
|
||||||
Label,
|
|
||||||
Chip,
|
|
||||||
TextArea
|
|
||||||
} from '@heroui/react';
|
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -54,57 +50,56 @@ function CourseDetail() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader />
|
<PageHeader />
|
||||||
<div className="mb-4 flex flex-row items-center justify-between">
|
<div className="mb-4 flex flex-row items-center justify-between">
|
||||||
<Typography className="font-bold">{course?.title}</Typography>
|
<h2 className="text-xl font-bold text-app-text">{course?.title}</h2>
|
||||||
<div className="flex flex-row item-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Button variant="outline" className="rounded-lg">
|
<Button
|
||||||
|
color="gray"
|
||||||
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||||
|
>
|
||||||
Inactivate
|
Inactivate
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="rounded-lg">Publish</Button>
|
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
variant="default"
|
className="w-full max-w-100 overflow-hidden rounded-xl border border-app-border bg-white"
|
||||||
className="w-full max-w-100 overflow-hidden rounded-lg border border-gray-300 bg-white shadow-none"
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
||||||
>
|
>
|
||||||
<Card.Header>
|
<div className="p-5">
|
||||||
<Typography className="font-medium">Course Details</Typography>
|
<h4 className="font-medium text-app-text">Course Details</h4>
|
||||||
</Card.Header>
|
</div>
|
||||||
<Card.Content>
|
<div className="px-5 pb-5">
|
||||||
<TextField
|
<div className="my-2 w-full max-w-64">
|
||||||
className="w-full max-w-64 my-2"
|
<Label className="mb-1 block text-app-text">Course Code</Label>
|
||||||
name="Course Code"
|
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
<Label>Course Code</Label>
|
|
||||||
<Input
|
<Input
|
||||||
className="rounded border border-gray-300"
|
|
||||||
value={course?.code}
|
value={course?.code}
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</div>
|
||||||
<Label>Status</Label>
|
<Label className="mb-1 block text-app-text">Status</Label>
|
||||||
{course?.status === 'Active' ? (
|
{course?.status === 'Active' ? (
|
||||||
<Chip color="success" variant="primary">
|
<Badge className="rounded-[6px] bg-app-success text-app-success-text">
|
||||||
Active
|
Active
|
||||||
</Chip>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Chip>Inactive</Chip>
|
<Badge className="rounded-[8px] bg-app-neutral-badge text-app-neutral-badge-text">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<TextField
|
<div className="my-2 w-full max-w-64">
|
||||||
className="w-full max-w-64 my-2"
|
<Label className="mb-1 block text-app-text">Description</Label>
|
||||||
name="Description"
|
<Textarea
|
||||||
type="text"
|
|
||||||
>
|
|
||||||
<Label>Description</Label>
|
|
||||||
<TextArea
|
|
||||||
className="max-h-50"
|
className="max-h-50"
|
||||||
placeholder="..."
|
placeholder="..."
|
||||||
rows={4}
|
rows={4}
|
||||||
value={course?.description}
|
value={course?.description}
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</div>
|
||||||
</Card.Content>
|
</div>
|
||||||
<Card.Footer />
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import CourseCard from '../../components/CourseCard.jsx';
|
import CourseCard from '../../components/CourseCard.jsx';
|
||||||
import PageHeader from '../../components/PageHeader.jsx';
|
import PageHeader from '../../components/PageHeader.jsx';
|
||||||
import {
|
import { Button } from '../../components/ui/button.jsx';
|
||||||
Typography,
|
import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs.jsx';
|
||||||
SearchField,
|
import { Input } from '../../components/ui/input.jsx';
|
||||||
Separator,
|
import { Separator } from '../../components/ui/separator.jsx';
|
||||||
Button,
|
import { HiOutlineFunnel, HiOutlinePlus, HiOutlineMagnifyingGlass } from 'react-icons/hi2';
|
||||||
Tabs
|
|
||||||
} from '@heroui/react';
|
|
||||||
import { FunnelIcon, PlusIcon } from '@heroicons/react/16/solid';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
@@ -53,67 +50,43 @@ function CoursePage() {
|
|||||||
<PageHeader />
|
<PageHeader />
|
||||||
<div className="mb-4 flex flex-row items-center justify-between">
|
<div className="mb-4 flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Typography variant="h3" className="mb-2">
|
<h3 className="mb-2 text-[28px] font-bold leading-[1.29] tracking-tight text-app-text">
|
||||||
Courses
|
Courses
|
||||||
</Typography>
|
</h3>
|
||||||
<Typography variant="body1" className="text-gray-600">
|
<p className="text-app-text-muted">
|
||||||
Manage and organize your learning content.
|
Manage and organize your learning content.
|
||||||
</Typography>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row item-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Button variant="outline" className="rounded-lg">
|
<Button
|
||||||
<FunnelIcon />
|
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
|
Filter
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="rounded-lg">
|
<Button className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark">
|
||||||
<PlusIcon />
|
<HiOutlinePlus className="mr-2 h-5 w-5" />
|
||||||
New Course
|
New Course
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="flex flex-row items-center justify-between my-3 bg-white rounded-md border p-3">
|
<div className="my-3 flex flex-row items-center justify-between rounded-xl border border-app-border bg-white p-3">
|
||||||
<Tabs defaultSelectedKey="all" className="w-fit">
|
<Tabs defaultValue="all" className="w-fit">
|
||||||
<Tabs.ListContainer>
|
<TabsList>
|
||||||
<Tabs.List
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
aria-label="Course status"
|
<TabsTrigger value="active">Active</TabsTrigger>
|
||||||
className="gap-1 rounded-md bg-gray-100"
|
<TabsTrigger value="inactive">Inactive</TabsTrigger>
|
||||||
>
|
</TabsList>
|
||||||
<Tabs.Tab
|
|
||||||
id="all"
|
|
||||||
className="h-7 rounded px-6 py-5 text-base font-normal text-gray-700"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
<Tabs.Indicator className="rounded bg-white shadow-sm" />
|
|
||||||
</Tabs.Tab>
|
|
||||||
|
|
||||||
<Tabs.Tab
|
|
||||||
id="draft"
|
|
||||||
className="h-7 rounded px-6 py-5 text-base font-normal text-gray-500"
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
<Tabs.Indicator className="rounded bg-white shadow-sm" />
|
|
||||||
</Tabs.Tab>
|
|
||||||
|
|
||||||
<Tabs.Tab
|
|
||||||
id="published"
|
|
||||||
className="h-7 rounded px-6 py-5 text-base font-normal text-gray-500"
|
|
||||||
>
|
|
||||||
Inactive
|
|
||||||
<Tabs.Indicator className="rounded bg-white shadow-sm" />
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs.ListContainer>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<SearchField name="search">
|
<div className="relative w-100">
|
||||||
<SearchField.Group className="rounded-xl border border-gray-300 w-100">
|
<HiOutlineMagnifyingGlass className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-app-text-muted" />
|
||||||
<SearchField.SearchIcon />
|
<Input
|
||||||
|
placeholder="Search course by title or code..."
|
||||||
<SearchField.Input placeholder="Search course by title or code..." />
|
className="pl-9"
|
||||||
|
/>
|
||||||
<SearchField.ClearButton />
|
</div>
|
||||||
</SearchField.Group>
|
|
||||||
</SearchField>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{stats.map((course) => (
|
{stats.map((course) => (
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import PageHeader from '../components/PageHeader.jsx'
|
|
||||||
|
|
||||||
function ReportsPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Bao cao"
|
|
||||||
description="Tong hop so lieu hoc tap, hieu qua khoa hoc va ket qua danh gia."
|
|
||||||
/>
|
|
||||||
<section className="content-panel">
|
|
||||||
<h2>Bao cao dao tao</h2>
|
|
||||||
<p>Khoi tao bieu do va bao cao xuat file tai day.</p>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ReportsPage
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import PageHeader from '../components/PageHeader.jsx'
|
|
||||||
|
|
||||||
function StudentsPage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Hoc vien"
|
|
||||||
description="Quan ly ho so hoc vien, tien do hoc tap va lich su ghi danh."
|
|
||||||
/>
|
|
||||||
<section className="content-panel">
|
|
||||||
<h2>Danh sach hoc vien</h2>
|
|
||||||
<p>Khoi tao bang hoc vien va bo loc tai day.</p>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StudentsPage
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import { DataTable } from '../../components/ui/data-table';
|
||||||
|
|
||||||
|
const mockLessons = [
|
||||||
|
{ id: 1, code: 'LES001', name: 'Introduction to Programming', description: 'Basic concepts of programming', createdBy: 'Admin', createdAt: '2026-08-01' },
|
||||||
|
{ id: 2, code: 'LES002', name: 'Data Types and Variables', description: 'Understanding data types', createdBy: 'Admin', createdAt: '2026-08-03' },
|
||||||
|
{ id: 3, code: 'LES003', name: 'Control Flow', description: 'If/else and loops', createdBy: 'Teacher1', createdAt: '2026-08-05' },
|
||||||
|
{ id: 4, code: 'LES004', name: 'Functions', description: 'Function declaration and usage', createdBy: 'Teacher1', createdAt: '2026-08-07' },
|
||||||
|
{ id: 5, code: 'LES005', name: 'Arrays', description: 'Working with arrays', createdBy: 'Admin', createdAt: '2026-08-10' },
|
||||||
|
{ id: 6, code: 'LES006', name: 'Introduction to Programming', description: 'Basic concepts of programming', createdBy: 'Admin', createdAt: '2026-08-01' },
|
||||||
|
{ id: 7, code: 'LES007', name: 'Data Types and Variables', description: 'Understanding data types', createdBy: 'Admin', createdAt: '2026-08-03' },
|
||||||
|
{ id: 8, code: 'LES008', name: 'Control Flow', description: 'If/else and loops', createdBy: 'Teacher1', createdAt: '2026-08-05' },
|
||||||
|
{ id: 9, code: 'LES009', name: 'Functions', description: 'Function declaration and usage', createdBy: 'Teacher1', createdAt: '2026-08-07' },
|
||||||
|
{ id: 10, code: 'LES010', name: 'Arrays', description: 'Working with arrays', createdBy: 'Admin', createdAt: '2026-08-10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'createdBy', label: 'Created By' },
|
||||||
|
{ key: 'createdAt', label: 'Created At', tdStyle: { color: '#9497a9', fontSize: 13 } },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Action',
|
||||||
|
render: () => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={actionBtn}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function LessonPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Lesson
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={mockLessons}
|
||||||
|
searchKeys={['code', 'name', 'description', 'createdBy']}
|
||||||
|
searchPlaceholder="Search lessons..."
|
||||||
|
perPage={10}
|
||||||
|
emptyText="No lessons found."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LessonPage;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
function MCQPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>MCQ Questions</h2>
|
||||||
|
<p>MCQ content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MCQPage
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
function ParagraphPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Paragraph Questions</h2>
|
||||||
|
<p>Paragraph content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ParagraphPage
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
function MasterdataPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Master Data</h2>
|
||||||
|
<p>Master data content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MasterdataPage
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
function PermissionPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Permissions</h2>
|
||||||
|
<p>Permission management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PermissionPage
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
|
function RolePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Roles</h2>
|
||||||
|
<p>Role management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RolePage
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader />
|
||||||
|
<div className="content-panel">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p>User management content will appear here.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPage
|
||||||
+9
-2
@@ -1,8 +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 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(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
})
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user