Skip to content

Tutorial: build your first admin page ​

This tutorial takes you from an empty project to an admin page with a menu, a dashboard, a data table, a validated form, delete confirmation, and language/theme switching. The examples use Vue 3 + Vite; each step ends with notes for React and Angular.

Requirements: Node 20+, and one of: Vue ≥ 3.5.2, React ≥ 18, Angular ≥ 20.

1. Create the project and install thl-ui ​

sh
npm create vite@latest my-admin -- --template vue-ts
cd my-admin
npm i @thl-ui/vue @thl-ui/styles vue-router

@thl-ui/core, @thl-ui/locales, @thl-ui/tokens, @thl-ui/icons and @thl-ui/charts are installed automatically. You don't need Bootstrap, jQuery or Google Fonts: the CSS and the Open Sans font are all included in @thl-ui/styles.

React / Angular
sh
npm i @thl-ui/react @thl-ui/styles react-router        # React
npm i @thl-ui/angular @thl-ui/styles                    # Angular

2. Register the plugin and CSS ​

src/main.ts:

ts
import { createApp } from 'vue';
import ThlUI from '@thl-ui/vue';
import '@thl-ui/styles/thl-ui.css';
import App from './App.vue';
import { router } from './router';

createApp(App)
  .use(router)
  .use(ThlUI, {
    locale: 'en',          // default language: en · ja · vi · zh
    theme: 'default',      // one of 12 skins
    colorMode: 'auto',     // light · dark · auto (follows the OS)
    persist: true,         // remember the user's choices in localStorage
  })
  .mount('#app');

Add the thl-body class to <body> in index.html to get the base font, background color and heading sizes:

html
<body class="thl-body">
React / Angular

React: wrap the app in <ThlProvider config={{ locale: 'en', persist: true }}>. Angular: bootstrapApplication(App, { providers: [provideZonelessChangeDetection(), provideThlUI({ locale: 'en' })] }) and add node_modules/@thl-ui/styles/dist/thl-ui.css to styles in angular.json.

3. Page shell: menu, topbar, sidebar ​

The menu is an array of MenuItem. label can be an i18n key (translated automatically) or plain text.

src/menu.ts:

ts
import type { MenuItem } from '@thl-ui/core';

export const menu: MenuItem[] = [
  { key: 'dash', label: 'menu.dashboard', icon: 'layout-dashboard', href: '/' },
  { key: 'users', label: 'Users', icon: 'users', href: '/users' },
  { key: 'settings', label: 'Settings', icon: 'settings', children: [
    { key: 'profile', label: 'Profile', href: '/settings/profile' },
  ] },
];

src/App.vue:

vue
<script setup lang="ts">
import { useRoute, RouterLink, RouterView } from 'vue-router';
import { ThlAppLayout } from '@thl-ui/vue';
import { menu } from './menu';

const route = useRoute();
const user = { name: 'An Nguyễn', role: 'Administrator', avatar: '/me.jpg' };
const Nodes = (p: { nodes: unknown }) => p.nodes;
</script>

<template>
  <ThlAppLayout :menu="menu" :current-path="route.path" :user="user" brand="My Admin">
    <!-- make the menu use RouterLink instead of <a href> (no full page reload) -->
    <template #link="{ item, content, class: cls, current }">
      <RouterLink :to="item.href" :class="cls" :aria-current="current ? 'page' : undefined"><Nodes :nodes="content" /></RouterLink>
    </template>
    <RouterView />
  </ThlAppLayout>
</template>

Out of the box you get: a topbar with search, language and light/dark toggles, and a user menu; a multi-level sidebar that highlights the active item; and a settings panel (the ⚙ button) where users can change skin, layout, color mode and language. Below 1170px the sidebar collapses automatically; below 768px it becomes a slide-out menu.

Available slots: brand, topbar-left, topbar-right, link, sidebar-footer, footer.

4. A dashboard page ​

Wrap each page in ThlPage (title + breadcrumb). Layout uses the 12-column thl-row / thl-col-* grid: below 768px every column stacks into a single column.

src/pages/Dashboard.vue:

vue
<script setup lang="ts">
import { ThlPage, ThlInfoBox, ThlCard, ThlLineChart, ThlLabel } from '@thl-ui/vue';
const series = [{ name: 'Revenue', data: [12, 19, 14, 25, 22, 30] }];
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
</script>

<template>
  <ThlPage title="Overview" :breadcrumb="[{ label: 'Home', href: '/' }, { label: 'Overview' }]">
    <div class="thl-row">
      <div class="thl-col-12 thl-col-sm-6 thl-col-lg-3">
        <ThlInfoBox icon="shopping-cart" value="154" title="Orders">
          <template #footer>Target <ThlLabel color="success" rounded>300</ThlLabel></template>
        </ThlInfoBox>
      </div>
      <!-- …3 more boxes… -->
    </div>
    <div class="thl-row">
      <div class="thl-col-12 thl-col-lg-8">
        <ThlCard title="Revenue, last 6 months"><ThlLineChart :series="series" :labels="labels" smooth area /></ThlCard>
      </div>
    </div>
  </ThlPage>
</template>

Grid classes: thl-col-{1..12} (all sizes), thl-col-sm-* (≥ 768px), thl-col-md-* (≥ 1024px), thl-col-lg-* (≥ 1170px).

5. Data table with search, sorting and pagination ​

vue
<script setup lang="ts">
import { ref } from 'vue';
import { ThlPage, ThlCard, ThlDataTable, ThlButton, ThlLabel, confirm, useToast } from '@thl-ui/vue';

const toast = useToast();
const users = ref([
  { id: 1, name: 'An', email: '[email protected]', role: 'admin' },
  { id: 2, name: 'Bình', email: '[email protected]', role: 'user' },
]);
const columns = [
  { key: 'name', title: 'Name', sortable: true },
  { key: 'email', title: 'Email' },
  { key: 'role', title: 'Role', sortable: true },
  { key: 'actions', title: '', searchable: false },
];

async function remove(row: { id: number; name: string }) {
  if (!(await confirm({ title: `Delete ${row.name}?`, icon: 'warning', confirmVariant: 'danger' }))) return;
  users.value = users.value.filter((u) => u.id !== row.id);
  toast.success('Deleted');
}
</script>

<template>
  <ThlPage title="Users">
    <ThlCard>
      <ThlDataTable :data="users" :columns="columns" row-key="id" selectable :page-size="10">
        <!-- customize cells with the cell-{key} slot -->
        <template #cell-role="{ value }">
          <ThlLabel :color="value === 'admin' ? 'danger' : 'info'" rounded>{{ value }}</ThlLabel>
        </template>
        <template #cell-actions="{ row }">
          <ThlButton size="sm" variant="danger" outline icon="trash-2" :aria-label="`Delete ${row.name}`" @click="remove(row)" />
        </template>
      </ThlDataTable>
    </ThlCard>
  </ThlPage>
</template>

Large server-side datasets: add manual and :total="totalCount", then load data in the @query-change="({ page, pageSize, sort, search }) => …" event.

6. Form with validation ​

Error messages are translated into the current language. Errors only appear after the first Submit, then update as soon as the user edits.

vue
<script setup lang="ts">
import { reactive } from 'vue';
import { ThlForm, ThlField, ThlInput, ThlSelect, ThlButton, ThlFormActions, rules, useToast } from '@thl-ui/vue';

const toast = useToast();
const v = reactive({ name: '', email: '', role: null as string | null });
const roles = [{ label: 'Admin', value: 'admin' }, { label: 'Users', value: 'user' }];
const save = (ok: boolean) => ok && toast.success('Saved');
</script>

<template>
  <ThlForm :values="v" @submit="save">
    <ThlField label="Full name" name="name" :value="v.name" :rules="[rules.required(), rules.minLength(2)]" required v-slot="{ id }">
      <ThlInput :id="id" v-model="v.name" />
    </ThlField>
    <ThlField label="Email" name="email" :value="v.email" :rules="[rules.required(), rules.email()]" required v-slot="{ id }">
      <ThlInput :id="id" v-model="v.email" type="email" />
    </ThlField>
    <ThlField label="Role" name="role" :value="v.role" :rules="[rules.required()]" required>
      <ThlSelect v-model="v.role" :options="roles" />
    </ThlField>
    <ThlFormActions>
      <ThlButton type="submit" variant="primary">Save</ThlButton>
    </ThlFormActions>
  </ThlForm>
</template>

Built-in rules: required, email, minLength(n), maxLength(n), min(n), max(n), number, pattern(re), match('fieldName'). Custom rules: a function that returns null (valid) or { key: 'i18n.key', params }.

7. Toasts and confirm dialogs ​

ts
import { useToast, confirm } from '@thl-ui/vue';
const toast = useToast();

toast.success('Saved');
toast.error('Could not connect', { title: 'Error', duration: 0 }); // 0 = don't auto-close
if (await confirm({ title: 'Sign out?', icon: 'question' })) logout();

AppLayout already mounts the toast and confirm dialog hosts. If you don't use AppLayout, place <ThlToaster /> and <ThlConfirmHost /> once at the app root.

8. Language, theme, color mode ​

Users can change these in the ⚙ panel. From code:

ts
import { useThlConfig, useI18n } from '@thl-ui/vue';
const { set } = useThlConfig();
const { t, setLocale } = useI18n();

set({ theme: 'purple-dark', colorMode: 'dark', layout: 'horizontal' });
setLocale('en');
t('common.save'); // "Save"

Add your own translation strings at setup:

ts
app.use(ThlUI, {
  locale: 'en',
  messages: {
    vi: { app: { welcome: 'Xin chào, {name}!' } },
    en: { app: { welcome: 'Hello, {name}!' } },
  },
});
// in a template: {{ $t('app.welcome', { name: user.name }) }}

Change the brand color by overriding CSS variables, for example:

css
:root { --thl-theme: #7c3aed; --thl-on-theme: #fff; --thl-theme-text: #6d28d9; }

See Theme & dark mode for all variables and how to keep sufficient contrast.

9. Login and error pages ​

Pages without a sidebar use their own layouts:

vue
<ThlAuthLayout title="Sign in">
  <ThlForm :values="v" @submit="login">…</ThlForm>
</ThlAuthLayout>

<ThlErrorLayout :code="404" />  <!-- title and description are translated automatically -->

In the router, mark these routes so App.vue doesn't wrap them in ThlAppLayout (see meta.blank in apps/playground-vue).

10. Going to production ​

  • npm run build: Vite bundles the CSS and fonts automatically (only the font subsets you need are loaded).
  • Import only the components you use (import { ThlButton } from '@thl-ui/vue') and install the plugin without global registration (app.use(ThlUI) → import { ThlUI } from '@thl-ui/vue') for a smaller bundle.
  • SSR (Nuxt, Next, Angular SSR) is supported: components don't touch window when rendering on the server.

Next steps ​