feat: update UI dashboard page

This commit is contained in:
2nguyen
2026-08-19 11:37:47 +07:00
parent 3f56f788d1
commit 31cc376e4f
8 changed files with 245 additions and 229 deletions
+45 -26
View File
@@ -1,6 +1,25 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@heroui/styles'; @import '@heroui/styles';
@theme {
--color-app-bg: #f8fafc;
--color-app-surface: #ffffff;
--color-app-surface-muted: #f1f5f9;
--color-app-border: #e2e8f0;
--color-app-border-input: #cbd5e1;
--color-app-text: #1e293b;
--color-app-text-strong: #0f172a;
--color-app-text-muted: #64748b;
--color-app-primary: #1a73e8;
--color-app-primary-dark: #1558b0;
--color-app-primary-soft: #e8f1fd;
--color-app-primary-contrast: #ffffff;
--color-app-interactive-hover: #f1f5f9;
--color-app-interactive-active: #e8f1fd;
--color-app-focus: #1a73e8;
--shadow-app-panel: 0 16px 42px rgb(0 0 0 / 5%);
}
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
@@ -17,8 +36,8 @@
top: 0; top: 0;
height: 100vh; height: 100vh;
padding: 24px 16px; padding: 24px 16px;
border-right: 1px solid var(--border); border-right: 1px solid var(--color-app-border);
background: var(--surface); background: var(--color-app-surface);
overflow: hidden; overflow: hidden;
} }
@@ -36,7 +55,7 @@
place-items: center; place-items: center;
border-radius: 8px; border-radius: 8px;
color: #ffffff; color: #ffffff;
background: var(--primary); background: var(--color-app-primary);
font-weight: 800; font-weight: 800;
} }
@@ -61,7 +80,7 @@
.sidebar__brand span { .sidebar__brand span {
margin-top: 3px; margin-top: 3px;
color: var(--text-muted); color: var(--color-app-text-muted);
font-size: 13px; font-size: 13px;
} }
@@ -71,10 +90,10 @@
height: 36px; height: 36px;
margin-bottom: 14px; margin-bottom: 14px;
place-items: center; place-items: center;
border: 1px solid var(--border); border: 1px solid var(--color-app-border);
border-radius: 8px; border-radius: 8px;
color: var(--text-muted); color: var(--color-app-text-muted);
background: var(--surface); background: var(--color-app-surface);
cursor: pointer; cursor: pointer;
font-weight: 800; font-weight: 800;
transition: transition:
@@ -84,14 +103,14 @@
} }
.sidebar__toggle:hover { .sidebar__toggle:hover {
color: var(--primary); color: var(--color-app-primary);
border-color: var(--primary); border-color: var(--color-app-primary);
background: var(--primary-soft); background: var(--color-app-primary-soft);
} }
.sidebar__toggle:focus-visible, .sidebar__toggle:focus-visible,
.sidebar__link:focus-visible { .sidebar__link:focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--color-app-focus);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -106,7 +125,7 @@
min-height: 42px; min-height: 42px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 8px;
color: var(--text-muted); color: var(--color-app-text-muted);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
gap: 10px; gap: 10px;
@@ -123,20 +142,20 @@
height: 28px; height: 28px;
place-items: center; place-items: center;
border-radius: 7px; border-radius: 7px;
color: var(--primary-dark); color: var(--color-app-primary-dark);
background: var(--primary-soft); background: var(--color-app-primary-soft);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
} }
.sidebar__link:hover { .sidebar__link:hover {
color: var(--text); color: var(--color-app-text);
background: var(--interactive-hover); background: var(--color-app-interactive-hover);
} }
.sidebar__link--active { .sidebar__link--active {
color: var(--primary); color: var(--color-app-primary);
background: var(--interactive-active); background: var(--color-app-interactive-active);
} }
.app-shell--collapsed .sidebar { .app-shell--collapsed .sidebar {
@@ -168,7 +187,7 @@
.main-content { .main-content {
min-width: 0; min-width: 0;
padding: 32px; padding: 16px;
} }
.page-header { .page-header {
@@ -188,7 +207,7 @@
.page-header p { .page-header p {
max-width: 720px; max-width: 720px;
margin: 8px 0 0; margin: 8px 0 0;
color: var(--text-muted); color: var(--color-app-text-muted);
} }
.stat-grid { .stat-grid {
@@ -199,10 +218,10 @@
.stat-card, .stat-card,
.content-panel { .content-panel {
border: 1px solid var(--border); border: 1px solid var(--color-app-border);
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--color-app-surface);
box-shadow: 0 16px 42px var(--shadow-color); box-shadow: var(--shadow-app-panel);
} }
.stat-card { .stat-card {
@@ -211,7 +230,7 @@
.stat-card span { .stat-card span {
display: block; display: block;
color: var(--text-muted); color: var(--color-app-text-muted);
font-size: 14px; font-size: 14px;
} }
@@ -233,7 +252,7 @@
.content-panel p { .content-panel p {
margin: 8px 0 0; margin: 8px 0 0;
color: var(--text-muted); color: var(--color-app-text-muted);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -250,7 +269,7 @@
height: auto; height: auto;
padding: 16px; padding: 16px;
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--color-app-border);
} }
.sidebar__brand { .sidebar__brand {
+83
View File
@@ -0,0 +1,83 @@
import { Card, Button } from '@heroui/react';
import { PencilIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
const statusStyles = {
Published: 'bg-green-100 text-green-700',
Draft: 'bg-yellow-100 text-yellow-700',
Archived: 'bg-gray-100 text-gray-600'
};
function CourseCard({ course, image }) {
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
variant="default"
className="w-full max-w-100 overflow-hidden rounded-lg border border-gray-300 bg-white shadow-none"
>
{/* Image */}
<div className="relative h-48 overflow-hidden border-b border-gray-300">
<img src={image} alt={title} className="h-full w-full object-cover" />
{/* Course code */}
<div className="absolute left-4 top-4 rounded-sm border border-gray-300 bg-white px-3 py-1.5">
<span className="text-lg font-semibold text-gray-800">{code}</span>
</div>
</div>
{/* Content */}
<Card.Content className="px-5 py-5">
<h3 className="mb-3 text-xl font-medium leading-tight text-gray-900">
{title}
</h3>
<p className="line-clamp-2 text-lg leading-8 text-gray-700">
{description}
</p>
</Card.Content>
{/* Footer */}
<Card.Footer className="mx-5 flex items-center justify-between border-t border-gray-300 px-0 py-5">
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-sm font-medium ${
statusStyles[status] ?? 'bg-gray-100 text-gray-600'
}`}
>
{status}
</span>
<span className="text-sm text-gray-600">Updated {formattedDate}</span>
</div>
<div className="flex items-center gap-1">
<Button
isIconOnly
variant="ghost"
size="sm"
aria-label={`Edit ${title}`}
>
<PencilIcon className="h-5 w-5" />
</Button>
<Button
isIconOnly
variant="ghost"
size="sm"
aria-label={`More actions for ${title}`}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</Button>
</div>
</Card.Footer>
</Card>
);
}
export default CourseCard;
-14
View File
@@ -1,14 +0,0 @@
function StatGrid({ items }) {
return (
<section className="stat-grid" aria-label="Metrics">
{items.map((item) => (
<article className="stat-card" key={item.label}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</article>
))}
</section>
)
}
export default StatGrid
-121
View File
@@ -1,121 +0,0 @@
export const colors = {
// =========================
// Brand
// =========================
primary: {
main: '#1A73E8',
light: '#E8F1FD',
dark: '#1558B0',
contrast: '#FFFFFF',
},
// =========================
// Text & Icon
// =========================
slate: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A',
},
// =========================
// Background
// =========================
background: {
default: '#F8FAFC',
surface: '#FFFFFF',
muted: '#F1F5F9',
},
// =========================
// Border
// =========================
border: {
default: '#E2E8F0',
input: '#CBD5E1',
focus: '#1A73E8',
divider: '#E2E8F0',
},
// =========================
// Status
// =========================
status: {
success: {
main: '#16A34A',
light: '#DCFCE7',
dark: '#166534',
},
warning: {
main: '#D97706',
light: '#FEF3C7',
dark: '#92400E',
},
error: {
main: '#DC2626',
light: '#FEE2E2',
dark: '#991B1B',
},
info: {
main: '#2563EB',
light: '#DBEAFE',
dark: '#1E40AF',
},
neutral: {
main: '#64748B',
light: '#F1F5F9',
dark: '#475569',
},
},
// =========================
// Interactive
// =========================
interactive: {
hover: '#F1F5F9',
active: '#E8F1FD',
selected: '#E8F1FD',
focus: '#1A73E8',
disabled: '#CBD5E1',
},
// =========================
// Drag & Drop
// =========================
drag: {
outline: '#1A73E8',
background: '#FFFFFF',
dropZone: '#1A73E8',
dropZoneBackground: '#E8F1FD',
},
// =========================
// Overlay / Shadow
// =========================
overlay: {
shadow: 'rgba(0, 0, 0, 0.05)',
backdrop: 'rgba(15, 23, 42, 0.32)',
},
// =========================
// Common
// =========================
common: {
white: '#FFFFFF',
black: '#000000',
transparent: 'transparent',
},
} as const;
export type Colors = typeof colors;
-26
View File
@@ -1,26 +0,0 @@
import { colors } from './color'
export const themeCssVariables = {
'--bg': colors.background.default,
'--surface': colors.background.surface,
'--surface-muted': colors.background.muted,
'--border': colors.border.default,
'--border-input': colors.border.input,
'--text': colors.slate[800],
'--text-strong': colors.slate[900],
'--text-muted': colors.slate[500],
'--primary': colors.primary.main,
'--primary-dark': colors.primary.dark,
'--primary-soft': colors.primary.light,
'--primary-contrast': colors.primary.contrast,
'--interactive-hover': colors.interactive.hover,
'--interactive-active': colors.interactive.active,
'--focus': colors.interactive.focus,
'--shadow-color': colors.overlay.shadow,
}
export function applyThemeCssVariables() {
Object.entries(themeCssVariables).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value)
})
}
+2 -17
View File
@@ -1,27 +1,12 @@
:root { :root {
--bg: #f6f7f9;
--surface: #ffffff;
--surface-muted: #f0f3f7;
--border: #dfe3ea;
--text: #1d2433;
--text-muted: #667085;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-soft: #e9f0ff;
--primary-contrast: #ffffff;
--interactive-hover: #f0f3f7;
--interactive-active: #e9f0ff;
--focus: #2563eb;
--shadow: 0 16px 42px rgba(29, 36, 51, 0.08);
--shadow-color: rgba(0, 0, 0, 0.05);
--sidebar-width: 264px; --sidebar-width: 264px;
--sidebar-collapsed-width: 88px; --sidebar-collapsed-width: 88px;
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif; sans-serif;
color: var(--text); color: var(--color-app-text);
background: var(--bg); background: var(--color-app-bg);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-3
View File
@@ -2,9 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { applyThemeCssVariables } from './config/theme.js'
applyThemeCssVariables()
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
+109 -16
View File
@@ -1,29 +1,54 @@
import PageHeader from '../../components/PageHeader.jsx'; import CourseCard from '../../components/CourseCard.jsx';
import StatGrid from '../../components/StatGrid.jsx'; import {
import { Typography, SearchField, Separator } from '@heroui/react'; Typography,
SearchField,
Separator,
Button,
Tabs
} from '@heroui/react';
import { import {
BellIcon, BellIcon,
QuestionMarkCircleIcon, QuestionMarkCircleIcon,
UserCircleIcon UserCircleIcon,
FunnelIcon,
PlusIcon
} from '@heroicons/react/16/solid'; } from '@heroicons/react/16/solid';
const stats = [ const stats = [
{ label: 'Khoa hoc dang mo', value: '24' }, {
{ label: 'Hoc vien', value: '1,248' }, code: 'LDR-101',
{ label: 'Lop trong tuan', value: '18' }, title: 'Foundations of Modern Leadership',
{ label: 'Ty le hoan thanh', value: '82%' } description:
'An introductory course covering essential leadership principles for new managers.',
status: 'Published',
lastUpdate: '2026-08-19'
},
{
code: 'TEC-204',
title: 'Advanced Cloud Architecture Patterns',
description:
'Deep dive into scalable cloud infrastructure design and implementation strategies.',
status: 'Draft',
lastUpdate: '2026-08-19'
},
{
code: 'CMP-099',
title: '2022 Annual Compliance Review',
description: 'Outdated compliance materials from the previous fiscal year.',
status: 'Archived',
lastUpdate: '2026-08-19'
}
]; ];
const courseImage =
'https://images.unsplash.com/photo-1556761175-b413da4baf72?auto=format&fit=crop&w=800&q=80';
function DashboardPage() { function DashboardPage() {
return ( return (
<> <>
{/* <PageHeader
title="Tong quan"
description="Theo doi nhanh tinh hinh dao tao va cac chi so LMS."
/> */}
<div className="mb-3 flex flex-row items-center justify-between"> <div className="mb-3 flex flex-row items-center justify-between">
<SearchField name="search"> <SearchField name="search">
<SearchField.Group className="w-[280px]"> <SearchField.Group className="rounded-xl border border-gray-300 w-100">
<SearchField.SearchIcon /> <SearchField.SearchIcon />
<SearchField.Input <SearchField.Input
@@ -34,14 +59,15 @@ function DashboardPage() {
<SearchField.ClearButton /> <SearchField.ClearButton />
</SearchField.Group> </SearchField.Group>
</SearchField> </SearchField>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-5">
<BellIcon className="mr-1 inline h-6 w-6" /> <BellIcon className="mr-1 inline h-6 w-6" />
<QuestionMarkCircleIcon 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" /> <UserCircleIcon className="mr-1 inline h-6 w-6" />
</div> </div>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="mb-4"> <div className="mb-4 flex flex-row items-center justify-between">
<div>
<Typography variant="h3" className="mb-2"> <Typography variant="h3" className="mb-2">
Course Course
</Typography> </Typography>
@@ -49,7 +75,74 @@ function DashboardPage() {
Manage and organize your learning content. Manage and organize your learning content.
</Typography> </Typography>
</div> </div>
<StatGrid items={stats} /> <div className="flex flex-row item-center gap-2">
<Button variant="outline" className="rounded-lg">
<FunnelIcon />
Filter
</Button>
<Button className="rounded-lg">
<PlusIcon />
New Course
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="flex flex-row items-center justify-between my-3 bg-white rounded-md border p-3">
<Tabs defaultSelectedKey="all" className="w-fit">
<Tabs.ListContainer>
<Tabs.List
aria-label="Course status"
className="gap-1 rounded-md bg-gray-100"
>
<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"
>
Draft
<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"
>
Published
<Tabs.Indicator className="rounded bg-white shadow-sm" />
</Tabs.Tab>
<Tabs.Tab
id="archived"
className="h-7 rounded px-6 py-5 text-base font-normal text-gray-500"
>
Archived
<Tabs.Indicator className="rounded bg-white shadow-sm" />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<SearchField name="search">
<SearchField.Group className="rounded-xl border border-gray-300 w-100">
<SearchField.SearchIcon />
<SearchField.Input placeholder="Search course by title or code..." />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-4">
{stats.map((course) => (
<CourseCard key={course.code} course={course} image={courseImage} />
))}
</div>
</> </>
); );
} }