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
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue