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

View file

@ -102,6 +102,7 @@ var websocket = null
- **Pinia** - Type-safe state management - **Pinia** - Type-safe state management
- **VueUse** - Composables for common tasks - **VueUse** - Composables for common tasks
- **Vue I18n** - Internationalization - **Vue I18n** - Internationalization
- **shadcn-vue** - Shared components with admin UI
> [!note] Why Not Svelte? > [!note] Why Not Svelte?
> Svelte has the smallest bundle, but Vue has: > Svelte has the smallest bundle, but Vue has:
@ -109,6 +110,9 @@ var websocket = null
> - More developers familiar with it > - More developers familiar with it
> - Better tooling maturity > - 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 #vue #framework
--- ---

View file

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