@falcondev-oss/nuxt-layers-base 0.37.2 → 0.38.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/app/assets/css/base.css +5 -1
- package/app/assets/css/theme.css +1 -0
- package/app/assets/css/variants.css +3 -0
- package/app/components/ForwardSlots.tsx +30 -0
- package/app/components/layout/LayoutNavbar.vue +93 -30
- package/app/components/layout/LayoutSidebar.vue +17 -10
- package/app/components/u/URibbonCard.vue +43 -0
- package/app/components/u/URibbonSection.vue +29 -0
- package/app/composables/useStableColumnWidths.ts +41 -0
- package/app/composables/useToolbar.ts +42 -0
- package/app/utils/plugins/helper/request-error.ts +82 -0
- package/app/utils/plugins/index.ts +3 -0
- package/app/utils/plugins/trpc.ts +120 -0
- package/app/utils/plugins/vue-query.ts +131 -0
- package/nuxt.config.ts +9 -0
- package/package.json +1 -1
- package/app/components/u/UTableCard.vue +0 -22
- package/app/pages/index.vue +0 -3
- package/app/utils/define-setup-component-old.ts +0 -55
- package/app/utils/plugins.ts +0 -295
package/app/assets/css/base.css
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
body {
|
|
16
|
-
background: var(--ui-color-neutral-50);
|
|
16
|
+
background: color-mix(in oklch, var(--ui-color-neutral-50) 50%, white);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
button:not(:disabled),
|
|
@@ -28,4 +28,8 @@
|
|
|
28
28
|
fieldset > label {
|
|
29
29
|
cursor: pointer;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
.iconify {
|
|
33
|
+
flex-shrink: 0;
|
|
34
|
+
}
|
|
31
35
|
}
|
package/app/assets/css/theme.css
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--color-brand-secondary-900: color-mix(in oklch, var(--color-brand-secondary), black 55%);
|
|
24
24
|
--color-brand-secondary-950: color-mix(in oklch, var(--color-brand-secondary), black 64%);
|
|
25
25
|
|
|
26
|
+
/* force usage of Nuxt UI gray classes */
|
|
26
27
|
--color-slate-*: initial;
|
|
27
28
|
--color-gray-*: initial;
|
|
28
29
|
--color-zinc-*: initial;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Component, Slot, VNode } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passes a set of slot functions on to the component in the default slot.
|
|
5
|
+
*
|
|
6
|
+
* Slots can only be declared where a component is written, so forwarding a dynamic set of them has
|
|
7
|
+
* to happen from the outside: the target's vnode is re-created with `slots` merged into the slots
|
|
8
|
+
* written inline. Inline slots win on conflict.
|
|
9
|
+
*/
|
|
10
|
+
export default defineSetupComponent(
|
|
11
|
+
(_: {
|
|
12
|
+
props: {
|
|
13
|
+
slots: Record<string, Slot | undefined>
|
|
14
|
+
}
|
|
15
|
+
slots: { default: () => VNode[] }
|
|
16
|
+
}) =>
|
|
17
|
+
options(_, {
|
|
18
|
+
props: ['slots'],
|
|
19
|
+
emits: [],
|
|
20
|
+
setup: (props, ctx) => () => {
|
|
21
|
+
const [target] = ctx.slots.default?.() ?? []
|
|
22
|
+
if (!target) return null
|
|
23
|
+
|
|
24
|
+
// dropping `_` makes Vue re-normalize the merged slots instead of treating them as stable
|
|
25
|
+
const { _: _slotFlag, ...inlineSlots } = (target.children ?? {}) as Record<string, unknown>
|
|
26
|
+
|
|
27
|
+
return h(target.type as Component, target.props, { ...props.slots, ...inlineSlots })
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type {
|
|
3
|
+
BreadcrumbItem,
|
|
4
|
+
BreadcrumbProps,
|
|
3
5
|
DashboardNavbarProps,
|
|
4
6
|
DashboardNavbarSlots,
|
|
5
7
|
DashboardPanelProps,
|
|
@@ -7,67 +9,128 @@ import type {
|
|
|
7
9
|
NavigationMenuItem,
|
|
8
10
|
NavigationMenuProps,
|
|
9
11
|
} from '@nuxt/ui'
|
|
12
|
+
import type { MaybeRefOrGetter, Ref } from 'vue'
|
|
13
|
+
import type { ToolbarTool } from '../../composables/useToolbar'
|
|
10
14
|
import type { AddPropertyPrefix } from '../../types/helpers'
|
|
11
15
|
import * as R from 'remeda'
|
|
16
|
+
import { toolbarToolsKey } from '../../composables/useToolbar'
|
|
17
|
+
import { mergeSlotClass } from '../../utils/ui'
|
|
12
18
|
|
|
13
|
-
defineProps<{
|
|
19
|
+
const props = defineProps<{
|
|
14
20
|
panel?: DashboardPanelProps
|
|
15
21
|
navbar?: {
|
|
16
|
-
|
|
22
|
+
sidebarToggle?: boolean
|
|
17
23
|
title?: string
|
|
24
|
+
breadcrumb?: BreadcrumbItem[]
|
|
25
|
+
breadcrumbUi?: BreadcrumbProps<BreadcrumbItem>['ui']
|
|
18
26
|
ui?: DashboardNavbarProps['ui']
|
|
19
27
|
}
|
|
20
|
-
|
|
28
|
+
tabs?: {
|
|
21
29
|
items?: NavigationMenuItem[]
|
|
22
|
-
|
|
23
|
-
ui?: DashboardToolbarProps['ui']
|
|
24
|
-
itemsUi?: NavigationMenuProps['ui']
|
|
25
|
-
itemsEndUi?: NavigationMenuProps['ui']
|
|
30
|
+
ui?: NavigationMenuProps['ui']
|
|
26
31
|
}
|
|
32
|
+
tools?: {
|
|
33
|
+
items?: NavigationMenuItem[]
|
|
34
|
+
ui?: NavigationMenuProps['ui']
|
|
35
|
+
}
|
|
36
|
+
toolbarUi?: DashboardToolbarProps['ui']
|
|
27
37
|
}>()
|
|
28
38
|
|
|
29
39
|
const slots = defineSlots<
|
|
30
40
|
{
|
|
31
|
-
default: any
|
|
41
|
+
'default': any
|
|
42
|
+
'navbar-title': any
|
|
43
|
+
'navbar-trailing': any
|
|
44
|
+
'navbar-actions': any
|
|
32
45
|
} & AddPropertyPrefix<DashboardNavbarSlots, 'navbar'>
|
|
33
46
|
>()
|
|
34
47
|
|
|
35
|
-
|
|
48
|
+
// rendered manually in the `left` slot below
|
|
49
|
+
const omitNavbarSlots = [
|
|
50
|
+
'navbar-left',
|
|
51
|
+
'navbar-leading',
|
|
52
|
+
'navbar-title',
|
|
53
|
+
'navbar-trailing',
|
|
54
|
+
'navbar-right',
|
|
55
|
+
'navbar-actions',
|
|
56
|
+
] satisfies (keyof typeof slots)[]
|
|
36
57
|
const navbarSlots = computed(() =>
|
|
37
58
|
R.pipe(
|
|
38
59
|
slots,
|
|
39
60
|
R.pickBy((_, key) => key.startsWith('navbar-')),
|
|
40
61
|
R.omit(omitNavbarSlots),
|
|
41
|
-
R.
|
|
42
|
-
R.pullObject(
|
|
43
|
-
(key) => key.replace('navbar-', ''),
|
|
44
|
-
(key) => key,
|
|
45
|
-
),
|
|
62
|
+
R.mapKeys((key) => key.replace('navbar-', '')),
|
|
46
63
|
),
|
|
47
64
|
)
|
|
65
|
+
|
|
66
|
+
// added by the content rendered inside this navbar through `useToolbar()`
|
|
67
|
+
// `ref()`'s deep unwrapping blows up on the recursive `NavigationMenuItem` type
|
|
68
|
+
const providedTools = ref([]) as Ref<MaybeRefOrGetter<ToolbarTool>[]>
|
|
69
|
+
provide(toolbarToolsKey, providedTools)
|
|
70
|
+
|
|
71
|
+
const toolItems = computed(() => [
|
|
72
|
+
...(props.tools?.items ?? []),
|
|
73
|
+
...providedTools.value.map((tool) => toValue(tool)),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const navbarUi = computed<DashboardNavbarProps['ui']>(() => ({
|
|
77
|
+
...props.navbar?.ui,
|
|
78
|
+
toggle: mergeSlotClass(props.navbar?.ui?.toggle, '-ml-1'),
|
|
79
|
+
...(props.navbar?.breadcrumb && {
|
|
80
|
+
root: mergeSlotClass(props.navbar.ui?.root, 'h-auto min-h-(--ui-header-height) py-2'),
|
|
81
|
+
}),
|
|
82
|
+
}))
|
|
48
83
|
</script>
|
|
49
84
|
|
|
50
85
|
<template>
|
|
51
86
|
<UDashboardPanel v-bind="panel">
|
|
52
87
|
<template #header>
|
|
53
|
-
<
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
88
|
+
<ForwardSlots v-if="navbar" :slots="navbarSlots">
|
|
89
|
+
<UDashboardNavbar :ui="navbarUi" class="bg-white" :title="navbar.title">
|
|
90
|
+
<template #left>
|
|
91
|
+
<UDashboardSidebarCollapse v-if="navbar.sidebarToggle" class="-ml-1" />
|
|
92
|
+
|
|
93
|
+
<div class="flex min-w-0 flex-col items-start gap-0.5">
|
|
94
|
+
<div class="flex min-w-0 items-center gap-1.5">
|
|
95
|
+
<h1 class="text-highlighted truncate font-semibold">
|
|
96
|
+
<slot name="navbar-title">{{ navbar.title }}</slot>
|
|
97
|
+
</h1>
|
|
98
|
+
<slot name="navbar-trailing" />
|
|
99
|
+
</div>
|
|
57
100
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
101
|
+
<UBreadcrumb
|
|
102
|
+
v-if="navbar.breadcrumb"
|
|
103
|
+
:items="navbar.breadcrumb"
|
|
104
|
+
:ui="{
|
|
105
|
+
...navbar.breadcrumbUi,
|
|
106
|
+
link: mergeSlotClass(navbar.breadcrumbUi?.link, 'text-xs'),
|
|
107
|
+
separatorIcon: mergeSlotClass(navbar.breadcrumbUi?.separatorIcon, 'size-3.5'),
|
|
108
|
+
}"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<template #right>
|
|
114
|
+
<!-- eslint-disable-next-line vue/require-explicit-slots -->
|
|
115
|
+
<slot name="navbar-right" />
|
|
116
|
+
<div id="navbar-actions" class="flex items-center gap-2">
|
|
117
|
+
<slot name="navbar-actions" />
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</UDashboardNavbar>
|
|
121
|
+
</ForwardSlots>
|
|
122
|
+
<UDashboardToolbar
|
|
123
|
+
v-if="tabs || toolItems.length"
|
|
124
|
+
:ui="toolbarUi"
|
|
125
|
+
class="bg-white *:first:-ml-2"
|
|
126
|
+
>
|
|
127
|
+
<template #left>
|
|
128
|
+
<UNavigationMenu v-if="tabs" :items="tabs.items" highlight variant="link" :ui="tabs.ui" />
|
|
129
|
+
<UNavigationMenu v-else :items="toolItems" highlight :ui="tools?.ui" />
|
|
130
|
+
</template>
|
|
131
|
+
<template v-if="tabs" #right>
|
|
132
|
+
<UNavigationMenu :items="toolItems" highlight :ui="tools?.ui" />
|
|
61
133
|
</template>
|
|
62
|
-
</UDashboardNavbar>
|
|
63
|
-
<UDashboardToolbar v-if="toolbar" :ui="toolbar.ui" class="bg-white">
|
|
64
|
-
<UNavigationMenu :items="toolbar.items" highlight :ui="toolbar.itemsUi" />
|
|
65
|
-
<UNavigationMenu
|
|
66
|
-
:items="toolbar.itemsEnd"
|
|
67
|
-
class="ml-auto"
|
|
68
|
-
highlight
|
|
69
|
-
:ui="toolbar.itemsEndUi"
|
|
70
|
-
/>
|
|
71
134
|
</UDashboardToolbar>
|
|
72
135
|
</template>
|
|
73
136
|
<template #body>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type {
|
|
3
3
|
ArrayOrNested,
|
|
4
4
|
AvatarProps,
|
|
5
|
+
DashboardSearchProps,
|
|
5
6
|
DashboardSidebarProps,
|
|
6
7
|
DropdownMenuItem,
|
|
7
8
|
NavigationMenuItem,
|
|
@@ -15,6 +16,7 @@ defineProps<{
|
|
|
15
16
|
src?: string
|
|
16
17
|
iconSrc?: string
|
|
17
18
|
}
|
|
19
|
+
search?: DashboardSearchProps
|
|
18
20
|
items?: NavigationMenuItem[]
|
|
19
21
|
bottomItems?: NavigationMenuItem[]
|
|
20
22
|
userMenu?: {
|
|
@@ -55,18 +57,21 @@ const config = useRuntimeConfig()
|
|
|
55
57
|
<slot v-if="!collapsed" name="logo">
|
|
56
58
|
<img v-if="logo?.src" class="h-5 w-auto shrink-0" :src="logo.src" />
|
|
57
59
|
</slot>
|
|
58
|
-
<
|
|
59
|
-
<
|
|
60
|
-
v-if="logo?.iconSrc"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
:class="{
|
|
64
|
-
'mx-auto': collapsed,
|
|
65
|
-
}"
|
|
66
|
-
/>
|
|
67
|
-
</slot>
|
|
60
|
+
<div v-if="collapsed || (!logo?.src && !slots.logo)" :class="{ 'mx-auto': collapsed }">
|
|
61
|
+
<slot name="icon">
|
|
62
|
+
<img v-if="logo?.iconSrc" class="size-5" :src="logo.iconSrc" />
|
|
63
|
+
</slot>
|
|
64
|
+
</div>
|
|
68
65
|
</template>
|
|
69
66
|
<template #default="{ collapsed }">
|
|
67
|
+
<!-- height and negative margins line the border up with the `LayoutNavbar` toolbar's, cancelling the sidebar body's padding -->
|
|
68
|
+
<div
|
|
69
|
+
v-if="search"
|
|
70
|
+
class="border-default -mx-4 -mt-2 -mb-2 flex h-[calc(--spacing(12)+1px)] shrink-0 items-center border-b px-4 sm:max-lg:-mx-6 sm:max-lg:px-6"
|
|
71
|
+
>
|
|
72
|
+
<UDashboardSearchButton :collapsed="collapsed" variant="outline" tooltip block />
|
|
73
|
+
</div>
|
|
74
|
+
|
|
70
75
|
<UNavigationMenu
|
|
71
76
|
v-if="items"
|
|
72
77
|
:collapsed="collapsed"
|
|
@@ -105,6 +110,8 @@ const config = useRuntimeConfig()
|
|
|
105
110
|
</template>
|
|
106
111
|
</UDashboardSidebar>
|
|
107
112
|
|
|
113
|
+
<UDashboardSearch v-if="search" :color-mode="false" v-bind="search" />
|
|
114
|
+
|
|
108
115
|
<slot />
|
|
109
116
|
</UDashboardGroup>
|
|
110
117
|
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { CardProps, CardSlots } from '@nuxt/ui'
|
|
3
|
+
import { useForwardProps } from 'reka-ui'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<CardProps>()
|
|
6
|
+
const slots = defineSlots<
|
|
7
|
+
CardSlots & {
|
|
8
|
+
/** Ribbon strip above the body — fill it with `URibbonSection`s. */
|
|
9
|
+
ribbon?: () => any
|
|
10
|
+
}
|
|
11
|
+
>()
|
|
12
|
+
|
|
13
|
+
const forwarded = useForwardProps(props)
|
|
14
|
+
|
|
15
|
+
const cardSlots = computed(
|
|
16
|
+
() => Object.keys(slots).filter((name) => name !== 'ribbon') as (keyof CardSlots)[],
|
|
17
|
+
)
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="flex flex-col">
|
|
22
|
+
<!-- the ribbon's lower edge runs behind the card, so the card keeps its own rounded top -->
|
|
23
|
+
<div
|
|
24
|
+
v-if="slots.ribbon"
|
|
25
|
+
class="divide-default ring-default bg-elevated -mb-2 flex items-stretch divide-x overflow-x-auto rounded-t-lg pb-2 shadow-[inset_0_2px_3px_-2px_rgb(0_0_0/0.06),inset_2px_0_3px_-2px_rgb(0_0_0/0.06),inset_-2px_0_3px_-2px_rgb(0_0_0/0.06)] ring"
|
|
26
|
+
>
|
|
27
|
+
<slot name="ribbon" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<UCard
|
|
31
|
+
v-bind="forwarded"
|
|
32
|
+
:ui="{
|
|
33
|
+
body: 'p-0!',
|
|
34
|
+
...forwarded.ui,
|
|
35
|
+
}"
|
|
36
|
+
class="ring-accented relative shadow-[0_-2px_3px_-1px_rgb(0_0_0/0.08)]"
|
|
37
|
+
>
|
|
38
|
+
<template v-for="name of cardSlots" #[name]>
|
|
39
|
+
<slot :name />
|
|
40
|
+
</template>
|
|
41
|
+
</UCard>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineOptions({
|
|
3
|
+
inheritAttrs: false,
|
|
4
|
+
})
|
|
5
|
+
|
|
6
|
+
defineProps<{
|
|
7
|
+
title?: string
|
|
8
|
+
end?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
defineSlots<{
|
|
12
|
+
default: () => any
|
|
13
|
+
}>()
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div
|
|
18
|
+
:data-end="end || undefined"
|
|
19
|
+
class="flex shrink-0 flex-col gap-1.5 px-3 py-2"
|
|
20
|
+
:class="end && 'border-default [&:not([data-end]~*)]:ml-auto [&:not([data-end]~*)]:border-l'"
|
|
21
|
+
>
|
|
22
|
+
<p v-if="title" class="text-dimmed text-[10px] leading-none">
|
|
23
|
+
{{ title }}
|
|
24
|
+
</p>
|
|
25
|
+
<div class="flex flex-1 flex-wrap items-center gap-1" v-bind="$attrs">
|
|
26
|
+
<slot />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pins a table's column widths at their measured auto-layout size, so filtering rows out can't
|
|
3
|
+
* resize the columns underneath the user
|
|
4
|
+
*
|
|
5
|
+
* pass the `UTable` template ref — the widths re-measure when the table resizes or a column is
|
|
6
|
+
* shown or hidden, and `freeze()` re-measures them by hand once late data has rendered
|
|
7
|
+
*/
|
|
8
|
+
export function useStableColumnWidths(
|
|
9
|
+
table: MaybeRefOrGetter<{ $el?: HTMLElement } | null | undefined>,
|
|
10
|
+
) {
|
|
11
|
+
const tableEl = computed(() => toValue(table)?.$el?.querySelector('table') ?? null)
|
|
12
|
+
const headerRow = computed(() => tableEl.value?.tHead?.rows[0] ?? null)
|
|
13
|
+
|
|
14
|
+
/** the table width the current widths were measured at */
|
|
15
|
+
let measuredAt = 0
|
|
16
|
+
|
|
17
|
+
function freeze() {
|
|
18
|
+
const el = tableEl.value
|
|
19
|
+
const cells = headerRow.value && [...headerRow.value.cells]
|
|
20
|
+
if (!el || !cells) return
|
|
21
|
+
|
|
22
|
+
// measure the natural layout first — the pinned widths would otherwise fix the old one in place
|
|
23
|
+
el.style.tableLayout = ''
|
|
24
|
+
for (const cell of cells) cell.style.width = ''
|
|
25
|
+
|
|
26
|
+
const widths = cells.map((cell) => cell.getBoundingClientRect().width)
|
|
27
|
+
for (const [index, cell] of cells.entries()) cell.style.width = `${widths[index]!}px`
|
|
28
|
+
el.style.tableLayout = 'fixed'
|
|
29
|
+
measuredAt = el.getBoundingClientRect().width
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onMounted(freeze)
|
|
33
|
+
// a resize gives the columns a different share of the table, so they re-measure
|
|
34
|
+
useResizeObserver(tableEl, () => {
|
|
35
|
+
if (tableEl.value?.getBoundingClientRect().width !== measuredAt) freeze()
|
|
36
|
+
})
|
|
37
|
+
// showing or hiding a column adds or removes header cells, so the pinned widths no longer line up
|
|
38
|
+
useMutationObserver(headerRow, freeze, { childList: true })
|
|
39
|
+
|
|
40
|
+
return { freeze }
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
2
|
+
import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
export type ToolbarTool = NavigationMenuItem
|
|
5
|
+
|
|
6
|
+
export const toolbarToolsKey = Symbol('toolbar-tools') as InjectionKey<
|
|
7
|
+
Ref<MaybeRefOrGetter<ToolbarTool>[]>
|
|
8
|
+
>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tools of the enclosing `LayoutNavbar`'s toolbar, so that anything rendered inside it — a nested
|
|
12
|
+
* `NuxtPage`, a deeply nested component — can contribute to it. Whoever renders `LayoutNavbar`
|
|
13
|
+
* itself passes its tools as props instead.
|
|
14
|
+
*/
|
|
15
|
+
export function useToolbar() {
|
|
16
|
+
const injected = inject(toolbarToolsKey)
|
|
17
|
+
if (!injected) throw new Error('useToolbar() has to be called inside a `LayoutNavbar`')
|
|
18
|
+
const toolSources = injected
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Appends a tool to the toolbar. Pass a getter to keep the tool reactive.
|
|
22
|
+
*
|
|
23
|
+
* The tool is removed again when the calling scope is disposed, i.e. when the component that
|
|
24
|
+
* added it unmounts.
|
|
25
|
+
*/
|
|
26
|
+
function addTool(tool: MaybeRefOrGetter<ToolbarTool>) {
|
|
27
|
+
toolSources.value.push(tool)
|
|
28
|
+
|
|
29
|
+
const remove = () => {
|
|
30
|
+
const index = toolSources.value.indexOf(tool)
|
|
31
|
+
if (index !== -1) toolSources.value.splice(index, 1)
|
|
32
|
+
}
|
|
33
|
+
onScopeDispose(remove, true)
|
|
34
|
+
|
|
35
|
+
return remove
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
tools: computed(() => toolSources.value.map((tool) => toValue(tool))),
|
|
40
|
+
addTool,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { TRPCClientError } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
3
|
+
import type { ToastOptions } from '../../../composables/useToast'
|
|
4
|
+
import { isTRPCClientError } from '@trpc/client'
|
|
5
|
+
|
|
6
|
+
export interface ToastOpts {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
12
|
+
function isNetworkError(err: unknown) {
|
|
13
|
+
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
17
|
+
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
18
|
+
return err.data as TRPCDefaultErrorData | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
22
|
+
|
|
23
|
+
export function isRetryableError(err: unknown) {
|
|
24
|
+
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
25
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
26
|
+
|
|
27
|
+
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
31
|
+
function isSchemaIssueList(message: string) {
|
|
32
|
+
if (!message.startsWith('[')) return false
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const issues = JSON.parse(message) as unknown[]
|
|
36
|
+
return (
|
|
37
|
+
issues.length > 0 &&
|
|
38
|
+
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
39
|
+
)
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
46
|
+
BAD_REQUEST: 'Ungültige Eingabe',
|
|
47
|
+
UNAUTHORIZED: 'Nicht angemeldet',
|
|
48
|
+
FORBIDDEN: 'Keine Berechtigung',
|
|
49
|
+
NOT_FOUND: 'Nicht vorhanden',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requestErrorToast(err: unknown): ToastOpts {
|
|
53
|
+
if (isNetworkError(err))
|
|
54
|
+
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
55
|
+
|
|
56
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
57
|
+
|
|
58
|
+
// internal errors leak implementation details and mean nothing to the user
|
|
59
|
+
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
60
|
+
|
|
61
|
+
const code = errorData(err)?.code
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
65
|
+
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
70
|
+
export function toastAdd(opts: ToastOptions) {
|
|
71
|
+
if (import.meta.server) return
|
|
72
|
+
|
|
73
|
+
useToast().add({ duration: 5000, ...opts })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
77
|
+
export function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
78
|
+
toastAdd({
|
|
79
|
+
preset: 'error',
|
|
80
|
+
...(opts ?? requestErrorToast(err)),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OperationLink, TRPCLink } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter } from '@trpc/server'
|
|
3
|
+
import type { FetchOptions } from 'ofetch'
|
|
4
|
+
import type { ObjectPlugin } from '#app'
|
|
5
|
+
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
6
|
+
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
7
|
+
import { useQueryClient } from '@tanstack/vue-query'
|
|
8
|
+
import { httpSubscriptionLink, splitLink } from '@trpc/client'
|
|
9
|
+
import { observable } from '@trpc/server/observable'
|
|
10
|
+
import superjsonDefault from 'superjson'
|
|
11
|
+
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
12
|
+
import { toastRequestError } from './helper/request-error'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
16
|
+
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
17
|
+
* from the query/mutation cache instead.
|
|
18
|
+
*/
|
|
19
|
+
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
20
|
+
observable((observer) => {
|
|
21
|
+
const subscription = next(op).subscribe({
|
|
22
|
+
next: (value) => observer.next(value),
|
|
23
|
+
complete: () => observer.complete(),
|
|
24
|
+
error(err) {
|
|
25
|
+
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
26
|
+
console.error(err)
|
|
27
|
+
toastRequestError(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
observer.error(err)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
subscription.unsubscribe()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
39
|
+
|
|
40
|
+
interface TrpcNuxtPluginOptions {
|
|
41
|
+
url: string
|
|
42
|
+
/**
|
|
43
|
+
* ofetch options passed to the HTTP links.
|
|
44
|
+
* @see https://github.com/unjs/ofetch
|
|
45
|
+
*/
|
|
46
|
+
fetchOptions?: FetchOptions
|
|
47
|
+
/**
|
|
48
|
+
* ofetch options for queries, merged over `fetchOptions`.
|
|
49
|
+
*/
|
|
50
|
+
queryFetchOptions?: FetchOptions
|
|
51
|
+
/**
|
|
52
|
+
* ofetch options for mutations, merged over `fetchOptions`.
|
|
53
|
+
*/
|
|
54
|
+
mutationFetchOptions?: FetchOptions
|
|
55
|
+
/**
|
|
56
|
+
* Custom superjson instance, e.g. one with registered custom transformers.
|
|
57
|
+
* @default superjson
|
|
58
|
+
*/
|
|
59
|
+
transformer?: typeof superjsonDefault
|
|
60
|
+
/**
|
|
61
|
+
* Batching options passed to the `httpBatchLink`.
|
|
62
|
+
*/
|
|
63
|
+
batchOptions?: {
|
|
64
|
+
maxURLLength?: number
|
|
65
|
+
maxItems?: number
|
|
66
|
+
methodOverride?: 'POST'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'trpc',
|
|
72
|
+
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
73
|
+
dependsOn: ['vue-query'] as any,
|
|
74
|
+
setup() {
|
|
75
|
+
const queryClient = useQueryClient()
|
|
76
|
+
const headers = useRequestHeaders()
|
|
77
|
+
const superjson = opts.transformer ?? superjsonDefault
|
|
78
|
+
|
|
79
|
+
const trpc = createTRPCVueQueryClient<Router>({
|
|
80
|
+
queryClient,
|
|
81
|
+
trpc: {
|
|
82
|
+
links: [
|
|
83
|
+
requestErrorToastLink,
|
|
84
|
+
typedFormDataLink<AnyTRPCRouter>({ transformer: superjson }),
|
|
85
|
+
splitLink({
|
|
86
|
+
condition: (op) => op.type === 'subscription',
|
|
87
|
+
true: httpSubscriptionLink({
|
|
88
|
+
url: opts.url,
|
|
89
|
+
transformer: superjson,
|
|
90
|
+
}),
|
|
91
|
+
false: splitLink({
|
|
92
|
+
condition: (op) => op.type === 'mutation',
|
|
93
|
+
true: httpLink({
|
|
94
|
+
transformer: superjson,
|
|
95
|
+
url: opts.url,
|
|
96
|
+
headers,
|
|
97
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.mutationFetchOptions },
|
|
98
|
+
}),
|
|
99
|
+
false: httpBatchLink({
|
|
100
|
+
transformer: superjson,
|
|
101
|
+
url: opts.url,
|
|
102
|
+
headers,
|
|
103
|
+
maxURLLength: 2000,
|
|
104
|
+
...opts.batchOptions,
|
|
105
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.queryFetchOptions },
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
provide: {
|
|
115
|
+
trpc,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
} satisfies ObjectPlugin
|
|
120
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DefaultOptions,
|
|
3
|
+
DehydratedState,
|
|
4
|
+
QueryClientConfig,
|
|
5
|
+
VueQueryPluginOptions,
|
|
6
|
+
} from '@tanstack/vue-query'
|
|
7
|
+
import type { ObjectPlugin } from '#app'
|
|
8
|
+
import type { ToastOpts } from './helper/request-error'
|
|
9
|
+
import {
|
|
10
|
+
dehydrate,
|
|
11
|
+
hydrate,
|
|
12
|
+
MutationCache,
|
|
13
|
+
QueryCache,
|
|
14
|
+
QueryClient,
|
|
15
|
+
useIsFetching,
|
|
16
|
+
VueQueryPlugin,
|
|
17
|
+
} from '@tanstack/vue-query'
|
|
18
|
+
import defu from 'defu'
|
|
19
|
+
import { useState } from '#app'
|
|
20
|
+
import { isRetryableError, toastAdd, toastRequestError } from './helper/request-error'
|
|
21
|
+
|
|
22
|
+
interface VueQueryNuxtPluginOptions {
|
|
23
|
+
queryDefaultOptions?: DefaultOptions
|
|
24
|
+
vuePluginOptions?: VueQueryPluginOptions
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CustomMeta {
|
|
28
|
+
queryMeta: {
|
|
29
|
+
toast?: {
|
|
30
|
+
error?: ToastOpts
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
mutationMeta: {
|
|
34
|
+
toast?: {
|
|
35
|
+
success?: ToastOpts
|
|
36
|
+
error?: ToastOpts
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module '@tanstack/vue-query' {
|
|
42
|
+
interface Register extends CustomMeta {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'vue-query',
|
|
48
|
+
setup(nuxt) {
|
|
49
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
50
|
+
|
|
51
|
+
const queryClient = new QueryClient(
|
|
52
|
+
defu<QueryClientConfig, QueryClientConfig[]>(
|
|
53
|
+
{ defaultOptions: opts?.queryDefaultOptions },
|
|
54
|
+
{
|
|
55
|
+
defaultOptions: {
|
|
56
|
+
queries: {
|
|
57
|
+
retry(failureCount, error) {
|
|
58
|
+
if (!isRetryableError(error)) return false
|
|
59
|
+
|
|
60
|
+
return failureCount < 3
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
queryCache: new QueryCache({
|
|
65
|
+
onError(err, query) {
|
|
66
|
+
console.error(err)
|
|
67
|
+
|
|
68
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
mutationCache: new MutationCache({
|
|
72
|
+
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
73
|
+
if (mutation.meta?.toast?.success) {
|
|
74
|
+
toastAdd({
|
|
75
|
+
preset: 'success',
|
|
76
|
+
...mutation.meta.toast.success,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onError(err, _input, __onMutateRes, mutation) {
|
|
81
|
+
console.error(err)
|
|
82
|
+
|
|
83
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
91
|
+
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
92
|
+
|
|
93
|
+
if (import.meta.server) {
|
|
94
|
+
nuxt.hooks.hook('app:rendered', () => {
|
|
95
|
+
try {
|
|
96
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (import.meta.client) {
|
|
104
|
+
nuxt.hooks.hook('app:created', () => {
|
|
105
|
+
try {
|
|
106
|
+
hydrate(queryClient, vueQueryState.value)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// global nuxt loading indicator
|
|
114
|
+
const loadingIndicator = useLoadingIndicator()
|
|
115
|
+
const isFetching = useIsFetching()
|
|
116
|
+
|
|
117
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
118
|
+
watch(isFetching, () => {
|
|
119
|
+
if (isFetching.value > 0) {
|
|
120
|
+
loadingIndicator.start()
|
|
121
|
+
timeout = setTimeout(() => {
|
|
122
|
+
loadingIndicator.set(0)
|
|
123
|
+
}, 300)
|
|
124
|
+
} else {
|
|
125
|
+
if (timeout) clearTimeout(timeout)
|
|
126
|
+
loadingIndicator.finish()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
} satisfies ObjectPlugin
|
|
131
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -44,6 +44,11 @@ export default defineNuxtConfig({
|
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
46
|
imports: {
|
|
47
|
+
dirs: [
|
|
48
|
+
// layer configs resolve `~` against the consuming project, so use absolute paths
|
|
49
|
+
path.join(currentDir, './app/types'),
|
|
50
|
+
path.join(currentDir, './app/utils/*/index.ts'),
|
|
51
|
+
],
|
|
47
52
|
imports: [
|
|
48
53
|
{
|
|
49
54
|
from: '@falcondev-oss/form-vue',
|
|
@@ -60,6 +65,10 @@ export default defineNuxtConfig({
|
|
|
60
65
|
},
|
|
61
66
|
],
|
|
62
67
|
},
|
|
68
|
+
sourcemap: {
|
|
69
|
+
client: 'hidden',
|
|
70
|
+
server: true,
|
|
71
|
+
},
|
|
63
72
|
|
|
64
73
|
// runtime
|
|
65
74
|
experimental: {
|
package/package.json
CHANGED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { CardProps, CardSlots } from '@nuxt/ui'
|
|
3
|
-
import { useForwardProps } from 'reka-ui'
|
|
4
|
-
const props = defineProps<CardProps>()
|
|
5
|
-
const slots = defineSlots<CardSlots>()
|
|
6
|
-
|
|
7
|
-
const forwarded = useForwardProps(props)
|
|
8
|
-
</script>
|
|
9
|
-
|
|
10
|
-
<template>
|
|
11
|
-
<UCard
|
|
12
|
-
v-bind="forwarded"
|
|
13
|
-
:ui="{
|
|
14
|
-
body: 'p-0!',
|
|
15
|
-
...forwarded.ui,
|
|
16
|
-
}"
|
|
17
|
-
>
|
|
18
|
-
<template v-for="(_, name) of slots" #[name]>
|
|
19
|
-
<slot :name />
|
|
20
|
-
</template>
|
|
21
|
-
</UCard>
|
|
22
|
-
</template>
|
package/app/pages/index.vue
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/* eslint-disable ts/no-empty-object-type */
|
|
2
|
-
import type { AllUnionFields, UnionToTuple } from 'type-fest'
|
|
3
|
-
import type {
|
|
4
|
-
ComponentOptionsMixin,
|
|
5
|
-
CreateComponentPublicInstanceWithMixins,
|
|
6
|
-
EmitsOptions,
|
|
7
|
-
EmitsToProps,
|
|
8
|
-
PublicProps,
|
|
9
|
-
RenderFunction,
|
|
10
|
-
SetupContext,
|
|
11
|
-
SlotsType,
|
|
12
|
-
} from 'vue'
|
|
13
|
-
|
|
14
|
-
export function defineSetupComponentOld<
|
|
15
|
-
const Opts extends {
|
|
16
|
-
props: Record<string, any>
|
|
17
|
-
emits: Record<string, any>
|
|
18
|
-
slots: Record<string, any>
|
|
19
|
-
},
|
|
20
|
-
Setup extends (
|
|
21
|
-
props: Props,
|
|
22
|
-
ctx: SetupContext<Opts['emits'], SlotsType<Partial<Opts['slots']>>>,
|
|
23
|
-
) => RenderFunction | Promise<RenderFunction>,
|
|
24
|
-
const PropsRuntime extends Readonly<UnionToTuple<keyof AllUnionFields<Opts['props']>>>,
|
|
25
|
-
E extends EmitsOptions = Opts['emits'],
|
|
26
|
-
Props extends Record<string, any> = Opts['props'] & EmitsToProps<E>,
|
|
27
|
-
PP = PublicProps & { vSlots?: Opts['slots'] },
|
|
28
|
-
S extends SlotsType<Partial<Opts['slots']>> = SlotsType<Partial<Opts['slots']>>,
|
|
29
|
-
>(
|
|
30
|
-
define: (opts: Opts) => { props: PropsRuntime },
|
|
31
|
-
setup: Setup,
|
|
32
|
-
): new (
|
|
33
|
-
props: Opts['props'],
|
|
34
|
-
) => CreateComponentPublicInstanceWithMixins<
|
|
35
|
-
Props,
|
|
36
|
-
{},
|
|
37
|
-
{},
|
|
38
|
-
{},
|
|
39
|
-
{},
|
|
40
|
-
ComponentOptionsMixin,
|
|
41
|
-
ComponentOptionsMixin,
|
|
42
|
-
E,
|
|
43
|
-
PP,
|
|
44
|
-
{},
|
|
45
|
-
false,
|
|
46
|
-
{},
|
|
47
|
-
S
|
|
48
|
-
> {
|
|
49
|
-
// eslint-disable-next-line ts/no-unsafe-argument
|
|
50
|
-
const opts = define({} as any)
|
|
51
|
-
// eslint-disable-next-line ts/no-unsafe-return
|
|
52
|
-
return defineComponent(setup, {
|
|
53
|
-
props: opts.props as unknown as string[],
|
|
54
|
-
}) as any
|
|
55
|
-
}
|
package/app/utils/plugins.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import type { DehydratedState, QueryClientConfig, VueQueryPluginOptions } from '@tanstack/vue-query'
|
|
2
|
-
import type { OperationLink, TRPCClientError, TRPCLink } from '@trpc/client'
|
|
3
|
-
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
4
|
-
import type { FetchOptions } from 'ofetch'
|
|
5
|
-
import type { ObjectPlugin } from '#app'
|
|
6
|
-
import type { ToastOptions } from '../composables/useToast'
|
|
7
|
-
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
8
|
-
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
9
|
-
import {
|
|
10
|
-
dehydrate,
|
|
11
|
-
hydrate,
|
|
12
|
-
MutationCache,
|
|
13
|
-
QueryCache,
|
|
14
|
-
QueryClient,
|
|
15
|
-
useIsFetching,
|
|
16
|
-
useQueryClient,
|
|
17
|
-
VueQueryPlugin,
|
|
18
|
-
} from '@tanstack/vue-query'
|
|
19
|
-
import { httpSubscriptionLink, isTRPCClientError, splitLink } from '@trpc/client'
|
|
20
|
-
import { observable } from '@trpc/server/observable'
|
|
21
|
-
import defu from 'defu'
|
|
22
|
-
import superjson from 'superjson'
|
|
23
|
-
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
24
|
-
import { useState } from '#app'
|
|
25
|
-
|
|
26
|
-
interface VueQueryNuxtPluginOptions {
|
|
27
|
-
queryClientOptions?: QueryClientConfig
|
|
28
|
-
vuePluginOptions?: VueQueryPluginOptions
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ToastOpts {
|
|
32
|
-
title?: string
|
|
33
|
-
description?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface CustomMeta {
|
|
37
|
-
queryMeta: {
|
|
38
|
-
toast?: {
|
|
39
|
-
error?: ToastOpts
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
mutationMeta: {
|
|
43
|
-
toast?: {
|
|
44
|
-
success?: ToastOpts
|
|
45
|
-
error?: ToastOpts
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
declare module '@tanstack/vue-query' {
|
|
51
|
-
interface Register extends CustomMeta {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
55
|
-
function isNetworkError(err: unknown) {
|
|
56
|
-
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
60
|
-
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
61
|
-
return err.data as TRPCDefaultErrorData | undefined
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
65
|
-
|
|
66
|
-
function isRetryableError(err: unknown) {
|
|
67
|
-
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
68
|
-
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
69
|
-
|
|
70
|
-
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
74
|
-
function isSchemaIssueList(message: string) {
|
|
75
|
-
if (!message.startsWith('[')) return false
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const issues = JSON.parse(message) as unknown[]
|
|
79
|
-
return (
|
|
80
|
-
issues.length > 0 &&
|
|
81
|
-
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
82
|
-
)
|
|
83
|
-
} catch {
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
89
|
-
BAD_REQUEST: 'Ungültige Eingabe',
|
|
90
|
-
UNAUTHORIZED: 'Nicht angemeldet',
|
|
91
|
-
FORBIDDEN: 'Keine Berechtigung',
|
|
92
|
-
NOT_FOUND: 'Nicht vorhanden',
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function requestErrorToast(err: unknown): ToastOpts {
|
|
96
|
-
if (isNetworkError(err))
|
|
97
|
-
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
98
|
-
|
|
99
|
-
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
100
|
-
|
|
101
|
-
// internal errors leak implementation details and mean nothing to the user
|
|
102
|
-
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
103
|
-
|
|
104
|
-
const code = errorData(err)?.code
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
108
|
-
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
113
|
-
function toastAdd(opts: ToastOptions) {
|
|
114
|
-
if (import.meta.server) return
|
|
115
|
-
|
|
116
|
-
useToast().add({ duration: 5000, ...opts })
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
120
|
-
function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
121
|
-
toastAdd({
|
|
122
|
-
preset: 'error',
|
|
123
|
-
...(opts ?? requestErrorToast(err)),
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
128
|
-
return {
|
|
129
|
-
name: 'vue-query',
|
|
130
|
-
setup(nuxt) {
|
|
131
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
132
|
-
|
|
133
|
-
const queryClient = new QueryClient(
|
|
134
|
-
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
135
|
-
defaultOptions: {
|
|
136
|
-
queries: {
|
|
137
|
-
retry(failureCount, error) {
|
|
138
|
-
if (!isRetryableError(error)) return false
|
|
139
|
-
|
|
140
|
-
return failureCount < 3
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
queryCache: new QueryCache({
|
|
145
|
-
onError(err, query) {
|
|
146
|
-
console.error(err)
|
|
147
|
-
|
|
148
|
-
toastRequestError(err, query.meta?.toast?.error)
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
mutationCache: new MutationCache({
|
|
152
|
-
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
153
|
-
if (mutation.meta?.toast?.success) {
|
|
154
|
-
toastAdd({
|
|
155
|
-
preset: 'success',
|
|
156
|
-
...mutation.meta.toast.success,
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
onError(err, _input, __onMutateRes, mutation) {
|
|
161
|
-
console.error(err)
|
|
162
|
-
|
|
163
|
-
toastRequestError(err, mutation.meta?.toast?.error)
|
|
164
|
-
},
|
|
165
|
-
}),
|
|
166
|
-
}),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
170
|
-
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
171
|
-
|
|
172
|
-
if (import.meta.server) {
|
|
173
|
-
nuxt.hooks.hook('app:rendered', () => {
|
|
174
|
-
try {
|
|
175
|
-
vueQueryState.value = dehydrate(queryClient)
|
|
176
|
-
} catch (err) {
|
|
177
|
-
console.error('[vue-query] dehydrating state failed:', err)
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (import.meta.client) {
|
|
183
|
-
nuxt.hooks.hook('app:created', () => {
|
|
184
|
-
try {
|
|
185
|
-
hydrate(queryClient, vueQueryState.value)
|
|
186
|
-
} catch (err) {
|
|
187
|
-
console.error('[vue-query] hydrating state failed:', err)
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// global nuxt loading indicator
|
|
193
|
-
const loadingIndicator = useLoadingIndicator()
|
|
194
|
-
const isFetching = useIsFetching()
|
|
195
|
-
|
|
196
|
-
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
197
|
-
watch(isFetching, () => {
|
|
198
|
-
if (isFetching.value > 0) {
|
|
199
|
-
loadingIndicator.start()
|
|
200
|
-
timeout = setTimeout(() => {
|
|
201
|
-
loadingIndicator.set(0)
|
|
202
|
-
}, 300)
|
|
203
|
-
} else {
|
|
204
|
-
if (timeout) clearTimeout(timeout)
|
|
205
|
-
loadingIndicator.finish()
|
|
206
|
-
}
|
|
207
|
-
})
|
|
208
|
-
},
|
|
209
|
-
} satisfies ObjectPlugin
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
214
|
-
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
215
|
-
* from the query/mutation cache instead.
|
|
216
|
-
*/
|
|
217
|
-
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
218
|
-
observable((observer) => {
|
|
219
|
-
const subscription = next(op).subscribe({
|
|
220
|
-
next: (value) => observer.next(value),
|
|
221
|
-
complete: () => observer.complete(),
|
|
222
|
-
error(err) {
|
|
223
|
-
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
224
|
-
console.error(err)
|
|
225
|
-
toastRequestError(err)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
observer.error(err)
|
|
229
|
-
},
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
return () => {
|
|
233
|
-
subscription.unsubscribe()
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
237
|
-
|
|
238
|
-
interface TrpcNuxtPluginOptions {
|
|
239
|
-
url: string
|
|
240
|
-
/**
|
|
241
|
-
* ofetch options passed to the HTTP links.
|
|
242
|
-
* @see https://github.com/unjs/ofetch
|
|
243
|
-
*/
|
|
244
|
-
fetchOptions?: FetchOptions
|
|
245
|
-
}
|
|
246
|
-
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
247
|
-
return {
|
|
248
|
-
name: 'trpc',
|
|
249
|
-
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
250
|
-
dependsOn: ['vue-query'] as any,
|
|
251
|
-
setup() {
|
|
252
|
-
const queryClient = useQueryClient()
|
|
253
|
-
|
|
254
|
-
const trpc = createTRPCVueQueryClient<Router>({
|
|
255
|
-
queryClient,
|
|
256
|
-
trpc: {
|
|
257
|
-
links: [
|
|
258
|
-
requestErrorToastLink,
|
|
259
|
-
splitLink({
|
|
260
|
-
condition: (op) => op.type === 'subscription',
|
|
261
|
-
true: httpSubscriptionLink({
|
|
262
|
-
url: opts.url,
|
|
263
|
-
transformer: superjson,
|
|
264
|
-
}),
|
|
265
|
-
false: splitLink({
|
|
266
|
-
condition: (op) => op.type === 'mutation',
|
|
267
|
-
true: [
|
|
268
|
-
typedFormDataLink<AnyTRPCRouter>({
|
|
269
|
-
transformer: superjson,
|
|
270
|
-
}),
|
|
271
|
-
httpLink({
|
|
272
|
-
transformer: superjson,
|
|
273
|
-
url: opts.url,
|
|
274
|
-
fetchOptions: opts.fetchOptions,
|
|
275
|
-
}),
|
|
276
|
-
],
|
|
277
|
-
false: httpBatchLink({
|
|
278
|
-
transformer: superjson,
|
|
279
|
-
url: opts.url,
|
|
280
|
-
maxURLLength: 2000,
|
|
281
|
-
fetchOptions: opts.fetchOptions,
|
|
282
|
-
}),
|
|
283
|
-
}),
|
|
284
|
-
}),
|
|
285
|
-
],
|
|
286
|
-
},
|
|
287
|
-
})
|
|
288
|
-
return {
|
|
289
|
-
provide: {
|
|
290
|
-
trpc,
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
} satisfies ObjectPlugin
|
|
295
|
-
}
|