@kinetis/vector-ui 0.0.0-stage → 0.9.1
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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +18 -0
- package/README.md +72 -2
- package/app/app.config.ts +63 -0
- package/app/app.vue +11 -0
- package/app/components/Vx/Action/Dialog.vue +107 -0
- package/app/components/Vx/Cell/Boolean.vue +10 -0
- package/app/components/Vx/Cell/Color.vue +12 -0
- package/app/components/Vx/Cell/Count.vue +12 -0
- package/app/components/Vx/Cell/Date.vue +10 -0
- package/app/components/Vx/Cell/DateTime.vue +10 -0
- package/app/components/Vx/Cell/Email.vue +9 -0
- package/app/components/Vx/Cell/Enum.vue +14 -0
- package/app/components/Vx/Cell/File.vue +30 -0
- package/app/components/Vx/Cell/Image.vue +38 -0
- package/app/components/Vx/Cell/Json.vue +18 -0
- package/app/components/Vx/Cell/KeyValue.vue +25 -0
- package/app/components/Vx/Cell/Markdown.vue +18 -0
- package/app/components/Vx/Cell/Money.vue +16 -0
- package/app/components/Vx/Cell/Number.vue +16 -0
- package/app/components/Vx/Cell/Relation.vue +16 -0
- package/app/components/Vx/Cell/RichText.vue +41 -0
- package/app/components/Vx/Cell/Text.vue +13 -0
- package/app/components/Vx/Cell/Url.vue +11 -0
- package/app/components/Vx/Cell.vue +25 -0
- package/app/components/Vx/CommandPalette.vue +118 -0
- package/app/components/Vx/Confirm.vue +19 -0
- package/app/components/Vx/Dashboard.vue +27 -0
- package/app/components/Vx/FieldsForm.vue +46 -0
- package/app/components/Vx/Import/Dialog.vue +131 -0
- package/app/components/Vx/Inbox.vue +49 -0
- package/app/components/Vx/Input/Boolean.vue +10 -0
- package/app/components/Vx/Input/Currency.vue +25 -0
- package/app/components/Vx/Input/DateTime.vue +38 -0
- package/app/components/Vx/Input/Enum.vue +21 -0
- package/app/components/Vx/Input/KeyValue.vue +78 -0
- package/app/components/Vx/Input/Markdown.vue +120 -0
- package/app/components/Vx/Input/Money.vue +47 -0
- package/app/components/Vx/Input/Number.vue +24 -0
- package/app/components/Vx/Input/Relation.vue +127 -0
- package/app/components/Vx/Input/Repeater.vue +134 -0
- package/app/components/Vx/Input/RichText.vue +182 -0
- package/app/components/Vx/Input/Text.vue +21 -0
- package/app/components/Vx/Input/Textarea.vue +20 -0
- package/app/components/Vx/Input/Upload.vue +89 -0
- package/app/components/Vx/Layout.vue +111 -0
- package/app/components/Vx/List/BulkBar.vue +44 -0
- package/app/components/Vx/List/BulkEdit.vue +91 -0
- package/app/components/Vx/List/ColumnManager.vue +74 -0
- package/app/components/Vx/List/FilterBar.vue +224 -0
- package/app/components/Vx/List/ViewMenu.vue +115 -0
- package/app/components/Vx/Notifications.vue +9 -0
- package/app/components/Vx/Resource/Audit.vue +82 -0
- package/app/components/Vx/Resource/Form.vue +351 -0
- package/app/components/Vx/Resource/RelationPanel.vue +217 -0
- package/app/components/Vx/Resource/Show.vue +35 -0
- package/app/components/Vx/Resource/Table.vue +513 -0
- package/app/components/Vx/Widget/Card.vue +80 -0
- package/app/components/Vx/Widget/Partition.vue +21 -0
- package/app/components/Vx/Widget/Progress.vue +22 -0
- package/app/components/Vx/Widget/Stat.vue +22 -0
- package/app/components/Vx/Widget/Table.vue +31 -0
- package/app/components/Vx/Widget/Trend.vue +37 -0
- package/app/composables/useConfirm.ts +34 -0
- package/app/composables/useLeaveGuard.ts +21 -0
- package/app/composables/useVectorApi.ts +74 -0
- package/app/composables/useVectorAuthStore.ts +252 -0
- package/app/composables/useVectorBranding.ts +45 -0
- package/app/composables/useVectorDashboards.ts +15 -0
- package/app/composables/useVectorExtensions.ts +44 -0
- package/app/composables/useVectorFields.ts +68 -0
- package/app/composables/useVectorForm.ts +36 -0
- package/app/composables/useVectorIdle.ts +39 -0
- package/app/composables/useVectorImpersonation.ts +78 -0
- package/app/composables/useVectorInputs.ts +61 -0
- package/app/composables/useVectorJobs.ts +62 -0
- package/app/composables/useVectorLocale.ts +70 -0
- package/app/composables/useVectorNotifications.ts +24 -0
- package/app/composables/useVectorNotificationsInbox.ts +26 -0
- package/app/composables/useVectorPreferences.ts +30 -0
- package/app/composables/useVectorResource.ts +305 -0
- package/app/composables/useVectorRuntime.ts +42 -0
- package/app/composables/useVectorSchema.ts +56 -0
- package/app/composables/useVectorT.ts +9 -0
- package/app/composables/useVectorViews.ts +25 -0
- package/app/i18n/ar/actions.ts +11 -0
- package/app/i18n/ar/common.ts +23 -0
- package/app/i18n/ar/dashboard.ts +28 -0
- package/app/i18n/ar/fields.ts +77 -0
- package/app/i18n/ar/index.ts +12 -0
- package/app/i18n/ar/jobs.ts +36 -0
- package/app/i18n/ar/list.ts +160 -0
- package/app/i18n/ar/record.ts +62 -0
- package/app/i18n/ar/settings.ts +10 -0
- package/app/i18n/ar/shell.ts +54 -0
- package/app/i18n/de/actions.ts +11 -0
- package/app/i18n/de/common.ts +23 -0
- package/app/i18n/de/dashboard.ts +28 -0
- package/app/i18n/de/fields.ts +77 -0
- package/app/i18n/de/index.ts +12 -0
- package/app/i18n/de/jobs.ts +36 -0
- package/app/i18n/de/list.ts +160 -0
- package/app/i18n/de/record.ts +62 -0
- package/app/i18n/de/settings.ts +10 -0
- package/app/i18n/de/shell.ts +54 -0
- package/app/i18n/en/actions.ts +11 -0
- package/app/i18n/en/common.ts +23 -0
- package/app/i18n/en/dashboard.ts +28 -0
- package/app/i18n/en/fields.ts +77 -0
- package/app/i18n/en/index.ts +12 -0
- package/app/i18n/en/jobs.ts +36 -0
- package/app/i18n/en/list.ts +160 -0
- package/app/i18n/en/record.ts +62 -0
- package/app/i18n/en/settings.ts +10 -0
- package/app/i18n/en/shell.ts +54 -0
- package/app/i18n/es/actions.ts +11 -0
- package/app/i18n/es/common.ts +23 -0
- package/app/i18n/es/dashboard.ts +28 -0
- package/app/i18n/es/fields.ts +77 -0
- package/app/i18n/es/index.ts +12 -0
- package/app/i18n/es/jobs.ts +36 -0
- package/app/i18n/es/list.ts +160 -0
- package/app/i18n/es/record.ts +62 -0
- package/app/i18n/es/settings.ts +10 -0
- package/app/i18n/es/shell.ts +54 -0
- package/app/i18n/fr/actions.ts +11 -0
- package/app/i18n/fr/common.ts +23 -0
- package/app/i18n/fr/dashboard.ts +28 -0
- package/app/i18n/fr/fields.ts +77 -0
- package/app/i18n/fr/index.ts +12 -0
- package/app/i18n/fr/jobs.ts +36 -0
- package/app/i18n/fr/list.ts +160 -0
- package/app/i18n/fr/record.ts +62 -0
- package/app/i18n/fr/settings.ts +10 -0
- package/app/i18n/fr/shell.ts +54 -0
- package/app/i18n/he/actions.ts +11 -0
- package/app/i18n/he/common.ts +23 -0
- package/app/i18n/he/dashboard.ts +28 -0
- package/app/i18n/he/fields.ts +77 -0
- package/app/i18n/he/index.ts +12 -0
- package/app/i18n/he/jobs.ts +36 -0
- package/app/i18n/he/list.ts +160 -0
- package/app/i18n/he/record.ts +62 -0
- package/app/i18n/he/settings.ts +10 -0
- package/app/i18n/he/shell.ts +54 -0
- package/app/i18n/index.ts +111 -0
- package/app/i18n/it/actions.ts +11 -0
- package/app/i18n/it/common.ts +23 -0
- package/app/i18n/it/dashboard.ts +28 -0
- package/app/i18n/it/fields.ts +77 -0
- package/app/i18n/it/index.ts +12 -0
- package/app/i18n/it/jobs.ts +36 -0
- package/app/i18n/it/list.ts +160 -0
- package/app/i18n/it/record.ts +62 -0
- package/app/i18n/it/settings.ts +10 -0
- package/app/i18n/it/shell.ts +54 -0
- package/app/i18n/ja/actions.ts +11 -0
- package/app/i18n/ja/common.ts +23 -0
- package/app/i18n/ja/dashboard.ts +28 -0
- package/app/i18n/ja/fields.ts +77 -0
- package/app/i18n/ja/index.ts +12 -0
- package/app/i18n/ja/jobs.ts +36 -0
- package/app/i18n/ja/list.ts +160 -0
- package/app/i18n/ja/record.ts +62 -0
- package/app/i18n/ja/settings.ts +10 -0
- package/app/i18n/ja/shell.ts +54 -0
- package/app/i18n/ko/actions.ts +11 -0
- package/app/i18n/ko/common.ts +23 -0
- package/app/i18n/ko/dashboard.ts +28 -0
- package/app/i18n/ko/fields.ts +77 -0
- package/app/i18n/ko/index.ts +12 -0
- package/app/i18n/ko/jobs.ts +36 -0
- package/app/i18n/ko/list.ts +160 -0
- package/app/i18n/ko/record.ts +62 -0
- package/app/i18n/ko/settings.ts +10 -0
- package/app/i18n/ko/shell.ts +54 -0
- package/app/i18n/nl/actions.ts +11 -0
- package/app/i18n/nl/common.ts +23 -0
- package/app/i18n/nl/dashboard.ts +28 -0
- package/app/i18n/nl/fields.ts +77 -0
- package/app/i18n/nl/index.ts +12 -0
- package/app/i18n/nl/jobs.ts +36 -0
- package/app/i18n/nl/list.ts +160 -0
- package/app/i18n/nl/record.ts +62 -0
- package/app/i18n/nl/settings.ts +10 -0
- package/app/i18n/nl/shell.ts +54 -0
- package/app/i18n/pl/actions.ts +11 -0
- package/app/i18n/pl/common.ts +23 -0
- package/app/i18n/pl/dashboard.ts +28 -0
- package/app/i18n/pl/fields.ts +77 -0
- package/app/i18n/pl/index.ts +12 -0
- package/app/i18n/pl/jobs.ts +36 -0
- package/app/i18n/pl/list.ts +160 -0
- package/app/i18n/pl/record.ts +62 -0
- package/app/i18n/pl/settings.ts +10 -0
- package/app/i18n/pl/shell.ts +54 -0
- package/app/i18n/pt/actions.ts +11 -0
- package/app/i18n/pt/common.ts +23 -0
- package/app/i18n/pt/dashboard.ts +28 -0
- package/app/i18n/pt/fields.ts +77 -0
- package/app/i18n/pt/index.ts +12 -0
- package/app/i18n/pt/jobs.ts +36 -0
- package/app/i18n/pt/list.ts +160 -0
- package/app/i18n/pt/record.ts +62 -0
- package/app/i18n/pt/settings.ts +10 -0
- package/app/i18n/pt/shell.ts +54 -0
- package/app/i18n/pt-BR/actions.ts +11 -0
- package/app/i18n/pt-BR/common.ts +23 -0
- package/app/i18n/pt-BR/dashboard.ts +28 -0
- package/app/i18n/pt-BR/fields.ts +77 -0
- package/app/i18n/pt-BR/index.ts +12 -0
- package/app/i18n/pt-BR/jobs.ts +36 -0
- package/app/i18n/pt-BR/list.ts +160 -0
- package/app/i18n/pt-BR/record.ts +62 -0
- package/app/i18n/pt-BR/settings.ts +10 -0
- package/app/i18n/pt-BR/shell.ts +54 -0
- package/app/i18n/ru/actions.ts +11 -0
- package/app/i18n/ru/common.ts +23 -0
- package/app/i18n/ru/dashboard.ts +28 -0
- package/app/i18n/ru/fields.ts +77 -0
- package/app/i18n/ru/index.ts +12 -0
- package/app/i18n/ru/jobs.ts +36 -0
- package/app/i18n/ru/list.ts +160 -0
- package/app/i18n/ru/record.ts +62 -0
- package/app/i18n/ru/settings.ts +10 -0
- package/app/i18n/ru/shell.ts +54 -0
- package/app/i18n/tr/actions.ts +11 -0
- package/app/i18n/tr/common.ts +23 -0
- package/app/i18n/tr/dashboard.ts +28 -0
- package/app/i18n/tr/fields.ts +77 -0
- package/app/i18n/tr/index.ts +12 -0
- package/app/i18n/tr/jobs.ts +36 -0
- package/app/i18n/tr/list.ts +160 -0
- package/app/i18n/tr/record.ts +62 -0
- package/app/i18n/tr/settings.ts +10 -0
- package/app/i18n/tr/shell.ts +54 -0
- package/app/i18n/uk/actions.ts +11 -0
- package/app/i18n/uk/common.ts +23 -0
- package/app/i18n/uk/dashboard.ts +28 -0
- package/app/i18n/uk/fields.ts +77 -0
- package/app/i18n/uk/index.ts +12 -0
- package/app/i18n/uk/jobs.ts +36 -0
- package/app/i18n/uk/list.ts +160 -0
- package/app/i18n/uk/record.ts +62 -0
- package/app/i18n/uk/settings.ts +10 -0
- package/app/i18n/uk/shell.ts +54 -0
- package/app/i18n/zh-Hans/actions.ts +11 -0
- package/app/i18n/zh-Hans/common.ts +23 -0
- package/app/i18n/zh-Hans/dashboard.ts +28 -0
- package/app/i18n/zh-Hans/fields.ts +77 -0
- package/app/i18n/zh-Hans/index.ts +12 -0
- package/app/i18n/zh-Hans/jobs.ts +36 -0
- package/app/i18n/zh-Hans/list.ts +160 -0
- package/app/i18n/zh-Hans/record.ts +62 -0
- package/app/i18n/zh-Hans/settings.ts +10 -0
- package/app/i18n/zh-Hans/shell.ts +54 -0
- package/app/layouts/default.vue +8 -0
- package/app/layouts/vector.vue +238 -0
- package/app/middleware/vector-auth.ts +16 -0
- package/app/pages/d/[name].vue +12 -0
- package/app/pages/index.vue +22 -0
- package/app/pages/jobs/[id].vue +52 -0
- package/app/pages/jobs/index.vue +42 -0
- package/app/pages/login.vue +109 -0
- package/app/pages/p/[name].vue +37 -0
- package/app/pages/r/[resource]/[id]/edit.vue +54 -0
- package/app/pages/r/[resource]/[id]/index.vue +143 -0
- package/app/pages/r/[resource]/create.vue +53 -0
- package/app/pages/r/[resource]/index.vue +29 -0
- package/app/pages/settings.vue +104 -0
- package/app/plugins/vector-csp.client.ts +14 -0
- package/app/plugins/vector-extensions.client.ts +84 -0
- package/app/plugins/vector-i18n.ts +76 -0
- package/app/types/vector.ts +438 -0
- package/app/utils/files.ts +14 -0
- package/app/utils/format.ts +100 -0
- package/app/utils/richtext.ts +305 -0
- package/app/utils/rules.ts +113 -0
- package/app/utils/translate.ts +45 -0
- package/app/utils/widgets.ts +11 -0
- package/nuxt.config.ts +83 -0
- package/package.json +83 -5
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ data: VectorWidgetData }>()
|
|
5
|
+
const { t } = useVectorT()
|
|
6
|
+
const delta = computed(() => {
|
|
7
|
+
const { value, previous } = props.data
|
|
8
|
+
if (value === null || value === undefined || previous === null || previous === undefined || previous === 0) return null
|
|
9
|
+
return ((value - previous) / Math.abs(previous)) * 100
|
|
10
|
+
})
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div>
|
|
15
|
+
<div class="text-h4 font-weight-medium">{{ formatWidgetValue(data.value, data.format) }}</div>
|
|
16
|
+
<div class="d-flex align-center ga-2 mt-1">
|
|
17
|
+
<v-chip v-if="delta !== null" size="x-small" :color="delta >= 0 ? 'success' : 'error'" variant="tonal" :prepend-icon="delta >= 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'">{{ t('dashboard.percent', { value: Math.abs(delta).toFixed(1) }) }}</v-chip>
|
|
18
|
+
<span v-if="data.previous !== null && data.previous !== undefined" class="text-caption text-medium-emphasis">{{ t('dashboard.stat.previous', { value: formatWidgetValue(data.previous, data.format) }) }}</span>
|
|
19
|
+
</div>
|
|
20
|
+
<div v-if="data.caption" class="text-caption text-medium-emphasis mt-1">{{ data.caption }}</div>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ data: VectorWidgetData }>()
|
|
5
|
+
const appConfig = useAppConfig()
|
|
6
|
+
const { t } = useVectorT()
|
|
7
|
+
const rows = computed(() => props.data.rows ?? [])
|
|
8
|
+
|
|
9
|
+
function open(row: Record<string, unknown>) {
|
|
10
|
+
if (typeof row._link === 'string') navigateTo(`${appConfig.vector.basePath}${row._link}`)
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div>
|
|
16
|
+
<v-table density="compact" hover>
|
|
17
|
+
<thead>
|
|
18
|
+
<tr><th v-for="col in data.columns ?? []" :key="col.key" :class="col.align === 'end' ? 'text-end' : ''">{{ col.label }}</th></tr>
|
|
19
|
+
</thead>
|
|
20
|
+
<tbody>
|
|
21
|
+
<tr v-for="(row, i) in rows" :key="i" :style="typeof row._link === 'string' ? 'cursor: pointer' : ''" @click="open(row)">
|
|
22
|
+
<td v-for="col in data.columns ?? []" :key="col.key" :class="col.align === 'end' ? 'text-end' : ''">{{ row[col.key] }}</td>
|
|
23
|
+
</tr>
|
|
24
|
+
<tr v-if="rows.length === 0"><td :colspan="(data.columns ?? []).length" class="text-medium-emphasis">{{ t('dashboard.table.empty') }}</td></tr>
|
|
25
|
+
</tbody>
|
|
26
|
+
</v-table>
|
|
27
|
+
<div v-if="data.link" class="text-end mt-2">
|
|
28
|
+
<v-btn size="small" variant="text" :to="`${appConfig.vector.basePath}${data.link}`" append-icon="mdi-arrow-right">{{ t('dashboard.table.seeAll') }}</v-btn>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A sparkline drawn as inline SVG: no chart library, CSP-clean.
|
|
5
|
+
const props = defineProps<{ data: VectorWidgetData }>()
|
|
6
|
+
const { t } = useVectorT()
|
|
7
|
+
const width = 600
|
|
8
|
+
const height = 140
|
|
9
|
+
const points = computed(() => props.data.points ?? [])
|
|
10
|
+
const max = computed(() => Math.max(1, ...points.value.map(p => p.y)))
|
|
11
|
+
const path = computed(() => {
|
|
12
|
+
const n = points.value.length
|
|
13
|
+
if (n === 0) return ''
|
|
14
|
+
const step = n > 1 ? width / (n - 1) : width
|
|
15
|
+
return points.value.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(height - (p.y / max.value) * (height - 10) - 5).toFixed(1)}`).join(' ')
|
|
16
|
+
})
|
|
17
|
+
const area = computed(() => path.value ? `${path.value} L${width},${height} L0,${height} Z` : '')
|
|
18
|
+
const hover = ref<number | null>(null)
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div>
|
|
23
|
+
<div class="d-flex justify-space-between text-caption text-medium-emphasis mb-1">
|
|
24
|
+
<span>{{ points[0]?.x ?? '' }}</span>
|
|
25
|
+
<span v-if="data.total !== null && data.total !== undefined" class="font-weight-medium">{{ t('dashboard.trend.total', { value: formatWidgetValue(data.total, data.format) }) }}</span>
|
|
26
|
+
<span>{{ points[points.length - 1]?.x ?? '' }}</span>
|
|
27
|
+
</div>
|
|
28
|
+
<svg :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" class="w-100" style="height: 140px" @mouseleave="hover = null">
|
|
29
|
+
<path :d="area" fill="rgb(var(--v-theme-primary))" fill-opacity="0.12" />
|
|
30
|
+
<path :d="path" fill="none" stroke="rgb(var(--v-theme-primary))" stroke-width="2" vector-effect="non-scaling-stroke" />
|
|
31
|
+
<rect v-for="(p, i) in points" :key="p.x" :x="(i * (width / Math.max(1, points.length - 1))) - width / Math.max(1, points.length - 1) / 2" y="0" :width="width / Math.max(1, points.length - 1)" :height="height" fill="transparent" @mouseenter="hover = i" />
|
|
32
|
+
</svg>
|
|
33
|
+
<div class="text-caption text-center" style="min-height: 1.2em">
|
|
34
|
+
<template v-if="hover !== null && points[hover]">{{ points[hover]!.x }}: <strong>{{ formatWidgetValue(points[hover]!.y, data.format) }}</strong></template>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface ConfirmOptions {
|
|
2
|
+
title: string
|
|
3
|
+
text?: string
|
|
4
|
+
confirmText?: string
|
|
5
|
+
cancelText?: string
|
|
6
|
+
destructive?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PendingConfirm extends ConfirmOptions {
|
|
10
|
+
resolve: (confirmed: boolean) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A promise-based confirmation. Vuetify ships no programmatic dialog;
|
|
15
|
+
* VxConfirm, mounted once in the layout, renders whatever is pending.
|
|
16
|
+
*/
|
|
17
|
+
export function useConfirm() {
|
|
18
|
+
const pending = useState<PendingConfirm | null>('vector.confirm', () => null)
|
|
19
|
+
|
|
20
|
+
function confirm(options: ConfirmOptions): Promise<boolean> {
|
|
21
|
+
pending.value?.resolve(false) // whoever asked first is answered, not left waiting
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
pending.value = { ...options, resolve }
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settle(confirmed: boolean) {
|
|
29
|
+
pending.value?.resolve(confirmed)
|
|
30
|
+
pending.value = null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { pending, confirm, settle }
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asks before leaving a page while $dirty says it holds unsaved changes:
|
|
3
|
+
* a route change through the confirm dialog, a reload or a closed tab
|
|
4
|
+
* through the browser's own prompt.
|
|
5
|
+
*/
|
|
6
|
+
export function useLeaveGuard(dirty: () => boolean) {
|
|
7
|
+
const { confirm } = useConfirm()
|
|
8
|
+
const { t } = useVectorT()
|
|
9
|
+
|
|
10
|
+
const mayLeave = async () => !dirty() || await confirm({ title: t('record.leave.title'), text: t('record.leave.text'), confirmText: t('record.leave.discard'), destructive: true })
|
|
11
|
+
|
|
12
|
+
onBeforeRouteLeave(mayLeave)
|
|
13
|
+
onBeforeRouteUpdate((to, from) => to.path === from.path || mayLeave())
|
|
14
|
+
|
|
15
|
+
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
|
16
|
+
if (dirty()) event.preventDefault()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload))
|
|
20
|
+
onBeforeUnmount(() => window.removeEventListener('beforeunload', warnBeforeUnload))
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { $Fetch } from 'nitropack'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one HTTP client every Vector request goes through. Bearer mode
|
|
5
|
+
* attaches the in-memory token — renewed first when it has under two
|
|
6
|
+
* minutes left — and on a 401 renews it once and repeats the request,
|
|
7
|
+
* sending the user to the login page only when that fails too (a 401
|
|
8
|
+
* means the request did nothing, so repeating even a POST is safe);
|
|
9
|
+
* cookie mode (hosted) rides the application's session cookie,
|
|
10
|
+
* sends the CSRF token on every unsafe method, and hands a 401 to the
|
|
11
|
+
* application's own login URL.
|
|
12
|
+
*/
|
|
13
|
+
export function useVectorApi(): $Fetch {
|
|
14
|
+
const config = useRuntimeConfig()
|
|
15
|
+
const appConfig = useAppConfig()
|
|
16
|
+
const runtime = useVectorRuntime()
|
|
17
|
+
const auth = useVectorAuthStore()
|
|
18
|
+
const router = useRouter()
|
|
19
|
+
|
|
20
|
+
return $fetch.create({
|
|
21
|
+
baseURL: runtime.hosted ? (runtime.apiBase ?? '') : config.public.vector.apiBase,
|
|
22
|
+
credentials: runtime.hosted ? 'same-origin' : 'omit',
|
|
23
|
+
async onRequest({ options }) {
|
|
24
|
+
if (runtime.hosted) {
|
|
25
|
+
const method = String(options.method ?? 'GET').toUpperCase()
|
|
26
|
+
|
|
27
|
+
if (runtime.csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
|
28
|
+
options.headers.set('X-CSRF-Token', runtime.csrfToken)
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
const token = await auth.fresh()
|
|
32
|
+
if (token) options.headers.set('Authorization', `Bearer ${token}`)
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
async onResponseError({ response, options }) {
|
|
36
|
+
const once = options as typeof options & { vectorRenewed?: boolean }
|
|
37
|
+
|
|
38
|
+
if (response.status === 401 && !runtime.hosted && !once.vectorRenewed && auth.canRefresh && await auth.refresh()) {
|
|
39
|
+
// repeated once, with the renewed token (onRequest sets it)
|
|
40
|
+
once.vectorRenewed = true
|
|
41
|
+
options.retry = 1
|
|
42
|
+
options.retryStatusCodes = [401]
|
|
43
|
+
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (response.status === 401) {
|
|
48
|
+
if (runtime.hosted && runtime.loginUrl && import.meta.client) {
|
|
49
|
+
window.location.assign(runtime.loginUrl)
|
|
50
|
+
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
auth.clear()
|
|
55
|
+
// back to this page after signing in again, as the route middleware would send a visitor
|
|
56
|
+
const current = router.currentRoute.value.fullPath
|
|
57
|
+
await navigateTo({ path: `${appConfig.vector.basePath || ''}/login`, query: current.startsWith(`${appConfig.vector.basePath || ''}/login`) ? {} : { redirect: current } })
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}) as $Fetch
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The message a failed request should show, from either Kinetis error shape. */
|
|
64
|
+
export function vectorErrorMessage(error: unknown): string {
|
|
65
|
+
const failure = error as { data?: { error?: string, detail?: string }, message?: string } | null
|
|
66
|
+
const data = failure?.data
|
|
67
|
+
|
|
68
|
+
if (data && typeof data === 'object') {
|
|
69
|
+
if (typeof data.error === 'string') return data.error
|
|
70
|
+
if (typeof data.detail === 'string') return data.detail
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return typeof failure?.message === 'string' ? failure.message : String(error)
|
|
74
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
export interface VectorUser {
|
|
4
|
+
id: number | string
|
|
5
|
+
name: string
|
|
6
|
+
email?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TokenResponse {
|
|
10
|
+
accessToken: string
|
|
11
|
+
tokenType: string
|
|
12
|
+
expiresIn: number
|
|
13
|
+
/** Present when the application issues refresh tokens (POST /auth/refresh renews both, once). */
|
|
14
|
+
refreshToken?: string
|
|
15
|
+
refreshExpiresIn?: number
|
|
16
|
+
user: VectorUser
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Saved {
|
|
20
|
+
token: string
|
|
21
|
+
expiresAt: number
|
|
22
|
+
user: VectorUser
|
|
23
|
+
refreshToken?: string | null
|
|
24
|
+
refreshExpiresAt?: number
|
|
25
|
+
refreshSubject?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STORAGE_KEY = 'vector.session'
|
|
29
|
+
/** An access token with less than this left is renewed before it is used. */
|
|
30
|
+
export const REFRESH_MARGIN_MS = 120_000
|
|
31
|
+
|
|
32
|
+
// The pending end (or renewal) of the session, and a renewal in flight. The
|
|
33
|
+
// UI runs in the browser only (ssr: false), one app per page, so one of
|
|
34
|
+
// each per module is one per session.
|
|
35
|
+
let expiryTimer: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
let refreshing: Promise<boolean> | null = null
|
|
37
|
+
// setTimeout's longest delay; a later expiry is re-checked when it fires
|
|
38
|
+
const MAX_DELAY = 2 ** 31 - 1
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bearer-mode session. The access token lives in memory for the tab and
|
|
42
|
+
* is mirrored to sessionStorage — never localStorage — beside the refresh
|
|
43
|
+
* token when the application issues one (`/auth/login` answering
|
|
44
|
+
* `refreshToken`). The session renews itself through `POST /auth/refresh`
|
|
45
|
+
* when the access token has under two minutes left, and once more on a
|
|
46
|
+
* 401; a failed renewal signs the user out. Logout revokes the refresh
|
|
47
|
+
* token (`POST /auth/logout`). An impersonation token is never renewed:
|
|
48
|
+
* the refresh token is the signed-in user's own, used only while the
|
|
49
|
+
* session is theirs. Hosted mode rides the application's session instead.
|
|
50
|
+
*/
|
|
51
|
+
export const useVectorAuthStore = defineStore('vectorAuth', {
|
|
52
|
+
state: () => ({
|
|
53
|
+
token: null as string | null,
|
|
54
|
+
expiresAt: 0,
|
|
55
|
+
user: null as VectorUser | null,
|
|
56
|
+
refreshToken: null as string | null,
|
|
57
|
+
refreshExpiresAt: 0,
|
|
58
|
+
/** Whose refresh token it is: the user who signed in. */
|
|
59
|
+
refreshSubject: null as string | null,
|
|
60
|
+
restored: false,
|
|
61
|
+
hosted: false,
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
getters: {
|
|
65
|
+
/** Whether the session can renew itself: a live refresh token, and the session is its owner's. */
|
|
66
|
+
canRefresh: state => state.refreshToken !== null
|
|
67
|
+
&& state.refreshExpiresAt > Date.now()
|
|
68
|
+
&& state.user !== null
|
|
69
|
+
&& String(state.user.id) === state.refreshSubject,
|
|
70
|
+
// A getter is cached until the state changes, and the clock is not
|
|
71
|
+
// state: the session's own timer (expireAt) renews or clears the
|
|
72
|
+
// token when it runs out, which is the change that updates this.
|
|
73
|
+
isAuthenticated(state): boolean {
|
|
74
|
+
return state.hosted || (state.token !== null && state.expiresAt > Date.now()) || this.canRefresh
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
actions: {
|
|
79
|
+
restore() {
|
|
80
|
+
if (this.restored) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.restored = true
|
|
85
|
+
const runtime = useVectorRuntime()
|
|
86
|
+
|
|
87
|
+
if (runtime.hosted) {
|
|
88
|
+
this.hosted = true
|
|
89
|
+
this.user = runtime.user ? { id: runtime.user.id, name: runtime.user.name ?? vt('shell.account') } : null
|
|
90
|
+
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const raw = sessionStorage.getItem(STORAGE_KEY)
|
|
96
|
+
|
|
97
|
+
if (raw) {
|
|
98
|
+
const saved = JSON.parse(raw) as Saved
|
|
99
|
+
const refreshable = !!saved.refreshToken && (saved.refreshExpiresAt ?? 0) > Date.now()
|
|
100
|
+
|
|
101
|
+
if (saved.expiresAt > Date.now() || refreshable) {
|
|
102
|
+
this.token = saved.token
|
|
103
|
+
this.expiresAt = saved.expiresAt
|
|
104
|
+
this.user = saved.user
|
|
105
|
+
this.refreshToken = refreshable ? saved.refreshToken! : null
|
|
106
|
+
this.refreshExpiresAt = refreshable ? saved.refreshExpiresAt! : 0
|
|
107
|
+
this.refreshSubject = refreshable ? saved.refreshSubject ?? null : null
|
|
108
|
+
this.expireAt(saved.expiresAt)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Storage unavailable or unreadable: the user simply logs in again.
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async login(email: string, password: string) {
|
|
117
|
+
const config = useRuntimeConfig()
|
|
118
|
+
const response = await $fetch<TokenResponse>('/auth/login', {
|
|
119
|
+
baseURL: config.public.vector.apiBase,
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body: { email, password },
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
this.take(response)
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Renews both tokens, once at a time however many callers ask.
|
|
129
|
+
* False, and the session ended, when the refresh token is refused.
|
|
130
|
+
*/
|
|
131
|
+
refresh(): Promise<boolean> {
|
|
132
|
+
if (!this.canRefresh) return Promise.resolve(false)
|
|
133
|
+
|
|
134
|
+
refreshing ??= (async () => {
|
|
135
|
+
try {
|
|
136
|
+
const config = useRuntimeConfig()
|
|
137
|
+
const response = await $fetch<TokenResponse>('/auth/refresh', {
|
|
138
|
+
baseURL: config.public.vector.apiBase,
|
|
139
|
+
method: 'POST',
|
|
140
|
+
body: { refreshToken: this.refreshToken },
|
|
141
|
+
})
|
|
142
|
+
this.take(response)
|
|
143
|
+
|
|
144
|
+
return true
|
|
145
|
+
} catch {
|
|
146
|
+
this.clear()
|
|
147
|
+
|
|
148
|
+
return false
|
|
149
|
+
} finally {
|
|
150
|
+
refreshing = null
|
|
151
|
+
}
|
|
152
|
+
})()
|
|
153
|
+
|
|
154
|
+
return refreshing
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** A token fit to send: renewed first when it has under two minutes left and the session can renew. */
|
|
158
|
+
async fresh(): Promise<string | null> {
|
|
159
|
+
if (this.token !== null && this.expiresAt - Date.now() < REFRESH_MARGIN_MS && this.canRefresh) {
|
|
160
|
+
await this.refresh()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this.token
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/** Takes a token the server issued outside the login form: impersonation. It is not renewed. */
|
|
167
|
+
adopt(token: string, expiresIn: number, user: VectorUser) {
|
|
168
|
+
this.token = token
|
|
169
|
+
this.expiresAt = Date.now() + expiresIn * 1000
|
|
170
|
+
this.user = user
|
|
171
|
+
this.expireAt(this.expiresAt)
|
|
172
|
+
this.save()
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/** Signs out: the refresh token is revoked where the application issued it, then the session is forgotten. */
|
|
176
|
+
async logout() {
|
|
177
|
+
const refreshToken = this.refreshToken
|
|
178
|
+
|
|
179
|
+
if (refreshToken) {
|
|
180
|
+
const config = useRuntimeConfig()
|
|
181
|
+
await $fetch('/auth/logout', { baseURL: config.public.vector.apiBase, method: 'POST', body: { refreshToken } }).catch(() => undefined)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.clear()
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/** Renews the session shortly before its token runs out, or ends it when it cannot. */
|
|
188
|
+
expireAt(expiresAt: number) {
|
|
189
|
+
if (expiryTimer !== null) clearTimeout(expiryTimer)
|
|
190
|
+
|
|
191
|
+
const due = this.canRefresh ? expiresAt - REFRESH_MARGIN_MS : expiresAt
|
|
192
|
+
|
|
193
|
+
expiryTimer = setTimeout(() => {
|
|
194
|
+
expiryTimer = null
|
|
195
|
+
|
|
196
|
+
if (this.expiresAt !== expiresAt) return // a newer token has its own timer
|
|
197
|
+
if (Date.now() < due) this.expireAt(expiresAt) // past setTimeout's range: wait again
|
|
198
|
+
else if (this.canRefresh) void this.refresh()
|
|
199
|
+
else this.clear()
|
|
200
|
+
}, Math.min(MAX_DELAY, Math.max(0, due - Date.now())))
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
clear() {
|
|
204
|
+
if (expiryTimer !== null) {
|
|
205
|
+
clearTimeout(expiryTimer)
|
|
206
|
+
expiryTimer = null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.token = null
|
|
210
|
+
this.expiresAt = 0
|
|
211
|
+
this.user = null
|
|
212
|
+
this.refreshToken = null
|
|
213
|
+
this.refreshExpiresAt = 0
|
|
214
|
+
this.refreshSubject = null
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
sessionStorage.removeItem(STORAGE_KEY)
|
|
218
|
+
} catch {
|
|
219
|
+
// Nothing to remove.
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/** A login or refresh answer: the tokens, the user, and the timer that renews them. */
|
|
224
|
+
take(response: TokenResponse) {
|
|
225
|
+
this.token = response.accessToken
|
|
226
|
+
this.expiresAt = Date.now() + response.expiresIn * 1000
|
|
227
|
+
this.user = response.user
|
|
228
|
+
|
|
229
|
+
if (response.refreshToken) {
|
|
230
|
+
this.refreshToken = response.refreshToken
|
|
231
|
+
this.refreshExpiresAt = Date.now() + (response.refreshExpiresIn ?? 0) * 1000
|
|
232
|
+
this.refreshSubject = String(response.user.id)
|
|
233
|
+
} else {
|
|
234
|
+
this.refreshToken = null
|
|
235
|
+
this.refreshExpiresAt = 0
|
|
236
|
+
this.refreshSubject = null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
this.expireAt(this.expiresAt)
|
|
240
|
+
this.save()
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
save() {
|
|
244
|
+
try {
|
|
245
|
+
const saved: Saved = { token: this.token!, expiresAt: this.expiresAt, user: this.user!, refreshToken: this.refreshToken, refreshExpiresAt: this.refreshExpiresAt, refreshSubject: this.refreshSubject }
|
|
246
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(saved))
|
|
247
|
+
} catch {
|
|
248
|
+
// Fine without the mirror.
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useTheme } from 'vuetify'
|
|
2
|
+
import type { VectorBranding } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the shell shows about the application: the Branding settings
|
|
6
|
+
* group, published with the descriptor (and, hosted, injected before
|
|
7
|
+
* the first paint). Falls back to app.config while nothing loaded.
|
|
8
|
+
*/
|
|
9
|
+
export function useVectorBranding() {
|
|
10
|
+
const appConfig = useAppConfig()
|
|
11
|
+
const { schema } = useVectorSchema()
|
|
12
|
+
const theme = useTheme()
|
|
13
|
+
const applied = useState<string>('vector.branding.applied', () => '')
|
|
14
|
+
|
|
15
|
+
const hosted = import.meta.client ? window.__VECTOR__?.branding ?? null : null
|
|
16
|
+
|
|
17
|
+
const branding = computed<VectorBranding>(() => ({
|
|
18
|
+
title: appConfig.vector.brand.title,
|
|
19
|
+
logoUrl: null,
|
|
20
|
+
primaryColor: null,
|
|
21
|
+
primaryColorDark: null,
|
|
22
|
+
theme: null,
|
|
23
|
+
footerText: null,
|
|
24
|
+
...(schema.value?.branding ?? hosted ?? {}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
/** Pushes the colours into Vuetify's theme and picks the default theme once per session. */
|
|
28
|
+
function apply() {
|
|
29
|
+
const b = branding.value
|
|
30
|
+
const signature = JSON.stringify(b)
|
|
31
|
+
|
|
32
|
+
if (applied.value === signature) return
|
|
33
|
+
|
|
34
|
+
if (b.primaryColor) theme.themes.value.light!.colors.primary = b.primaryColor
|
|
35
|
+
if (b.primaryColorDark) theme.themes.value.dark!.colors.primary = b.primaryColorDark
|
|
36
|
+
|
|
37
|
+
if (applied.value === '' && b.theme && b.theme !== 'system') {
|
|
38
|
+
theme.change(b.theme)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
applied.value = signature
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { branding, apply }
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { VectorDashboard, VectorWidgetData } from '#vector/app/types/vector'
|
|
2
|
+
|
|
3
|
+
export function useVectorDashboards() {
|
|
4
|
+
const api = useVectorApi()
|
|
5
|
+
|
|
6
|
+
async function show(name: string): Promise<VectorDashboard> {
|
|
7
|
+
return (await api<{ data: VectorDashboard }>(`/vector/dashboards/${encodeURIComponent(name)}`)).data
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function widget(dashboard: string, name: string, range?: string): Promise<VectorWidgetData> {
|
|
11
|
+
return (await api<{ data: VectorWidgetData }>(`/vector/dashboards/${encodeURIComponent(dashboard)}/widgets/${encodeURIComponent(name)}`, { query: range ? { range } : {} })).data
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return { show, widget }
|
|
15
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { AnyExtension } from '@tiptap/core'
|
|
3
|
+
|
|
4
|
+
/** A custom page: a menu entry and a route, `/p/{name}`, rendering the component. */
|
|
5
|
+
export interface VectorPageDefinition {
|
|
6
|
+
title: string
|
|
7
|
+
icon?: string
|
|
8
|
+
/** The menu section it appears under; the "Pages" section when omitted. */
|
|
9
|
+
group?: string
|
|
10
|
+
component: Component | string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The registries runtime extensions fill through `window.Vector`:
|
|
15
|
+
* cells, inputs, widget renderers and custom pages by name. Reactive, so
|
|
16
|
+
* something registered after boot shows up without a reload. `ready`
|
|
17
|
+
* turns true once every extension module has loaded (or failed to).
|
|
18
|
+
*/
|
|
19
|
+
export function useVectorExtensions() {
|
|
20
|
+
const cells = useState<Map<string, Component>>('vector.ext.cells', () => shallowReactive(new Map()))
|
|
21
|
+
const inputs = useState<Map<string, Component>>('vector.ext.inputs', () => shallowReactive(new Map()))
|
|
22
|
+
const widgets = useState<Map<string, Component>>('vector.ext.widgets', () => shallowReactive(new Map()))
|
|
23
|
+
const pages = useState<Map<string, VectorPageDefinition>>('vector.ext.pages', () => shallowReactive(new Map()))
|
|
24
|
+
// rich-text node and mark types, by the feature name a field's `features` uses
|
|
25
|
+
const richText = useState<Map<string, AnyExtension>>('vector.ext.richText', () => shallowReactive(new Map()))
|
|
26
|
+
const ready = useState<boolean>('vector.ext.ready', () => false)
|
|
27
|
+
|
|
28
|
+
return { cells: cells.value, inputs: inputs.value, widgets: widgets.value, pages: pages.value, richText: richText.value, ready }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every custom page: the application's own from app config
|
|
33
|
+
* `vector.pages` (a globally registered component by name) and the ones
|
|
34
|
+
* runtime extensions defined.
|
|
35
|
+
*/
|
|
36
|
+
export function useVectorPages() {
|
|
37
|
+
const appConfig = useAppConfig()
|
|
38
|
+
const { pages } = useVectorExtensions()
|
|
39
|
+
|
|
40
|
+
return computed<Record<string, VectorPageDefinition>>(() => ({
|
|
41
|
+
...(appConfig.vector.pages ?? {}),
|
|
42
|
+
...Object.fromEntries(pages.entries()),
|
|
43
|
+
}))
|
|
44
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The field registry: a descriptor field resolves to the component that
|
|
6
|
+
* renders it as a table cell or a show-page value.
|
|
7
|
+
*
|
|
8
|
+
* Built-in cells are imported explicitly — a dynamic `<component :is>`
|
|
9
|
+
* defeats template-time auto-import, so nothing here relies on it. A
|
|
10
|
+
* consuming app adds or replaces entries through `app.config.vector.fields`
|
|
11
|
+
* (keyed by a field's explicit `component` name or by its `type`), naming
|
|
12
|
+
* a component it registered globally in a plugin.
|
|
13
|
+
*/
|
|
14
|
+
const cells: Record<string, Component> = {
|
|
15
|
+
id: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
|
|
16
|
+
string: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
|
|
17
|
+
text: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
|
|
18
|
+
markdown: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Markdown.vue')),
|
|
19
|
+
richtext: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/RichText.vue')),
|
|
20
|
+
number: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Number.vue')),
|
|
21
|
+
money: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Money.vue')),
|
|
22
|
+
boolean: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Boolean.vue')),
|
|
23
|
+
enum: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Enum.vue')),
|
|
24
|
+
date: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Date.vue')),
|
|
25
|
+
datetime: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/DateTime.vue')),
|
|
26
|
+
email: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Email.vue')),
|
|
27
|
+
url: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Url.vue')),
|
|
28
|
+
password: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
|
|
29
|
+
slug: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Text.vue')),
|
|
30
|
+
color: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Color.vue')),
|
|
31
|
+
json: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Json.vue')),
|
|
32
|
+
keyValue: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/KeyValue.vue')),
|
|
33
|
+
file: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/File.vue')),
|
|
34
|
+
image: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Image.vue')),
|
|
35
|
+
belongsTo: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Relation.vue')),
|
|
36
|
+
hasOne: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Relation.vue')),
|
|
37
|
+
hasMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Count.vue')),
|
|
38
|
+
manyToMany: defineAsyncComponent(() => import('#vector/app/components/Vx/Cell/Count.vue')),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useVectorFields() {
|
|
42
|
+
const appConfig = useAppConfig()
|
|
43
|
+
|
|
44
|
+
/** The cell for a field: an app override by name, then the built-in for its type. */
|
|
45
|
+
function cellFor(field: VectorField): Component | string {
|
|
46
|
+
const overrides = appConfig.vector.fields
|
|
47
|
+
const runtime = useVectorExtensions().cells
|
|
48
|
+
|
|
49
|
+
if (field.component && overrides[field.component]) {
|
|
50
|
+
return overrides[field.component]!
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (field.component && runtime.has(field.component)) {
|
|
54
|
+
return runtime.get(field.component)!
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (overrides[field.type]) {
|
|
58
|
+
return overrides[field.type]!
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// a computed value is shown by the cell of what it is computed as
|
|
62
|
+
const type = field.type === 'computed' ? (field.computed?.as === 'text' ? 'string' : field.computed?.as ?? 'string') : field.type
|
|
63
|
+
|
|
64
|
+
return cells[type] ?? cells.string!
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { cellFor }
|
|
68
|
+
}
|