feat(10-03): tabs, toggles, relation fields, form lifecycle and settings pages
- FormTabs groups fields by tab (untabbed fields in the default tab) as a
segmented tablist; a tab holding invalid fields after a 422 shows a
count badge and the form switches to the first invalid field
- switch, checkbox and relation join the renderer registry: toggle cards
(role=switch, 20px checkbox) keep a numeric value numeric; relation
fields are read-only labels from meta.labels, a searchable single
select over fields/{field}/options with emptyOption first, or ordered
removable chips with an appending search (300 ms debounce, 20 per
page, more on scroll or the more action)
- FormView gets the back button, record title and update subtitle, a
sticky footer (Usuń with deleteConfirm then DELETE, Anuluj, Zapisz i
zamknij to the mapped redirectClose, Zapisz), and asks before leaving a
dirty form on any route change plus a beforeunload guard
- Settings: the rail pins Ustawienia to the bottom when /settings is
non-empty; /settings lists pages by category; /settings/:code renders
the settings schema through FormGrid and the registry, PUTs the values
and maps a 422 like the record form
- New backend::lang keys (form.more_options, tab_default, discard,
settings.back); form and settings smoke tests; boardwalk/dist rebuilt
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||
@@ -8,20 +9,30 @@ import { message, t } from '../app/i18n'
|
||||
import { mapWinterUrl } from '../app/winterUrl'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import FormTabs from '../components/form/FormTabs.vue'
|
||||
import {
|
||||
DEFAULT_TAB,
|
||||
contextAllows,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
panelDomId,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
type FormMode,
|
||||
type TabItem,
|
||||
} from '../components/form/formState'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
|
||||
import { useConfirm } from '../components/ui/confirm'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
|
||||
// keyed by route path, so one instance serves one record or one create.
|
||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
|
||||
// 4). The view is keyed by route path, so one instance serves one record or
|
||||
// one create. Tabs group fields by `tab`; a sticky footer holds delete,
|
||||
// cancel, save-and-close and save; a dirty form confirms before leaving.
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -43,12 +54,53 @@ const labels = ref<RecordMeta['labels']>({})
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const saving = ref(false)
|
||||
const busy = ref(false)
|
||||
const saved = ref('')
|
||||
const activeTab = ref(DEFAULT_TAB)
|
||||
const confirm = useConfirm()
|
||||
// Set right before a navigation the form itself starts after a save or a
|
||||
// delete, so the dirty guard does not ask.
|
||||
let leaving = false
|
||||
|
||||
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
|
||||
const tabs = computed<TabItem[]>(() => {
|
||||
if (!fields.value.some((field) => field.tab)) {
|
||||
return []
|
||||
}
|
||||
const out: TabItem[] = []
|
||||
for (const field of fields.value) {
|
||||
const key = tabOf(field)
|
||||
let tab = out.find((item) => item.key === key)
|
||||
if (!tab) {
|
||||
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
|
||||
if (key === DEFAULT_TAB) {
|
||||
out.unshift(tab)
|
||||
} else {
|
||||
out.push(tab)
|
||||
}
|
||||
}
|
||||
if (errors.value[field.name]) {
|
||||
tab.errors += 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const activeIndex = computed(() => Math.max(
|
||||
tabs.value.findIndex((tab) => tab.key === activeTab.value),
|
||||
0,
|
||||
))
|
||||
|
||||
const panelFields = computed(() => {
|
||||
if (tabs.value.length === 0) {
|
||||
return fields.value
|
||||
}
|
||||
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
|
||||
return fields.value.filter((field) => tabOf(field) === key)
|
||||
})
|
||||
|
||||
/** The record's display name: the first text field's value, in schema order. */
|
||||
const recordName = computed(() => {
|
||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
||||
@@ -68,7 +120,7 @@ const subtitle = computed(() =>
|
||||
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
|
||||
)
|
||||
|
||||
const dirty = computed(() => snapshot(editablePayload(fields.value, values.value)) !== saved.value)
|
||||
const dirty = computed(() => !loading.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value)
|
||||
|
||||
function adopt(record: RecordEnvelope | undefined): void {
|
||||
if (record) {
|
||||
@@ -94,6 +146,7 @@ async function load(): Promise<void> {
|
||||
values.value = initialValues(fields.value)
|
||||
}
|
||||
adopt(recordResult?.data)
|
||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -106,13 +159,19 @@ function update(name: string, value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 422: messages under fields, the first invalid field (schema order) focused. */
|
||||
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||
errors.value = fieldErrors(error?.details)
|
||||
await nextTick()
|
||||
const first = fields.value.find((field) => errors.value[field.name])
|
||||
if (first) {
|
||||
focusField(ID_PREFIX, first.name)
|
||||
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||
if (!first) {
|
||||
return
|
||||
}
|
||||
if (tabs.value.length > 0) {
|
||||
activeTab.value = tabOf(first)
|
||||
}
|
||||
await nextTick()
|
||||
focusField(ID_PREFIX, first.name)
|
||||
}
|
||||
|
||||
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
|
||||
@@ -123,10 +182,10 @@ function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string
|
||||
|
||||
/** Saves the record; returns the saved envelope, or null after an error. */
|
||||
async function save(): Promise<RecordEnvelope | null> {
|
||||
if (saving.value || !schema.value) {
|
||||
if (busy.value || !schema.value) {
|
||||
return null
|
||||
}
|
||||
saving.value = true
|
||||
busy.value = true
|
||||
try {
|
||||
const body = editablePayload(fields.value, values.value)
|
||||
const result =
|
||||
@@ -149,29 +208,110 @@ async function save(): Promise<RecordEnvelope | null> {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
return null
|
||||
} finally {
|
||||
saving.value = false
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function go(to: string): Promise<void> {
|
||||
leaving = true
|
||||
try {
|
||||
await router.push(to)
|
||||
} finally {
|
||||
leaving = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Zapisz: stays on the form; after a create it follows create.redirect. */
|
||||
async function onSave(): Promise<void> {
|
||||
const record = await save()
|
||||
if (record && mode === 'create') {
|
||||
await router.push(redirectTarget('redirect', record.data.id))
|
||||
await go(redirectTarget('redirect', record.data.id))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ dirty })
|
||||
/** Zapisz i zamknij: saves, then follows redirectClose (the toast follows). */
|
||||
async function onSaveAndClose(): Promise<void> {
|
||||
const record = await save()
|
||||
if (record) {
|
||||
await go(redirectTarget('redirectClose', record.data.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (recordId === null || busy.value || !schema.value) {
|
||||
return
|
||||
}
|
||||
const ok = await confirm.ask({
|
||||
message: message(schema.value.messages.deleteConfirm, undefined, { name: recordName.value }),
|
||||
confirmLabel: t('backend::lang.form.delete'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.DELETE('/{vendor}/{plugin}/{controller}/{id}', {
|
||||
params: { path: { ...path, id: recordId } },
|
||||
})
|
||||
if (result.data) {
|
||||
showToast(message(schema.value.messages.deleted, undefined, { name: recordName.value }))
|
||||
await go(listPath)
|
||||
return
|
||||
}
|
||||
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaving a dirty form (Anuluj, back, any route change) asks first. */
|
||||
async function guard(): Promise<boolean> {
|
||||
if (leaving || !dirty.value) {
|
||||
return true
|
||||
}
|
||||
return confirm.ask({
|
||||
message: t('backend::lang.form.unsaved_confirm'),
|
||||
confirmLabel: t('backend::lang.form.discard'),
|
||||
danger: true,
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(guard)
|
||||
onBeforeRouteUpdate(guard)
|
||||
|
||||
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
if (dirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
|
||||
<header class="flex items-center gap-4">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<header class="flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-action="back"
|
||||
:to="listPath"
|
||||
:aria-label="t('backend::lang.form.return_to_list')"
|
||||
class="size-10! px-0!"
|
||||
>
|
||||
<ArrowLeft :size="18" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
|
||||
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
|
||||
</header>
|
||||
|
||||
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
@@ -181,12 +321,15 @@ void load()
|
||||
<template v-else-if="schema">
|
||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||
<form
|
||||
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
|
||||
:role="tabs.length > 0 ? 'tabpanel' : undefined"
|
||||
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
|
||||
class="rounded-card border border-border bg-surface p-7 shadow-card"
|
||||
novalidate
|
||||
@submit.prevent="onSave"
|
||||
>
|
||||
<FormGrid
|
||||
:fields="fields"
|
||||
:fields="panelFields"
|
||||
:values="values"
|
||||
:errors="errors"
|
||||
:labels="labels"
|
||||
@@ -198,12 +341,36 @@ void load()
|
||||
</template>
|
||||
|
||||
<footer
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
|
||||
>
|
||||
<Button variant="ghost" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
|
||||
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
<Button
|
||||
v-if="mode === 'update'"
|
||||
variant="danger"
|
||||
data-action="delete"
|
||||
:icon="Trash2"
|
||||
:disabled="busy || loading || !schema"
|
||||
@click="onDelete"
|
||||
>
|
||||
{{ t('backend::lang.form.delete') }}
|
||||
</Button>
|
||||
<div class="ml-auto flex items-center gap-2.5">
|
||||
<Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema" @click="onSaveAndClose">
|
||||
{{ t('backend::lang.form.save_and_close') }}
|
||||
</Button>
|
||||
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave">
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirm.request.value !== null"
|
||||
:message="confirm.request.value?.message ?? ''"
|
||||
:confirm-label="confirm.request.value?.confirmLabel"
|
||||
:danger="confirm.request.value?.danger"
|
||||
@confirm="confirm.confirm"
|
||||
@cancel="confirm.cancel"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from '../app/i18n'
|
||||
import { safeRedirect } from '../app/router'
|
||||
import { login, me } from '../state/useAuth'
|
||||
import { loadNavigation } from '../state/useNavigation'
|
||||
import { loadSettings } from '../state/useSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -28,7 +29,7 @@ async function submit(): Promise<void> {
|
||||
return
|
||||
}
|
||||
password.value = ''
|
||||
await loadNavigation()
|
||||
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
|
||||
await router.replace(safeRedirect(route.query.redirect) ?? '/')
|
||||
} finally {
|
||||
busy.value = false
|
||||
|
||||
134
admin/src/views/SettingsFormView.vue
Normal file
134
admin/src/views/SettingsFormView.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminRecord, FormView } from '../api/types'
|
||||
import { t } from '../app/i18n'
|
||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||
import FormGrid from '../components/form/FormGrid.vue'
|
||||
import { contextAllows, editablePayload, fieldErrors, focusField } from '../components/form/formState'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import { settingsEntry } from '../state/useSettings'
|
||||
import { showToast } from '../state/useToasts'
|
||||
|
||||
// One settings page (D-21) through the same FormGrid and field registry as
|
||||
// records: GET the schema and the values, PUT the values keyed by field name,
|
||||
// map a 422 the same way. Settings forms have no create step.
|
||||
const route = useRoute()
|
||||
const code = String(route.params.code ?? '')
|
||||
const ID_PREFIX = 'setting'
|
||||
|
||||
const schema = ref<FormView | null>(null)
|
||||
const values = ref<AdminRecord>({})
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const busy = ref(false)
|
||||
|
||||
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, 'update')))
|
||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
||||
const title = computed(() => settingsEntry(code)?.label || schema.value?.name || code)
|
||||
const description = computed(() => settingsEntry(code)?.description ?? '')
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const params = { path: { code } }
|
||||
const [schemaResult, valuesResult] = await Promise.all([
|
||||
api.GET('/settings/{code}/schema', { params }),
|
||||
api.GET('/settings/{code}', { params }),
|
||||
])
|
||||
schema.value = schemaResult.data?.data ?? null
|
||||
values.value = { ...(valuesResult.data?.data.data ?? {}) }
|
||||
failed.value = !schemaResult.data || !valuesResult.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function update(name: string, value: unknown): void {
|
||||
values.value = { ...values.value, [name]: value }
|
||||
if (errors.value[name]) {
|
||||
const next = { ...errors.value }
|
||||
delete next[name]
|
||||
errors.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (busy.value || !schema.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.PUT('/settings/{code}', {
|
||||
params: { path: { code } },
|
||||
body: editablePayload(fields.value, values.value),
|
||||
})
|
||||
if (result.data) {
|
||||
errors.value = {}
|
||||
values.value = { ...result.data.data.data }
|
||||
showToast(t('backend::lang.settings.saved'))
|
||||
return
|
||||
}
|
||||
if (result.response.status === 422) {
|
||||
errors.value = fieldErrors(result.error?.error.details)
|
||||
const first = fields.value.find((field) => errors.value[field.name])
|
||||
await nextTick()
|
||||
if (first) {
|
||||
focusField(ID_PREFIX, first.name)
|
||||
}
|
||||
return
|
||||
}
|
||||
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
|
||||
<header class="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-action="back"
|
||||
to="/settings"
|
||||
:aria-label="t('backend::lang.settings.back')"
|
||||
class="size-10! px-0!"
|
||||
>
|
||||
<ArrowLeft :size="18" aria-hidden="true" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
|
||||
<p v-if="description" class="text-muted">{{ description }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
||||
{{ t('backend::lang.form.load_failed') }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="schema">
|
||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||
<form class="rounded-card border border-border bg-surface p-7 shadow-card" novalidate @submit.prevent="onSave">
|
||||
<FormGrid
|
||||
:fields="fields"
|
||||
:values="values"
|
||||
:errors="errors"
|
||||
:source="null"
|
||||
:id-prefix="ID_PREFIX"
|
||||
@update="update"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<footer class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5">
|
||||
<Button variant="ghost" to="/settings">{{ t('backend::lang.form.cancel') }}</Button>
|
||||
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave">
|
||||
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
45
admin/src/views/SettingsIndexView.vue
Normal file
45
admin/src/views/SettingsIndexView.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { t } from '../app/i18n'
|
||||
import { iconFor } from '../app/icons'
|
||||
import { settingsGroups } from '../state/useSettings'
|
||||
|
||||
// Settings index (D-21): every permitted settings page, grouped by category,
|
||||
// with its icon, label and description. The server filters the list.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-5">
|
||||
<header>
|
||||
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ t('backend::lang.settings.title') }}</h1>
|
||||
</header>
|
||||
<p v-if="settingsGroups.length === 0" class="text-muted">{{ t('backend::lang.settings.empty') }}</p>
|
||||
<section
|
||||
v-for="group in settingsGroups"
|
||||
:key="group.category"
|
||||
data-settings-group
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<h2 v-if="group.category" class="text-[13px] font-semibold tracking-[0.02em] text-muted uppercase">
|
||||
{{ group.category }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-3 min-[900px]:grid-cols-2">
|
||||
<RouterLink
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.code"
|
||||
:to="`/settings/${encodeURIComponent(entry.code)}`"
|
||||
:data-settings="entry.code"
|
||||
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
|
||||
>
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
|
||||
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="font-semibold">{{ entry.label }}</span>
|
||||
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user