Switch UI component library from PrimeVue to shadcn-vue

Benefits of shadcn-vue:
- Copy components to repo (you own the code)
- Tailwind-native (no style conflicts)
- Radix Vue primitives (a11y-first)
- Much smaller bundle (~15kb vs ~80kb tree-shaken)
- Full control over styling and behavior

Updated:
- Admin UI: Complete shadcn-vue examples with TanStack Table
- Machine UI: Note about shared shadcn-vue components
- Modernization plan: Updated architecture diagram and bundle sizes
- Bundle target: ~60kb total (down from ~120kb with PrimeVue)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-22 14:54:45 -05:00
commit 9550d132dd
3 changed files with 286 additions and 221 deletions

View file

@ -15,7 +15,7 @@ priority: high
# Admin UI Modernization
> [!abstract] Summary
> Migrate the admin dashboard from **React 18 + MUI** to **Vue 3** for consistency with the machine UI, using PrimeVue for components and tRPC for type-safe API communication.
> Migrate the admin dashboard from **React 18 + MUI** to **Vue 3** for consistency with the machine UI, using **shadcn-vue** for components and tRPC for type-safe API communication.
## Quick Links
@ -118,9 +118,9 @@ packages/admin-ui/
│ │ └── SettingsPage.vue ││
│ └─────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────┐│
│ │ UI Components (PrimeVue) ││
│ │ ├── DataTable, Charts, Forms ││
│ │ └── Custom components ││
│ │ UI Components (shadcn-vue + Radix Vue) ││
│ │ ├── Button, Card, Dialog, Table... ││
│ │ └── Custom: CryptoAmount, StatusBadge... ││
│ └─────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────┐│
│ │ State (Pinia) ││
@ -241,147 +241,224 @@ packages/admin-ui/
## UI Component Library
### PrimeVue vs Alternatives
### shadcn-vue vs Alternatives
| Library | Bundle | Components | Vue 3 | Tailwind |
|---------|--------|------------|-------|----------|
| **PrimeVue** | Tree-shakeable | 90+ | Yes | Yes |
| Naive UI | ~80kb | 80+ | Yes | Manual |
| Vuetify 3 | ~300kb | 80+ | Yes | No |
| Element Plus | ~200kb | 70+ | Yes | No |
| Headless UI | ~10kb | 10 | Yes | Yes |
| Library | Approach | Bundle | Tailwind | Control |
|---------|----------|--------|----------|---------|
| **shadcn-vue** | Copy to repo | Zero overhead | Native | Full |
| PrimeVue | npm install | Tree-shake | Adapter | Theme only |
| Vuetify 3 | npm install | ~300kb | No | Theme only |
| Headless UI | npm install | ~10kb | Yes | Style only |
| Radix Vue | npm install | ~15kb | Yes | Full |
> [!decision] PrimeVue
> - **Tree-shakeable** - Only import what you use
> - **Tailwind integration** - PrimeVue + Tailwind preset
> - **Rich data table** - Critical for admin dashboards
> - **Chart components** - Built-in Chart.js integration
> - **Accessibility** - WCAG compliant
> - **Unstyled mode** - Full control with Tailwind
> [!decision] shadcn-vue
> - **Copy, don't install** - Components live in your codebase
> - **Tailwind-native** - No style conflicts or overrides
> - **Radix Vue primitives** - Accessible, unstyled foundation
> - **Full control** - Modify any component directly
> - **No library updates** - You own the code
> - **Consistent** - Same approach for admin + machine UI
### PrimeVue Setup
### shadcn-vue Setup
```bash
# Initialize shadcn-vue
npx shadcn-vue@latest init
# Add components as needed
npx shadcn-vue@latest add button
npx shadcn-vue@latest add card
npx shadcn-vue@latest add data-table
npx shadcn-vue@latest add dialog
npx shadcn-vue@latest add dropdown-menu
npx shadcn-vue@latest add input
npx shadcn-vue@latest add toast
```
```typescript
// src/main.ts
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import { pinia } from './stores'
import './styles/globals.css' // Tailwind + shadcn styles
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities',
},
},
},
})
app.use(ToastService)
app.use(ConfirmationService)
app.use(router)
app.use(pinia)
app.mount('#app')
```
### Component Structure
```
src/components/
├── ui/ # shadcn-vue components (copied)
│ ├── button/
│ │ ├── Button.vue
│ │ └── index.ts
│ ├── card/
│ │ ├── Card.vue
│ │ ├── CardHeader.vue
│ │ ├── CardContent.vue
│ │ └── index.ts
│ ├── data-table/
│ │ ├── DataTable.vue
│ │ ├── DataTablePagination.vue
│ │ └── index.ts
│ ├── dialog/
│ ├── dropdown-menu/
│ ├── input/
│ ├── toast/
│ └── ...
└── custom/ # Your custom components
├── CryptoAmount.vue
├── FiatAmount.vue
└── StatusBadge.vue
```
### Component Examples
#### Data Table Wrapper
#### Data Table with TanStack Table
```vue
<!-- src/components/common/DataTable.vue -->
<script setup lang="ts" generic="T">
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import InputText from 'primevue/inputtext'
import { ref, computed } from 'vue'
<!-- src/components/custom/TransactionsTable.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import {
FlexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useVueTable,
type ColumnDef,
type SortingState,
} from '@tanstack/vue-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import type { Transaction } from '@/types'
interface Props {
data: T[]
columns: ColumnDef<T>[]
const props = defineProps<{
data: Transaction[]
loading?: boolean
paginator?: boolean
rows?: number
globalFilter?: boolean
}
interface ColumnDef<T> {
field: keyof T
header: string
sortable?: boolean
filterable?: boolean
body?: (row: T) => VNode
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
paginator: true,
rows: 20,
globalFilter: true,
})
const emit = defineEmits<{
rowClick: [row: T]
rowSelect: [rows: T[]]
}>()
const filters = ref({
global: { value: null, matchMode: 'contains' },
})
const emit = defineEmits<{
rowClick: [row: Transaction]
}>()
const selectedRows = ref<T[]>([])
const sorting = ref<SortingState>([])
const globalFilter = ref('')
const columns: ColumnDef<Transaction>[] = [
{ accessorKey: 'id', header: 'ID' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => h(StatusBadge, { status: row.original.status }),
},
{ accessorKey: 'direction', header: 'Type' },
{
accessorKey: 'fiatAmount',
header: 'Fiat',
cell: ({ row }) => h(FiatAmount, {
amount: row.original.fiatAmount,
currency: row.original.fiatCode,
}),
},
{
accessorKey: 'cryptoAmount',
header: 'Crypto',
cell: ({ row }) => h(CryptoAmount, {
amount: row.original.cryptoAmount,
coin: row.original.cryptoCode,
}),
},
{
accessorKey: 'created',
header: 'Date',
cell: ({ row }) => h(DateTimeDisplay, { date: row.original.created }),
},
]
const table = useVueTable({
get data() { return props.data },
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
get sorting() { return sorting.value },
get globalFilter() { return globalFilter.value },
},
onSortingChange: (updater) => {
sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
},
})
</script>
<template>
<div class="card">
<div v-if="globalFilter" class="flex justify-end mb-4">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText
v-model="filters.global.value"
placeholder="Search..."
class="w-64"
/>
</span>
<div class="space-y-4">
<div class="flex items-center justify-between">
<Input
v-model="globalFilter"
placeholder="Search transactions..."
class="max-w-sm"
/>
</div>
<DataTable
:value="data"
:loading="loading"
:paginator="paginator"
:rows="rows"
:filters="filters"
v-model:selection="selectedRows"
dataKey="id"
stripedRows
showGridlines
class="p-datatable-sm"
@row-click="(e) => emit('rowClick', e.data)"
>
<Column
v-for="col in columns"
:key="String(col.field)"
:field="String(col.field)"
:header="col.header"
:sortable="col.sortable"
>
<template v-if="col.body" #body="{ data: row }">
<component :is="col.body(row)" />
</template>
</Column>
</DataTable>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id">
<Button
v-if="header.column.getCanSort()"
variant="ghost"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</Button>
<FlexRender
v-else
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
class="cursor-pointer hover:bg-muted/50"
@click="emit('rowClick', row.original)"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<DataTablePagination :table="table" />
</div>
</template>
```
@ -391,76 +468,58 @@ const selectedRows = ref<T[]>([])
```vue
<!-- src/pages/transactions/TransactionsPage.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useTransactionsStore } from '@/stores/transactions'
import { storeToRefs } from 'pinia'
import DataTable from '@/components/common/DataTable.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import CryptoAmount from '@/components/common/CryptoAmount.vue'
import FiatAmount from '@/components/common/FiatAmount.vue'
import DateTimeDisplay from '@/components/common/DateTimeDisplay.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import TransactionsTable from '@/components/custom/TransactionsTable.vue'
import { Download, RefreshCw } from 'lucide-vue-next'
const router = useRouter()
const store = useTransactionsStore()
const { transactions, loading } = storeToRefs(store)
const columns = [
{ field: 'id', header: 'ID', sortable: true },
{
field: 'status',
header: 'Status',
sortable: true,
body: (row) => h(StatusBadge, { status: row.status }),
},
{ field: 'direction', header: 'Type', sortable: true },
{
field: 'fiatAmount',
header: 'Fiat',
sortable: true,
body: (row) => h(FiatAmount, { amount: row.fiatAmount, currency: row.fiatCode }),
},
{
field: 'cryptoAmount',
header: 'Crypto',
sortable: true,
body: (row) => h(CryptoAmount, { amount: row.cryptoAmount, coin: row.cryptoCode }),
},
{
field: 'created',
header: 'Date',
sortable: true,
body: (row) => h(DateTimeDisplay, { date: row.created }),
},
]
function handleRowClick(transaction: Transaction) {
router.push(`/transactions/${transaction.id}`)
}
// Load on mount
onMounted(() => store.fetchTransactions())
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Transactions</h1>
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-3xl font-bold tracking-tight">Transactions</h1>
<div class="flex gap-2">
<Button label="Export CSV" icon="pi pi-download" outlined />
<Button label="Refresh" icon="pi pi-refresh" @click="store.fetchTransactions" />
<Button variant="outline">
<Download class="mr-2 h-4 w-4" />
Export CSV
</Button>
<Button @click="store.fetchTransactions" :disabled="loading">
<RefreshCw class="mr-2 h-4 w-4" :class="{ 'animate-spin': loading }" />
Refresh
</Button>
</div>
</div>
<DataTable
:data="transactions"
:columns="columns"
:loading="loading"
@row-click="handleRowClick"
/>
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<TransactionsTable
:data="transactions"
:loading="loading"
@row-click="handleRowClick"
/>
</CardContent>
</Card>
</div>
</template>
```
#primevue #components
#shadcn #components
---
@ -594,6 +653,14 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { startAuthentication } from '@simplewebauthn/browser'
import { trpc } from '@/api/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { KeyRound, Lock, Loader2 } from 'lucide-vue-next'
const router = useRouter()
const auth = useAuthStore()
@ -632,52 +699,49 @@ async function handlePasskeyLogin() {
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="min-h-screen flex items-center justify-center bg-background">
<Card class="w-full max-w-md">
<template #title>
<div class="text-center">
<img src="/logo.svg" alt="Lamassu" class="h-12 mx-auto mb-4" />
<h1 class="text-2xl font-bold">Admin Login</h1>
</div>
</template>
<CardHeader class="text-center">
<img src="/logo.svg" alt="Lamassu" class="h-12 mx-auto mb-4" />
<CardTitle class="text-2xl">Admin Login</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<template #content>
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email</label>
<InputText
id="email"
v-model="email"
type="email"
class="w-full"
placeholder="admin@example.com"
/>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="email"
type="email"
placeholder="admin@example.com"
/>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<Button class="w-full" :disabled="loading" @click="handlePasskeyLogin">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
<KeyRound v-else class="mr-2 h-4 w-4" />
Sign in with Passkey
</Button>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<Separator class="w-full" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">or</span>
</div>
<Message v-if="error" severity="error" :closable="false">
{{ error }}
</Message>
<Button
label="Sign in with Passkey"
icon="pi pi-key"
class="w-full"
:loading="loading"
@click="handlePasskeyLogin"
/>
<Divider align="center">or</Divider>
<Button
label="Sign in with Password"
icon="pi pi-lock"
class="w-full"
severity="secondary"
outlined
@click="router.push('/auth/password')"
/>
</div>
</template>
<Button variant="outline" class="w-full" @click="router.push('/auth/password')">
<Lock class="mr-2 h-4 w-4" />
Sign in with Password
</Button>
</CardContent>
</Card>
</div>
</template>
@ -821,15 +885,12 @@ Migrate pages in order of complexity:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [PrimeVueResolver()],
}),
tailwindcss(),
],
resolve: {
@ -846,7 +907,6 @@ export default defineConfig({
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
primevue: ['primevue'],
charts: ['chart.js', 'vue-chartjs'],
},
},
@ -889,13 +949,16 @@ export default defineConfig({
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"@primevue/themes": "^4.2.0",
"primevue": "^4.2.0",
"primeicons": "^7.0.0",
"radix-vue": "^1.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0",
"@tanstack/vue-table": "^8.20.0",
"@trpc/client": "^11.0.0",
"@tanstack/vue-query": "^5.60.0",
"@vueuse/core": "^11.0.0",
"@simplewebauthn/browser": "^10.0.0",
"lucide-vue-next": "^0.460.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0"
},
@ -910,9 +973,7 @@ export default defineConfig({
"tailwindcss": "^4.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"unplugin-vue-components": "^0.27.0",
"@primevue/auto-import-resolver": "^4.2.0"
"eslint-plugin-vue": "^9.30.0"
}
}
```
@ -926,7 +987,7 @@ export default defineConfig({
| Aspect | Before (React) | After (Vue 3) |
|--------|---------------|---------------|
| Framework | React 18 | Vue 3 |
| UI Library | MUI (~300kb) | PrimeVue (tree-shake) |
| UI Library | MUI (~300kb) | shadcn-vue (own code) |
| State | Zustand | Pinia |
| API | Apollo GraphQL | tRPC |
| Styling | Tailwind | Tailwind (keep) |
@ -939,11 +1000,11 @@ export default defineConfig({
| Chunk | Before | After |
|-------|--------|-------|
| Framework | ~42kb | ~33kb |
| UI Library | ~300kb | ~80kb* |
| UI Library | ~300kb | ~15kb* |
| API Client | ~50kb | ~5kb |
| **Total** | **~400kb** | **~120kb** |
| **Total** | **~400kb** | **~60kb** |
*PrimeVue with tree-shaking, only used components
*shadcn-vue components are copied to your codebase, only includes what you use
#comparison #summary

View file

@ -102,6 +102,7 @@ var websocket = null
- **Pinia** - Type-safe state management
- **VueUse** - Composables for common tasks
- **Vue I18n** - Internationalization
- **shadcn-vue** - Shared components with admin UI
> [!note] Why Not Svelte?
> Svelte has the smallest bundle, but Vue has:
@ -109,6 +110,9 @@ var websocket = null
> - More developers familiar with it
> - Better tooling maturity
> [!tip] Shared UI Components
> Use shadcn-vue for base components (Button, Card, etc.) to share code between machine and admin UIs via `@lamassu/ui-shared` package.
#vue #framework
---

View file

@ -42,7 +42,7 @@ graph TB
end
subgraph "Admin UI"
vue_admin[Vue 3 + PrimeVue]
vue_admin[Vue 3 + shadcn-vue]
tailwind[Tailwind CSS]
end
@ -161,10 +161,10 @@ graph LR
| Aspect | Before (React) | After (Vue 3) |
|--------|---------------|---------------|
| Framework | React 18 | Vue 3 |
| UI Library | MUI (~300kb) | PrimeVue (tree-shake) |
| UI Library | MUI (~300kb) | shadcn-vue (~15kb) |
| State | Zustand | Pinia |
| API | Apollo GraphQL | tRPC |
| Bundle | ~400kb | ~120kb |
| Bundle | ~400kb | ~60kb |
**Benefits:**
- Single framework across all UIs