feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
42
admin/src/components/ui/Button.vue
Normal file
42
admin/src/components/ui/Button.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
// One button for the whole admin (design tokens): primary, outline, ghost and
|
||||
// danger (outline). With `to` it renders a router link styled the same way.
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'outline' | 'ghost' | 'danger'
|
||||
size?: 'md' | 'sm'
|
||||
type?: 'button' | 'submit'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
to?: RouteLocationRaw
|
||||
}>(),
|
||||
{ variant: 'outline', size: 'md', type: 'button', disabled: false, icon: undefined, to: undefined },
|
||||
)
|
||||
|
||||
const variants: Record<NonNullable<typeof props.variant>, string> = {
|
||||
primary: 'bg-primary text-on-primary hover:opacity-90',
|
||||
outline: 'border border-border-strong bg-surface text-text hover:bg-hover',
|
||||
ghost: 'text-muted hover:bg-hover hover:text-text',
|
||||
danger: 'border border-danger bg-surface text-danger hover:bg-danger-soft',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 rounded-control px-4 font-semibold whitespace-nowrap no-underline transition-colors duration-150 ease-out disabled:cursor-not-allowed disabled:opacity-60',
|
||||
props.size === 'sm' ? 'h-[38px]' : 'h-button',
|
||||
variants[props.variant],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to !== undefined" :to="to" :class="classes">
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<button v-else :type="type" :disabled="disabled" :class="classes">
|
||||
<component :is="icon" v-if="icon" :size="16" aria-hidden="true" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
36
admin/src/components/ui/Toast.vue
Normal file
36
admin/src/components/ui/Toast.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, CircleAlert, X } from '@lucide/vue'
|
||||
import { t } from '../../app/i18n'
|
||||
import { dismissToast, toasts } from '../../state/useToasts'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom centre, 88px up so it clears the sticky form footer. -->
|
||||
<div class="pointer-events-none fixed inset-x-0 bottom-[88px] z-50 flex flex-col items-center gap-2 px-4">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:role="toast.tone === 'danger' ? 'alert' : 'status'"
|
||||
:data-tone="toast.tone"
|
||||
class="pointer-events-auto flex max-w-[560px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
|
||||
>
|
||||
<span
|
||||
:class="toast.tone === 'danger' ? 'bg-[#f58a8a] text-[#1d2740]' : 'bg-accent text-on-accent'"
|
||||
class="flex size-[26px] shrink-0 items-center justify-center rounded-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CircleAlert v-if="toast.tone === 'danger'" :size="16" />
|
||||
<Check v-else :size="16" />
|
||||
</span>
|
||||
<span class="font-medium">{{ toast.text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-8 items-center justify-center rounded-pager text-[#c3cbda] hover:bg-side-hover hover:text-white"
|
||||
@click="dismissToast(toast.id)"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user