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:
parent
7251692464
commit
9550d132dd
3 changed files with 286 additions and 221 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue