@veristone/nuxt-v-app 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ <script setup>
2
+ import { useTimeAgo } from '@vueuse/core'
3
+
4
+ /**
5
+ * VATableCellRenderer - Renders cell content based on column preset
6
+ *
7
+ * Automatically formats values based on column meta.preset:
8
+ * - text: Plain text (default)
9
+ * - email: Clickable email link
10
+ * - badge: Status badge with color mapping
11
+ * - date: Formatted date/time
12
+ * - currency: Formatted currency
13
+ * - number: Formatted number
14
+ * - boolean: Checkbox/icon
15
+ * - avatar: Avatar with optional name
16
+ * - link: Clickable URL
17
+ */
18
+
19
+ defineOptions({
20
+ name: 'VATableCellRenderer'
21
+ })
22
+
23
+ const props = defineProps({
24
+ value: {
25
+ type: [String, Number, Boolean, Date, Object, Array],
26
+ default: null
27
+ },
28
+ column: {
29
+ type: Object,
30
+ required: true
31
+ },
32
+ row: {
33
+ type: Object,
34
+ default: () => ({})
35
+ }
36
+ })
37
+
38
+ // Get preset from column meta
39
+ const preset = computed(() => props.column?.meta?.preset || 'text')
40
+
41
+ // Default badge color mappings
42
+ const defaultColorMap = {
43
+ active: 'success',
44
+ inactive: 'neutral',
45
+ pending: 'warning',
46
+ approved: 'success',
47
+ rejected: 'error',
48
+ completed: 'success',
49
+ cancelled: 'error',
50
+ draft: 'neutral',
51
+ published: 'success',
52
+ archived: 'neutral',
53
+ enabled: 'success',
54
+ disabled: 'neutral',
55
+ true: 'success',
56
+ false: 'neutral',
57
+ yes: 'success',
58
+ no: 'neutral'
59
+ }
60
+
61
+ // Format date based on format option
62
+ function formatDate(value, format) {
63
+ if (!value) return '—'
64
+ const date = new Date(value)
65
+ if (isNaN(date.getTime())) return value
66
+
67
+ switch (format) {
68
+ case 'relative':
69
+ return useTimeAgo(date).value
70
+ case 'time':
71
+ return date.toLocaleTimeString()
72
+ case 'datetime':
73
+ return date.toLocaleString()
74
+ case 'date':
75
+ default:
76
+ return date.toLocaleDateString()
77
+ }
78
+ }
79
+
80
+ // Format currency
81
+ function formatCurrency(value, currency = 'USD', locale = 'en-US') {
82
+ if (value == null) return '—'
83
+ return new Intl.NumberFormat(locale, {
84
+ style: 'currency',
85
+ currency
86
+ }).format(value)
87
+ }
88
+
89
+ // Format number
90
+ function formatNumber(value, decimals, locale = 'en-US') {
91
+ if (value == null) return '—'
92
+ const opts = decimals != null ? { minimumFractionDigits: decimals, maximumFractionDigits: decimals } : {}
93
+ return new Intl.NumberFormat(locale, opts).format(value)
94
+ }
95
+
96
+ // Get badge color from value
97
+ function getBadgeColor(value) {
98
+ const colorMap = props.column?.meta?.colorMap || {}
99
+ const key = String(value).toLowerCase()
100
+ return colorMap[key] || colorMap[value] || defaultColorMap[key] || 'neutral'
101
+ }
102
+
103
+ // Formatted display value
104
+ const displayValue = computed(() => {
105
+ const val = props.value
106
+ const meta = props.column?.meta || {}
107
+
108
+ switch (preset.value) {
109
+ case 'email':
110
+ return val || '—'
111
+ case 'date':
112
+ return formatDate(val, meta.format)
113
+ case 'currency':
114
+ return formatCurrency(val, meta.currency, meta.locale)
115
+ case 'number':
116
+ return formatNumber(val, meta.decimals, meta.locale)
117
+ case 'boolean':
118
+ return '' // Icon only
119
+ case 'badge':
120
+ return val || '—'
121
+ case 'link':
122
+ return val || '—'
123
+ case 'avatar':
124
+ return meta.showName !== false ? (props.row?.name || props.row?.title || val) : ''
125
+ default:
126
+ return val ?? '—'
127
+ }
128
+ })
129
+
130
+ // Avatar URL (for avatar preset)
131
+ const avatarUrl = computed(() => {
132
+ if (preset.value !== 'avatar') return null
133
+ const val = props.value
134
+ // If value is a string URL, use it directly
135
+ if (typeof val === 'string' && (val.startsWith('http') || val.startsWith('/'))) {
136
+ return val
137
+ }
138
+ // If value is an object with avatar/image/url property
139
+ if (val && typeof val === 'object') {
140
+ return val.avatar || val.image || val.url || val.src
141
+ }
142
+ return null
143
+ })
144
+ </script>
145
+
146
+ <template>
147
+ <!-- Email -->
148
+ <a
149
+ v-if="preset === 'email' && value"
150
+ :href="`mailto:${value}`"
151
+ class="text-primary-600 dark:text-primary-400 hover:underline"
152
+ >
153
+ {{ displayValue }}
154
+ </a>
155
+
156
+ <!-- Link -->
157
+ <a
158
+ v-else-if="preset === 'link' && value"
159
+ :href="value"
160
+ target="_blank"
161
+ rel="noopener noreferrer"
162
+ class="text-primary-600 dark:text-primary-400 hover:underline"
163
+ >
164
+ {{ displayValue }}
165
+ </a>
166
+
167
+ <!-- Badge -->
168
+ <UBadge
169
+ v-else-if="preset === 'badge'"
170
+ :color="getBadgeColor(value)"
171
+ variant="subtle"
172
+ class="capitalize"
173
+ >
174
+ {{ displayValue }}
175
+ </UBadge>
176
+
177
+ <!-- Boolean -->
178
+ <UIcon
179
+ v-else-if="preset === 'boolean'"
180
+ :name="value ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
181
+ :class="value ? 'text-success-500 w-5 h-5' : 'text-neutral-400 w-5 h-5'"
182
+ />
183
+
184
+ <!-- Avatar -->
185
+ <div v-else-if="preset === 'avatar'" class="flex items-center gap-2">
186
+ <UAvatar
187
+ :src="avatarUrl"
188
+ :alt="displayValue"
189
+ :size="column?.meta?.size || 'xs'"
190
+ />
191
+ <span v-if="column?.meta?.showName !== false">{{ displayValue }}</span>
192
+ </div>
193
+
194
+ <!-- Default: text, date, currency, number -->
195
+ <span v-else :class="preset === 'number' || preset === 'currency' ? 'tabular-nums' : ''">
196
+ {{ displayValue }}
197
+ </span>
198
+ </template>
@@ -0,0 +1,131 @@
1
+ <template>
2
+ <UPopover>
3
+ <UButton
4
+ icon="i-lucide-columns-3"
5
+ color="neutral"
6
+ variant="outline"
7
+ size="sm"
8
+ />
9
+
10
+ <template #content>
11
+ <div class="p-3 w-56">
12
+ <header class="flex items-center justify-between mb-3 pb-2 border-b border-neutral-200 dark:border-neutral-700">
13
+ <span class="text-sm font-medium">Columns</span>
14
+ <div class="flex gap-1">
15
+ <UButton
16
+ label="All"
17
+ size="xs"
18
+ variant="ghost"
19
+ color="neutral"
20
+ @click="showAll"
21
+ />
22
+ <UButton
23
+ label="Reset"
24
+ size="xs"
25
+ variant="ghost"
26
+ color="neutral"
27
+ @click="reset"
28
+ />
29
+ </div>
30
+ </header>
31
+
32
+ <ul class="space-y-1">
33
+ <li v-for="col in columns" :key="getColumnId(col)">
34
+ <label class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer">
35
+ <UCheckbox
36
+ :model-value="isVisible(col)"
37
+ @update:model-value="toggleColumn(col, $event)"
38
+ />
39
+ <span class="text-sm">{{ getColumnLabel(col) }}</span>
40
+ </label>
41
+ </li>
42
+ </ul>
43
+
44
+ <footer v-if="hiddenCount > 0" class="mt-3 pt-2 border-t border-neutral-200 dark:border-neutral-700">
45
+ <p class="text-xs text-neutral-500">
46
+ {{ hiddenCount }} column{{ hiddenCount === 1 ? '' : 's' }} hidden
47
+ </p>
48
+ </footer>
49
+ </div>
50
+ </template>
51
+ </UPopover>
52
+ </template>
53
+
54
+ <script setup>
55
+ defineOptions({
56
+ name: 'VATableColumnToggle'
57
+ })
58
+
59
+ const props = defineProps({
60
+ columns: {
61
+ type: Array,
62
+ required: true
63
+ },
64
+ modelValue: {
65
+ type: Array,
66
+ default: () => []
67
+ }
68
+ })
69
+
70
+ const emit = defineEmits(['update:modelValue'])
71
+
72
+ // Get column ID
73
+ function getColumnId(col) {
74
+ return col.id || col.accessorKey || col.key
75
+ }
76
+
77
+ // Get column label
78
+ function getColumnLabel(col) {
79
+ return col.header || col.label || getColumnId(col)
80
+ }
81
+
82
+ // Check if column is visible
83
+ function isVisible(col) {
84
+ const id = getColumnId(col)
85
+ // If no model value set, all columns are visible by default
86
+ if (!props.modelValue?.length) return true
87
+ return props.modelValue.includes(id)
88
+ }
89
+
90
+ // Toggle column visibility
91
+ function toggleColumn(col, visible) {
92
+ const id = getColumnId(col)
93
+ let newValue
94
+
95
+ if (!props.modelValue?.length) {
96
+ // First toggle - start with all columns except this one
97
+ if (!visible) {
98
+ newValue = props.columns
99
+ .map(c => getColumnId(c))
100
+ .filter(cid => cid !== id)
101
+ } else {
102
+ return // Already all visible
103
+ }
104
+ } else {
105
+ if (visible) {
106
+ newValue = [...props.modelValue, id]
107
+ } else {
108
+ newValue = props.modelValue.filter(cid => cid !== id)
109
+ }
110
+ }
111
+
112
+ emit('update:modelValue', newValue)
113
+ }
114
+
115
+ // Show all columns
116
+ function showAll() {
117
+ const allIds = props.columns.map(c => getColumnId(c))
118
+ emit('update:modelValue', allIds)
119
+ }
120
+
121
+ // Reset to default (all visible)
122
+ function reset() {
123
+ emit('update:modelValue', [])
124
+ }
125
+
126
+ // Count hidden columns
127
+ const hiddenCount = computed(() => {
128
+ if (!props.modelValue?.length) return 0
129
+ return props.columns.length - props.modelValue.length
130
+ })
131
+ </script>
@@ -0,0 +1,176 @@
1
+ <template>
2
+ <div
3
+ class="group relative"
4
+ :class="{ 'cursor-pointer': editable && !isEditing }"
5
+ @dblclick="startEditing"
6
+ >
7
+ <!-- Display Mode -->
8
+ <template v-if="!isEditing">
9
+ <div class="flex items-center gap-1">
10
+ <slot>
11
+ <span :class="displayClass">{{ displayValue }}</span>
12
+ </slot>
13
+ <UIcon
14
+ v-if="editable"
15
+ name="i-lucide-pencil"
16
+ class="w-3 h-3 text-neutral-400 opacity-0 group-hover:opacity-100 transition-opacity"
17
+ />
18
+ </div>
19
+ </template>
20
+
21
+ <!-- Edit Mode -->
22
+ <template v-else>
23
+ <!-- Text Input -->
24
+ <UInput
25
+ v-if="type === 'text' || type === 'email' || type === 'number'"
26
+ ref="inputRef"
27
+ v-model="editValue"
28
+ :type="type"
29
+ size="xs"
30
+ class="w-full"
31
+ @keydown.enter="saveEdit"
32
+ @keydown.escape="cancelEdit"
33
+ @blur="onBlur"
34
+ />
35
+
36
+ <!-- Textarea -->
37
+ <UTextarea
38
+ v-else-if="type === 'textarea'"
39
+ ref="inputRef"
40
+ v-model="editValue"
41
+ rows="2"
42
+ size="xs"
43
+ class="w-full"
44
+ @keydown.escape="cancelEdit"
45
+ @blur="onBlur"
46
+ />
47
+
48
+ <!-- Select -->
49
+ <USelect
50
+ v-else-if="type === 'select'"
51
+ ref="inputRef"
52
+ v-model="editValue"
53
+ :items="options"
54
+ size="xs"
55
+ class="w-full"
56
+ @update:model-value="saveEdit"
57
+ @keydown.escape="cancelEdit"
58
+ />
59
+
60
+ <!-- Checkbox -->
61
+ <UCheckbox
62
+ v-else-if="type === 'boolean'"
63
+ v-model="editValue"
64
+ @update:model-value="saveEdit"
65
+ />
66
+ </template>
67
+ </div>
68
+ </template>
69
+
70
+ <script setup>
71
+ defineOptions({
72
+ name: 'VATableEditableCell'
73
+ })
74
+
75
+ const props = defineProps({
76
+ value: {
77
+ type: [String, Number, Boolean, Object, Array],
78
+ default: null
79
+ },
80
+ editable: {
81
+ type: Boolean,
82
+ default: true
83
+ },
84
+ type: {
85
+ type: String,
86
+ default: 'text',
87
+ validator: value => ['text', 'email', 'number', 'textarea', 'select', 'boolean'].includes(value)
88
+ },
89
+ options: {
90
+ type: Array,
91
+ default: () => []
92
+ },
93
+ displayClass: {
94
+ type: String,
95
+ default: ''
96
+ },
97
+ formatter: {
98
+ type: Function,
99
+ default: null
100
+ }
101
+ })
102
+
103
+ const emit = defineEmits(['save', 'cancel'])
104
+
105
+ const inputRef = ref(null)
106
+ const isEditing = ref(false)
107
+ const editValue = ref(props.value)
108
+
109
+ // Display value with optional formatter
110
+ const displayValue = computed(() => {
111
+ if (props.formatter) {
112
+ return props.formatter(props.value)
113
+ }
114
+ if (props.value === null || props.value === undefined) {
115
+ return '-'
116
+ }
117
+ if (props.type === 'boolean') {
118
+ return props.value ? 'Yes' : 'No'
119
+ }
120
+ return String(props.value)
121
+ })
122
+
123
+ // Start editing
124
+ function startEditing() {
125
+ if (!props.editable) return
126
+ editValue.value = props.value
127
+ isEditing.value = true
128
+
129
+ nextTick(() => {
130
+ if (inputRef.value && inputRef.value.focus) {
131
+ inputRef.value.focus()
132
+ }
133
+ })
134
+ }
135
+
136
+ // Save edit
137
+ function saveEdit() {
138
+ if (editValue.value !== props.value) {
139
+ emit('save', editValue.value, props.value)
140
+ }
141
+ isEditing.value = false
142
+ }
143
+
144
+ // Cancel edit
145
+ function cancelEdit() {
146
+ editValue.value = props.value
147
+ isEditing.value = false
148
+ emit('cancel')
149
+ }
150
+
151
+ // Handle blur - save on blur unless escape was pressed
152
+ function onBlur() {
153
+ setTimeout(() => {
154
+ if (isEditing.value) {
155
+ saveEdit()
156
+ }
157
+ }, 100)
158
+ }
159
+
160
+ // Watch for external value changes
161
+ watch(
162
+ () => props.value,
163
+ (newValue) => {
164
+ if (!isEditing.value) {
165
+ editValue.value = newValue
166
+ }
167
+ }
168
+ )
169
+
170
+ // Expose methods for parent components
171
+ defineExpose({
172
+ startEditing,
173
+ cancelEdit,
174
+ isEditing
175
+ })
176
+ </script>
@@ -0,0 +1,154 @@
1
+ <template>
2
+ <UDropdownMenu :items="exportMenuItems">
3
+ <UButton
4
+ :label="showLabel ? 'Export' : undefined"
5
+ icon="i-lucide-download"
6
+ color="neutral"
7
+ variant="outline"
8
+ size="sm"
9
+ :trailing-icon="showLabel ? 'i-lucide-chevron-down' : undefined"
10
+ />
11
+ </UDropdownMenu>
12
+ </template>
13
+
14
+ <script setup>
15
+ defineOptions({
16
+ name: 'VATableExport'
17
+ })
18
+
19
+ const props = defineProps({
20
+ data: {
21
+ type: Array,
22
+ required: true
23
+ },
24
+ columns: {
25
+ type: Array,
26
+ required: true
27
+ },
28
+ filename: {
29
+ type: String,
30
+ default: 'export'
31
+ },
32
+ showLabel: {
33
+ type: Boolean,
34
+ default: true
35
+ },
36
+ enableCsv: {
37
+ type: Boolean,
38
+ default: true
39
+ },
40
+ enableJson: {
41
+ type: Boolean,
42
+ default: false
43
+ }
44
+ })
45
+
46
+ const emit = defineEmits(['export'])
47
+
48
+ // Build menu items
49
+ const exportMenuItems = computed(() => {
50
+ const items = []
51
+
52
+ if (props.enableCsv) {
53
+ items.push({
54
+ label: 'Export as CSV',
55
+ icon: 'i-lucide-file-spreadsheet',
56
+ onSelect: () => exportCsv()
57
+ })
58
+ }
59
+
60
+ if (props.enableJson) {
61
+ items.push({
62
+ label: 'Export as JSON',
63
+ icon: 'i-lucide-file-json',
64
+ onSelect: () => exportJson()
65
+ })
66
+ }
67
+
68
+ return items
69
+ })
70
+
71
+ // Format a cell value for export
72
+ function formatValue(value, formatter) {
73
+ if (formatter) {
74
+ return formatter(value)
75
+ }
76
+
77
+ if (value === null || value === undefined) {
78
+ return ''
79
+ }
80
+
81
+ if (typeof value === 'object') {
82
+ return JSON.stringify(value)
83
+ }
84
+
85
+ return String(value)
86
+ }
87
+
88
+ // Get value from nested path (e.g., "user.name")
89
+ function getNestedValue(obj, path) {
90
+ if (typeof obj !== 'object' || obj === null) return undefined
91
+
92
+ const keys = path.split('.')
93
+ let current = obj
94
+
95
+ for (const key of keys) {
96
+ if (typeof current !== 'object' || current === null) return undefined
97
+ current = current[key]
98
+ }
99
+
100
+ return current
101
+ }
102
+
103
+ // Export to CSV
104
+ function exportCsv() {
105
+ // Build header row
106
+ const headers = props.columns.map(col => `"${col.label.replace(/"/g, '""')}"`)
107
+ const rows = [headers.join(',')]
108
+
109
+ // Build data rows
110
+ for (const item of props.data) {
111
+ const row = props.columns.map(col => {
112
+ const value = getNestedValue(item, col.key)
113
+ const formatted = formatValue(value, col.formatter)
114
+ // Escape quotes and wrap in quotes
115
+ return `"${formatted.replace(/"/g, '""')}"`
116
+ })
117
+ rows.push(row.join(','))
118
+ }
119
+
120
+ const csvContent = rows.join('\n')
121
+ downloadFile(csvContent, `${props.filename}.csv`, 'text/csv')
122
+ emit('export', 'csv', csvContent)
123
+ }
124
+
125
+ // Export to JSON
126
+ function exportJson() {
127
+ // Build clean export data
128
+ const exportData = props.data.map(item => {
129
+ const obj = {}
130
+ for (const col of props.columns) {
131
+ const value = getNestedValue(item, col.key)
132
+ obj[col.key] = col.formatter ? col.formatter(value) : value
133
+ }
134
+ return obj
135
+ })
136
+
137
+ const jsonContent = JSON.stringify(exportData, null, 2)
138
+ downloadFile(jsonContent, `${props.filename}.json`, 'application/json')
139
+ emit('export', 'json', jsonContent)
140
+ }
141
+
142
+ // Download file utility
143
+ function downloadFile(content, filename, mimeType) {
144
+ const blob = new Blob([content], { type: mimeType })
145
+ const url = URL.createObjectURL(blob)
146
+ const link = document.createElement('a')
147
+ link.href = url
148
+ link.download = filename
149
+ document.body.appendChild(link)
150
+ link.click()
151
+ document.body.removeChild(link)
152
+ URL.revokeObjectURL(url)
153
+ }
154
+ </script>