@falcondev-oss/nuxt-layers-base 0.38.0 → 0.39.0

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.
@@ -28,4 +28,8 @@
28
28
  fieldset > label {
29
29
  cursor: pointer;
30
30
  }
31
+
32
+ .iconify {
33
+ flex-shrink: 0;
34
+ }
31
35
  }
@@ -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;
@@ -1,3 +1,7 @@
1
1
  @utility z-full {
2
2
  z-index: 2147483647;
3
3
  }
4
+
5
+ @utility no-scrollbar {
6
+ @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden;
7
+ }
@@ -0,0 +1,3 @@
1
+ @custom-variant self-hover (&[class*="self-hover"]:hover:not(:has([class*="self-hover"]:hover)));
2
+
3
+ @custom-variant self-focus-within (&[class*="self-focus-within"]:focus-within:not(:has([class*="self-focus-within"]:focus-within)));
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts" generic="const O extends RadioGroupItem[], R extends ConfirmModalResult<O>">
2
+ import type { ButtonProps, RadioGroupItem, RadioGroupProps } from '@nuxt/ui'
3
+ import type { ComponentPublicInstance } from 'vue'
4
+ import type { ConfirmModalResult } from '~/composables/confirm'
5
+ import type { templateParts } from '~/utils/display'
6
+
7
+ export type ConfirmModalProps<O extends RadioGroupItem[]> = {
8
+ title: string
9
+ confirmLabel: string
10
+ confirmDisabled?: () => boolean
11
+ text?: string | ReturnType<typeof templateParts>
12
+ slotRef?: ComponentPublicInstance
13
+ options?: O
14
+ color?: ButtonProps['color']
15
+ }
16
+
17
+ defineProps<ConfirmModalProps<O>>()
18
+
19
+ const emit = defineEmits<{
20
+ close: [result: R]
21
+ }>()
22
+
23
+ const selectedOption = ref<RadioGroupProps<O>['modelValue']>()
24
+ </script>
25
+
26
+ <template>
27
+ <UModal
28
+ :title
29
+ :close="{ onClick: () => emit('close', false as R) }"
30
+ :dismissible="false"
31
+ :ui="{ header: 'border-b-0!', body: 'pt-0!' }"
32
+ >
33
+ <template #body>
34
+ <div class="flex flex-col gap-4">
35
+ <p v-if="text && typeof text === 'string'">{{ text }}</p>
36
+ <TemplateText v-else-if="text && typeof text === 'object'" :text />
37
+
38
+ <SlotRef :slot-ref />
39
+
40
+ <URadioGroup
41
+ v-model="selectedOption"
42
+ :items="options"
43
+ variant="table"
44
+ :color="color ?? 'error'"
45
+ />
46
+ </div>
47
+ </template>
48
+ <template #footer>
49
+ <div class="flex w-full justify-end gap-2">
50
+ <UButton
51
+ color="neutral"
52
+ variant="soft"
53
+ label="Abbrechen"
54
+ @click="() => emit('close', false as R)"
55
+ />
56
+ <UButton
57
+ :color="color ?? 'error'"
58
+ :label="confirmLabel"
59
+ :disabled="(!!options?.length && selectedOption === undefined) || confirmDisabled?.()"
60
+ @click="() => emit('close', (selectedOption ?? true) as R)"
61
+ />
62
+ </div>
63
+ </template>
64
+ </UModal>
65
+ </template>
@@ -0,0 +1,109 @@
1
+ <script
2
+ setup
3
+ lang="ts"
4
+ generic="
5
+ Schema extends FormSchema,
6
+ QueryData extends NonNullable<FormSourceValues<Schema>>,
7
+ Select extends QueryData = QueryData,
8
+ Id extends string | null = string | null
9
+ "
10
+ >
11
+ import type { FormSchema, FormSourceValues, FormSubmitValues } from '@falcondev-oss/form-core'
12
+ import type { DefaultError, UseQueryOptions } from '@tanstack/vue-query'
13
+ import type { If, IsNever } from 'type-fest'
14
+ import { useForm } from '@falcondev-oss/form-vue'
15
+ import { useQuery } from '@tanstack/vue-query'
16
+ import modalTheme from '#build/ui/modal'
17
+
18
+ const props = defineProps<{
19
+ id: Id
20
+ name: string
21
+ schema: Schema
22
+ getQueryOptions: (
23
+ id: Id,
24
+ ) => UseQueryOptions<If<IsNever<Select>, QueryData, any>, DefaultError, Select>
25
+ newValues: NonNullable<FormSourceValues<Schema>>
26
+ mutate: (values: FormSubmitValues<Schema> & { id: Id }) => Promise<unknown>
27
+ }>()
28
+
29
+ const emit = defineEmits<{
30
+ close: []
31
+ }>()
32
+
33
+ defineSlots<{
34
+ default: (props: { form: ReturnType<typeof useForm<Schema>>; data: typeof data.value }) => any
35
+ }>()
36
+
37
+ const toast = useToast()
38
+ const appConfig = useAppConfig()
39
+
40
+ const { data, isLoading } = useQuery(computed(() => props.getQueryOptions(props.id)))
41
+
42
+ const form = useForm({
43
+ schema: props.schema,
44
+ sourceValues: () => {
45
+ if (isLoading.value) return
46
+
47
+ return data.value ?? props.newValues
48
+ },
49
+ async submit({ values }) {
50
+ await props.mutate({
51
+ ...values,
52
+ id: props.id,
53
+ })
54
+
55
+ emit('close')
56
+ toast.add({
57
+ title: `${props.name} ${props.id ? 'aktualisiert' : 'erstellt'}`,
58
+ color: 'success',
59
+ icon: 'lucide:check',
60
+ })
61
+ },
62
+ })
63
+
64
+ const formActionsTeleportId = useId()
65
+ const closeHandle = ref<(() => void) | null>(null)
66
+
67
+ // const { tryLeave, onLeave } = usePreventLeave()
68
+ // onLeave(() => {
69
+ // console.warn('leave')
70
+ // closeHandle.value?.()
71
+ // })
72
+ </script>
73
+
74
+ <template>
75
+ <UModal :close="false" :title="`${name} ${id ? 'bearbeiten' : 'erstellen'}`" :dismissible="false">
76
+ <template #actions>
77
+ <UButton
78
+ :class="modalTheme.slots.close"
79
+ :icon="appConfig.ui.icons.close"
80
+ color="neutral"
81
+ variant="ghost"
82
+ aria-label="Schließen"
83
+ loading-auto
84
+ @click="
85
+ async () => {
86
+ // if (!(await tryLeave())) return
87
+ closeHandle?.()
88
+ }
89
+ "
90
+ />
91
+ </template>
92
+ <template #body="{ close }">
93
+ <Sync v-model:output="closeHandle" :input="close" />
94
+ <UForm
95
+ class="flex flex-col gap-2"
96
+ :form
97
+ :submit-label="id ? 'Speichern' : 'Erstellen'"
98
+ :actions-teleport-to="`#${formActionsTeleportId}`"
99
+ >
100
+ <slot :form :data />
101
+ </UForm>
102
+ </template>
103
+ <template #footer>
104
+ <div :id="formActionsTeleportId" class="flex w-full justify-end gap-2">
105
+ <UButton color="neutral" variant="soft" label="Abbrechen" @click="() => emit('close')" />
106
+ </div>
107
+ </template>
108
+ </UModal>
109
+ </template>
@@ -0,0 +1,51 @@
1
+ <script lang="ts" setup>
2
+ const props = defineProps<{
3
+ title: string
4
+ icon?: string
5
+ loading?: boolean
6
+ alwaysExpanded?: boolean
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ toggle: [value: boolean, controller: AbortController]
11
+ }>()
12
+
13
+ defineSlots<{
14
+ default: () => any
15
+ }>()
16
+
17
+ const model = defineModel<boolean>({ required: true })
18
+
19
+ const expanded = computed(() => props.alwaysExpanded || model.value)
20
+
21
+ function onToggle(value: boolean) {
22
+ const controller = new AbortController()
23
+ emit('toggle', value, controller)
24
+ if (controller.signal.aborted) return
25
+
26
+ model.value = value
27
+ }
28
+ </script>
29
+
30
+ <template>
31
+ <UCard
32
+ class="m-px"
33
+ variant="subtle"
34
+ :ui="{
35
+ header: `text-sm font-semibold py-2 px-3! ${expanded ? '' : 'border-b-0'}`,
36
+ body: `p-3! ${expanded ? '' : 'hidden'}`,
37
+ }"
38
+ >
39
+ <template #header>
40
+ <div class="flex items-center justify-between gap-8">
41
+ <span class="flex items-center gap-2">
42
+ <UIcon v-if="icon" :name="icon" class="text-muted size-5" />
43
+ {{ title }}
44
+ </span>
45
+ <USwitch :model-value="model" :loading @update:model-value="(value) => onToggle(value)" />
46
+ </div>
47
+ </template>
48
+
49
+ <slot />
50
+ </UCard>
51
+ </template>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from 'vue'
3
+
4
+ defineProps<{
5
+ slotRef?: ComponentPublicInstance
6
+ }>()
7
+
8
+ defineSlots<{
9
+ default: () => any
10
+ }>()
11
+ </script>
12
+
13
+ <template>
14
+ <component :is="slotRef.$slots.default" v-if="slotRef" />
15
+ <!-- eslint-disable-next-line vue/no-lone-template -->
16
+ <template v-else />
17
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import type { templateParts } from '~/utils/display'
3
+
4
+ defineProps<{
5
+ text: ReturnType<typeof templateParts>
6
+ }>()
7
+ </script>
8
+
9
+ <template>
10
+ <p>
11
+ <span v-for="(string, index) in text.strings" :key="index">
12
+ <span>{{ string }}</span>
13
+ <strong v-if="text.values[index]" class="font-semibold">
14
+ <UBadge variant="soft" color="neutral" size="lg" class="h-6! p-1! font-semibold">
15
+ {{ text.values[index] }}
16
+ </UBadge>
17
+ </strong>
18
+ </span>
19
+ </p>
20
+ </template>
@@ -0,0 +1,22 @@
1
+ import type { RadioGroupItem } from '@nuxt/ui'
2
+ import type { ConfirmModalProps } from '~/components/modals/ConfirmModal.vue'
3
+ import { LazyConfirmModal } from '#components'
4
+
5
+ export type ConfirmModalSuccess<O extends RadioGroupItem[]> = 0 extends O['length']
6
+ ? true
7
+ : {
8
+ [K in keyof O]: O[K] extends { value: infer V } ? V : never
9
+ }[number]
10
+
11
+ export type ConfirmModalResult<O extends RadioGroupItem[]> = false | ConfirmModalSuccess<O>
12
+
13
+ export const useConfirm = createGlobalState(() => {
14
+ const overlay = useOverlay()
15
+
16
+ return async <const O extends RadioGroupItem[]>(props: ConfirmModalProps<O>) =>
17
+ overlay
18
+ .create(LazyConfirmModal, {
19
+ destroyOnClose: true,
20
+ })
21
+ .open(props).result as Promise<ConfirmModalResult<O>>
22
+ })
@@ -1,5 +1,5 @@
1
1
  import { onBeforeRouteLeave } from '#app'
2
- import { useConfirm } from './useConfirm'
2
+ import { useConfirm } from './confirm'
3
3
 
4
4
  export function usePreventPageLeave(
5
5
  preventPageLeave: MaybeRefOrGetter<boolean> = true,
@@ -10,6 +10,7 @@ export function usePreventPageLeave(
10
10
  ) {
11
11
  useEventListener('beforeunload', (event) => {
12
12
  if (!toValue(preventPageLeave)) return
13
+ // eslint-disable-next-line ts/no-unsafe-member-access
13
14
  event.returnValue = opts?.leaveDescription
14
15
  return opts?.leaveDescription
15
16
  })
@@ -19,11 +20,11 @@ export function usePreventPageLeave(
19
20
  onBeforeRouteLeave(async (_, __, next) => {
20
21
  if (!toValue(preventPageLeave)) return next()
21
22
 
22
- const allowLeave = await confirm.confirmDestructive({
23
+ const allowLeave = await confirm({
23
24
  title: opts?.leaveTitle || 'Ungespeicherte Änderungen',
24
- description:
25
+ text:
25
26
  opts?.leaveDescription || 'Es gibt ungespeicherte Änderungen. Seite trotzdem verlassen?',
26
- submitLabel: 'Verlassen',
27
+ confirmLabel: 'Verlassen',
27
28
  })
28
29
  if (allowLeave) return next()
29
30
  })
package/app/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module 'vue' {
2
+ interface ComponentCustomProps {
3
+ 'aria-label'?: string
4
+ }
5
+ }
6
+
7
+ export {}
@@ -1,6 +1,20 @@
1
- export function formatEurosAmount(amount: number) {
1
+ export function formatEuros(amount: number) {
2
2
  return new Intl.NumberFormat('de-DE', {
3
3
  style: 'currency',
4
4
  currency: 'EUR',
5
5
  }).format(amount)
6
6
  }
7
+
8
+ export function pluralize(count: number, singular: string, plural: string) {
9
+ return count === 1 ? singular : plural
10
+ }
11
+
12
+ export function templateParts(
13
+ strings: TemplateStringsArray,
14
+ ...values: unknown[]
15
+ ): { strings: string[]; values: string[] } {
16
+ return {
17
+ strings: [...strings],
18
+ values: values.map(String),
19
+ }
20
+ }
@@ -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,3 @@
1
+ export { requestErrorToastLink, trpcPlugin } from './trpc'
2
+ export { vueQueryPlugin } from './vue-query'
3
+ export type { CustomMeta } from './vue-query'
@@ -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
@@ -43,7 +43,19 @@ export default defineNuxtConfig({
43
43
  strict: true,
44
44
  },
45
45
  },
46
+ components: [
47
+ {
48
+ // layer configs resolve `~` against the consuming project, so use absolute paths
49
+ path: path.join(currentDir, './app/components'),
50
+ pathPrefix: false,
51
+ },
52
+ ],
46
53
  imports: {
54
+ dirs: [
55
+ // layer configs resolve `~` against the consuming project, so use absolute paths
56
+ path.join(currentDir, './app/types'),
57
+ path.join(currentDir, './app/utils/*/index.ts'),
58
+ ],
47
59
  imports: [
48
60
  {
49
61
  from: '@falcondev-oss/form-vue',
@@ -60,6 +72,10 @@ export default defineNuxtConfig({
60
72
  },
61
73
  ],
62
74
  },
75
+ sourcemap: {
76
+ client: 'hidden',
77
+ server: true,
78
+ },
63
79
 
64
80
  // runtime
65
81
  experimental: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falcondev-oss/nuxt-layers-base",
3
3
  "type": "module",
4
- "version": "0.38.0",
4
+ "version": "0.39.0",
5
5
  "description": "Nuxt layer with lots of useful helpers and @nuxt/ui components",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,16 +0,0 @@
1
- import { OverlayModalConfirm } from '#components'
2
-
3
- export const useConfirm = createGlobalState(() => {
4
- const overlay = useOverlay()
5
-
6
- return {
7
- confirmDestructive: async (props: {
8
- description: string
9
- submitLabel: string
10
- title: string
11
- }) => {
12
- const modal = overlay.create(OverlayModalConfirm)
13
- return modal.open(props).result as Promise<boolean>
14
- },
15
- }
16
- })
@@ -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
- }
File without changes
File without changes