Skip to content

Commit 16c7cae

Browse files
committed
feat: settings page as one two-pane card, new AFCL PageHeader, VerticalTabs tabsClass, contentClass and tabs-header slot AdminForth/2005
1 parent e3f5e6b commit 16c7cae

7 files changed

Lines changed: 191 additions & 38 deletions

File tree

‎adminforth/design/adoption.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ Which packages follow the [design concepts](./system-concepts.md): tokens instea
2727
| @adminforth/login-captcha | |
2828
| @adminforth/many2many | |
2929
| @adminforth/markdown | |
30-
| @adminforth/mcp | |
31-
| @adminforth/oauth | |
30+
| @adminforth/mcp | ✓ |
31+
| @adminforth/oauth | ✓ |
3232
| @adminforth/open-signup | |
3333
| @adminforth/quick-filters | ✓ |
3434
| @adminforth/rich-editor | |
3535
| @adminforth/text-complete | ✓ |
3636
| @adminforth/two-factors-auth | |
3737
| @adminforth/universal-search | |
3838
| @adminforth/upload | |
39-
| @adminforth/user-sessions | |
39+
| @adminforth/user-sessions | ✓ |
4040
| @adminforth/user-soft-delete | |
4141

4242
## Releasing an adapted plugin

‎adminforth/design/system.html‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2314,6 +2314,33 @@ <h3 class="t-h3">Storage adapter</h3>
23142314
</div>
23152315
</section>
23162316

2317+
<section class="sec">
2318+
<div class="sec-head"><span class="sec-name">&lt;PageHeader&gt;</span><span class="t-sm t-muted">title · description · actions on the right, they wrap under the text on narrow screens</span></div>
2319+
<div class="grid2">
2320+
<div class="tile">
2321+
<div class="tile-stage top">
2322+
<div style="display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px 16px; width: 100%">
2323+
<div style="flex: 1 1 200px; min-width: 0">
2324+
<div style="font-size: 18px; line-height: 28px; font-weight: 600; letter-spacing: -0.01em; color: var(--text)">MCP</div>
2325+
<p class="t-sm t-muted" style="margin: 4px 0 0">Connect an AI agent to dev-demo. It acts on your behalf with your permissions.</p>
2326+
</div>
2327+
<button class="btn btn-primary" type="button"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>Connect agent</button>
2328+
</div>
2329+
</div>
2330+
<div class="tile-cap"><b>With action</b><code>#actions</code></div>
2331+
</div>
2332+
<div class="tile">
2333+
<div class="tile-stage top">
2334+
<div style="width: 100%">
2335+
<div style="font-size: 18px; line-height: 28px; font-weight: 600; letter-spacing: -0.01em; color: var(--text)">Connected accounts</div>
2336+
<p class="t-sm t-muted" style="margin: 4px 0 0">Connect an external account to sign in without a password.</p>
2337+
</div>
2338+
</div>
2339+
<div class="tile-cap"><b>Text only</b><code>title · description</code></div>
2340+
</div>
2341+
</div>
2342+
</section>
2343+
23172344
<section class="sec">
23182345
<div class="sec-head"><span class="sec-name">&lt;VerticalTabs&gt;</span><span class="t-sm t-muted">gray pill marks the active tab, same as the sidebar</span></div>
23192346
<div class="grid3">
@@ -2344,6 +2371,23 @@ <h3 class="t-h3">Storage adapter</h3>
23442371
</div>
23452372
</div>
23462373
<div class="grid2">
2374+
<div class="tile">
2375+
<div class="tile-stage top soft">
2376+
<div class="card" style="display: flex; width: 100%; min-height: 180px; overflow: hidden; border-radius: 12px">
2377+
<div style="width: 180px; flex-shrink: 0; padding: 14px 10px; background: var(--bg); border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px">
2378+
<div style="padding: 0 10px 10px"><div class="t-h3">Settings</div><div class="t-muted" style="font-size: 12px; line-height: 16px">Signed in as adminforth</div></div>
2379+
<button class="vtab" type="button" role="tab"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>Connected accounts</button>
2380+
<button class="vtab is-on" type="button" role="tab"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8"></path><path d="M10 19v-3.96 3.15"></path><path d="M7 19h5"></path><rect width="6" height="10" x="16" y="12" rx="2"></rect></svg>Active sessions</button>
2381+
<button class="vtab" type="button" role="tab"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>MCP</button>
2382+
</div>
2383+
<div style="flex: 1; min-width: 0; padding: 16px 18px; display: flex; flex-direction: column; gap: 4px">
2384+
<div style="font-size: 15px; line-height: 22px; font-weight: 600; color: var(--text)">Active sessions</div>
2385+
<p class="t-sm t-muted" style="margin: 0">Every browser you signed in from has its own session.</p>
2386+
</div>
2387+
</div>
2388+
</div>
2389+
<div class="tile-cap"><b>In a card (settings page)</b><code>tabsClass · contentClass · #tabs-header</code></div>
2390+
</div>
23472391
</div>
23482392
</section>
23492393

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<template>
2+
<div class="afcl-page-header flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
3+
<!-- the text shrinks first, so actions stay on the right and wrap under it only on narrow screens -->
4+
<div class="min-w-0 max-w-2xl flex-1 basis-64">
5+
<h2 class="afcl-page-header-title text-lg font-semibold tracking-tight text-lightCardTitle dark:text-darkCardTitle">{{ title }}</h2>
6+
<p v-if="description" class="afcl-page-header-description mt-1 text-sm text-lightCardDescription dark:text-darkCardDescription">{{ description }}</p>
7+
</div>
8+
<div v-if="$slots.actions" class="afcl-page-header-actions flex flex-wrap items-center gap-2">
9+
<slot name="actions"></slot>
10+
</div>
11+
</div>
12+
</template>
13+
14+
<script setup lang="ts">
15+
defineProps<{
16+
title: string;
17+
description?: string;
18+
}>();
19+
</script>
Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,45 @@
11
<template>
22
<div class="md:flex">
3-
<ul class="flex-column space-y-0.5 text-sm text-lightVerticalTabsText dark:text-darkVerticalTabsText md:me-4 mb-4 md:mb-0 md:mr-0 mr-6 md:min-w-[11rem]">
4-
<li v-for="tab in tabs" :key="`${tab}-tab-controll`">
5-
<a
6-
v-if="!props.hideTabesWhenSingle || tabs.length > 1"
7-
href="#"
8-
@click="setActiveTab(tab)"
9-
class="inline-flex items-center gap-2 px-3 py-2 rounded-default w-full transition-colors"
10-
:class="tab === activeTab ? 'font-medium text-lightVerticalTabsTextActive bg-lightVerticalTabsBackgroundActive active dark:bg-darkVerticalTabsBackgroundActive dark:text-darkVerticalTabsTextActive' : 'text-lightVerticalTabsText dark:text-darkVerticalTabsText hover:text-lightVerticalTabsTextHover bg-lightVerticalTabsBackground hover:bg-lightVerticalTabsBackgroundHover dark:bg-darkVerticalTabsBackground dark:hover:bg-darkVerticalTabsBackgroundHover dark:hover:darkVerticalTabsTextHover'"
11-
aria-current="page"
12-
>
13-
<slot :name="`tab:${tab}`"></slot>
14-
</a>
15-
</li>
16-
</ul>
17-
<div class="md:ps-6 md:border-s md:border-lightFormBorder dark:md:border-darkFormBorder text-sm text-lightVerticalTabsSlotText dark:text-darkVerticalTabsSlotText w-full ">
3+
<div :class="mergeClasses('md:me-4 mb-4 md:mb-0 mr-6 md:mr-0 md:min-w-[11rem]', tabsClass)">
4+
<slot name="tabs-header"></slot>
5+
<ul class="space-y-0.5 text-sm">
6+
<li v-for="tab in tabs" :key="`${tab}-tab-controll`">
7+
<a
8+
v-if="!props.hideTabesWhenSingle || tabs.length > 1"
9+
href="#"
10+
@click.prevent="setActiveTab(tab)"
11+
class="flex items-center gap-2 w-full min-h-[34px] px-2.5 py-1.5 rounded-default transition-colors [&>svg]:shrink-0"
12+
:class="tab === activeTab
13+
? 'font-medium text-lightVerticalTabsTextActive bg-lightVerticalTabsBackgroundActive dark:text-darkVerticalTabsTextActive dark:bg-darkVerticalTabsBackgroundActive'
14+
: 'text-lightVerticalTabsText hover:text-lightVerticalTabsTextHover hover:bg-lightVerticalTabsBackgroundHover dark:text-darkVerticalTabsText dark:hover:text-darkVerticalTabsTextHover dark:hover:bg-darkVerticalTabsBackgroundHover [&>svg]:text-lightInputIconColor dark:[&>svg]:text-darkInputIconColor'"
15+
:aria-current="tab === activeTab ? 'page' : undefined"
16+
>
17+
<slot :name="`tab:${tab}`"></slot>
18+
</a>
19+
</li>
20+
</ul>
21+
</div>
22+
<div :class="mergeClasses('w-full md:ps-6 md:border-s md:border-lightFormBorder dark:md:border-darkFormBorder text-sm text-lightVerticalTabsSlotText dark:text-darkVerticalTabsSlotText', contentClass)">
1823
<slot :name="activeTab"></slot>
1924
</div>
2025
</div>
2126
</template>
2227

2328
<script setup lang="ts">
2429
import { onMounted, useSlots, ref, type Ref } from 'vue';
30+
import { type ClassNameValue } from 'tailwind-merge';
31+
import { mergeClasses } from './classMerge';
32+
2533
const tabs : Ref<string[]> = ref([]);
2634
const activeTab = ref('');
27-
const props = defineProps({
28-
activeTab: {
29-
type: String
30-
},
31-
hideTabesWhenSingle: {
32-
type: Boolean,
33-
default: false
34-
}
35+
const props = withDefaults(defineProps<{
36+
activeTab?: string;
37+
hideTabesWhenSingle?: boolean;
38+
// override the default classes of the tab list column and of the content, e.g. paddings or the divider
39+
tabsClass?: ClassNameValue;
40+
contentClass?: ClassNameValue;
41+
}>(), {
42+
hideTabesWhenSingle: false,
3543
});
3644
const emites = defineEmits([
3745
'update:activeTab',
@@ -60,4 +68,4 @@ import { onMounted, useSlots, ref, type Ref } from 'vue';
6068
emites('update:activeTab', tab);
6169
}
6270
}
63-
</script>
71+
</script>

‎adminforth/spa/src/afcl/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { default as DatePicker } from './DatePicker.vue';
2626
export { default as Textarea } from './Textarea.vue';
2727
export { default as ButtonGroup } from './ButtonGroup.vue';
2828
export { default as Card } from './Card.vue';
29+
export { default as PageHeader } from './PageHeader.vue';
2930
export { default as Modal } from './Modal.vue';
3031
export { default as DropdownMenu } from './DropdownMenu.vue';
3132
export { default as DropdownMenuItem } from './DropdownMenuItem.vue';

‎adminforth/spa/src/views/SettingsView.vue‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
<template>
2-
<div class="mt-20 h-full w-full" :class="{ 'hidden': initialTabSet === false }">
3-
<div v-if="!coreStore?.config?.settingPages?.filter(page => page.isVisible) || coreStore?.config?.settingPages.length === 0">
2+
<div class="h-full w-full px-4 py-6 md:px-10" :class="{ 'hidden': initialTabSet === false }">
3+
<div v-if="!visiblePages.length">
44
<p>{{ $t('No setting pages configured or still loading...') }}</p>
55
</div>
6-
<VerticalTabs v-else ref="VerticalTabsRef" hideTabesWhenSingle v-model:active-tab="activeTab" @update:active-tab="setURL({slug: $event, pageLabel: ''})">
7-
<template v-for="(c,i) in coreStore?.config?.settingPages.filter(page => page.isVisible)" :key="`tab:${settingPageSlotName(c,i)}`" v-slot:['tab:'+c.slug]>
8-
<div class="flex items-center justify-center whitespace-nowrap w-full px-4 gap-2" @click="setURL(c)">
9-
<component v-if="c.icon" :is="getIcon(c.icon)" class="w-5 h-5 group-hover:text-lightSidebarIconsHover transition duration-75 dark:group-hover:text-darkSidebarIconsHover dark:text-darkSidebarIcons" ></component>
10-
{{ c.pageLabel }}
6+
<!-- settings are one card: tabs on a tinted pane at the left, the page of the active tab at the right -->
7+
<VerticalTabs
8+
v-else
9+
ref="VerticalTabsRef"
10+
v-model:active-tab="activeTab"
11+
class="md:min-h-[40rem] overflow-hidden rounded-xl border border-lightCardBorder bg-lightCardBackground dark:border-darkCardBorder dark:bg-darkCardBackground"
12+
tabsClass="m-0 md:m-0 md:w-64 md:shrink-0 px-3 py-4 md:py-5 border-b md:border-b-0 md:border-e border-lightCardBorder dark:border-darkCardBorder bg-lightVerticalTabsBackground dark:bg-darkVerticalTabsBackground"
13+
contentClass="min-w-0 p-5 md:py-6 md:ps-7 md:pe-7 md:border-s-0"
14+
@update:active-tab="setURL({slug: $event, pageLabel: ''})"
15+
>
16+
<template #tabs-header>
17+
<div class="px-2.5 pb-4">
18+
<h1 class="text-[15px] font-semibold leading-snug text-lightCardTitle dark:text-darkCardTitle">{{ $t('Settings') }}</h1>
19+
<p class="mt-0.5 truncate text-xs text-lightCardDescription dark:text-darkCardDescription">{{ $t('Signed in as {username}', { username: coreStore.username }) }}</p>
1120
</div>
1221
</template>
1322

14-
<template v-for="(c,i) in coreStore?.config?.settingPages.filter(page => page.isVisible)" :key="`${settingPageSlotName(c,i)}-content`" v-slot:[c.slug]>
15-
<component
16-
:is="getCustomComponent({file: c.component || ''})"
23+
<template v-for="page in visiblePages" v-slot:['tab:'+page.slug]>
24+
<component v-if="page.icon" :is="getIcon(page.icon)" class="w-4 h-4"></component>
25+
<span class="min-w-0 truncate">{{ page.pageLabel }}</span>
26+
</template>
27+
28+
<template v-for="page in visiblePages" v-slot:[page.slug]>
29+
<component
30+
:is="getCustomComponent({file: page.component || ''})"
1731
:resource="coreStore.resource"
1832
:adminUser="coreStore.adminUser"
1933
/>
@@ -23,7 +37,7 @@
2337
</template>
2438

2539
<script setup lang="ts">
26-
import { ref, onMounted, watch } from 'vue';
40+
import { ref, computed, onMounted, watch } from 'vue';
2741
import { useRouter } from 'vue-router';
2842
import { useCoreStore } from '@/stores/core';
2943
import { getCustomComponent, getIcon } from '@/utils';
@@ -39,6 +53,7 @@ const loginRedirectCheckIsReady = ref(false);
3953
const VerticalTabsRef = ref();
4054
const activeTab = ref('');
4155
const initialTabSet = ref(false);
56+
const visiblePages = computed(() => coreStore.config?.settingPages?.filter(page => page.isVisible) ?? []);
4257
4358
watch(() => route?.params?.page, (val) => {
4459
handleURLChange(val as string | null);

‎documentation/docs/tutorial/03-Customization/15-afcl.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,45 @@ import { IconCircleUser, IconLayoutGrid } from '@iconify-prerendered/vue-lucide'
485485

486486
</AfclPreview>
487487

488+
### In a card
489+
490+
`tabsClass` and `contentClass` add classes to the column of tabs and to the content, they override the default ones (the margins of the column, the padding and the divider of the content). The `tabs-header` slot goes above the tabs. This is how the settings page puts its tabs on a tinted pane of a card:
491+
492+
<AfclPreview id="vertical-tabs-card">
493+
494+
```js
495+
import { VerticalTabs } from '@/afcl'
496+
import { IconLink, IconMonitorSmartphone } from '@iconify-prerendered/vue-lucide'
497+
```
498+
499+
```html
500+
<VerticalTabs
501+
class="overflow-hidden rounded-xl border border-lightCardBorder bg-lightCardBackground dark:border-darkCardBorder dark:bg-darkCardBackground"
502+
tabsClass="m-0 md:m-0 md:w-64 px-3 py-4 border-b md:border-b-0 md:border-e border-lightCardBorder dark:border-darkCardBorder bg-lightVerticalTabsBackground dark:bg-darkVerticalTabsBackground"
503+
contentClass="p-5 md:ps-6 md:border-s-0"
504+
>
505+
<template #tabs-header>
506+
<p class="px-2.5 pb-3 font-semibold text-lightCardTitle dark:text-darkCardTitle">Settings</p>
507+
</template>
508+
<template #tab:accounts>
509+
<IconLink class="w-4 h-4" />
510+
Connected accounts
511+
</template>
512+
<template #tab:sessions>
513+
<IconMonitorSmartphone class="w-4 h-4" />
514+
Active sessions
515+
</template>
516+
<template #accounts>
517+
Connected accounts content
518+
</template>
519+
<template #sessions>
520+
Active sessions content
521+
</template>
522+
</VerticalTabs>
523+
```
524+
525+
</AfclPreview>
526+
488527
## Checkbox
489528

490529
<AfclPreview id="checkbox">
@@ -557,6 +596,33 @@ import { Card } from '@/afcl'
557596

558597
</AfclPreview>
559598

599+
## Page Header
600+
601+
Title of a page or of a section with a description and actions on the right (`afcl-page-header`). The actions wrap under the text on narrow screens. Settings pages of plugins start with it.
602+
603+
<AfclPreview id="page-header">
604+
605+
```js
606+
import { Button, PageHeader } from '@/afcl'
607+
import { IconPlus } from '@iconify-prerendered/vue-lucide'
608+
```
609+
610+
```html
611+
<PageHeader
612+
title="MCP"
613+
description="Connect an AI agent to your admin panel. It acts on your behalf with your permissions."
614+
>
615+
<template #actions>
616+
<Button>
617+
<IconPlus class="w-4 h-4" />
618+
Connect agent
619+
</Button>
620+
</template>
621+
</PageHeader>
622+
```
623+
624+
</AfclPreview>
625+
560626
## Toggle
561627

562628
<AfclPreview id="toggle">

0 commit comments

Comments
 (0)