Skip to content

Commit e90afad

Browse files
committed
feat(docs): add fumadocs-style sidebar layout
1 parent 59ffd8b commit e90afad

10 files changed

Lines changed: 827 additions & 53 deletions

File tree

docs/app/app.config.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,36 @@ export default defineAppConfig({
44
primary: 'stone',
55
neutral: 'stone',
66
},
7+
prose: {
8+
a: {
9+
base: 'font-medium underline underline-offset-4 text-default hover:text-primary transition-colors',
10+
},
11+
},
12+
page: {
13+
slots: {
14+
root: 'flex flex-col lg:flex-row lg:gap-8 px-4 sm:px-6 lg:px-8 xl:px-12',
15+
left: 'hidden',
16+
center: 'flex-1 min-w-0 max-w-[var(--fd-content-width,860px)] mx-auto',
17+
right: 'hidden xl:block w-[var(--fd-toc-width,268px)] shrink-0',
18+
},
19+
},
20+
pageBody: {
21+
base: 'mt-8 pb-24 space-y-12',
22+
},
23+
pageAside: {
24+
slots: {
25+
root: 'sticky top-[var(--ui-header-height)] max-h-[calc(100vh-var(--ui-header-height))] overflow-y-auto py-8',
26+
},
27+
},
28+
contentToc: {
29+
slots: {
30+
root: '',
31+
container: '',
32+
header: 'text-sm font-semibold mb-3 text-[var(--ui-text-highlighted)]',
33+
links: 'space-y-1',
34+
link: 'text-sm block py-1.5 text-[var(--ui-text-muted)] hover:text-[var(--ui-text)] transition-colors',
35+
linkActive: 'text-[var(--ui-text-highlighted)]',
36+
},
37+
},
738
},
839
})

docs/app/assets/css/main.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
:root {
77
--ui-radius: 0.125rem;
88
--ui-bg: var(--color-white);
9+
--fd-sidebar-width: 268px;
10+
--fd-toc-width: 268px;
11+
--fd-content-width: 860px;
12+
}
13+
14+
@media (min-width: 1280px) {
15+
:root {
16+
--fd-sidebar-width: 286px;
17+
--fd-toc-width: 286px;
18+
}
919
}
1020

1121
.dark {

docs/app/components/app/AppHeader.vue

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<script setup lang="ts">
22
const appConfig = useAppConfig()
33
const site = useSiteConfig()
4+
const route = useRoute()
5+
const { sidebarOpen, toggle: toggleSidebar } = useDocusSidebar()
6+
7+
const isDocsPage = computed(() => route.path.startsWith('/getting-started') || route.path.startsWith('/core-concepts') || route.path.startsWith('/guides') || route.path.startsWith('/api') || route.path.startsWith('/troubleshooting') || route.path.startsWith('/better-auth'))
48
59
const navLinks = [
610
{ name: 'docs', path: '/getting-started/quickstart' },
@@ -13,7 +17,7 @@ const navLinks = [
1317
<template>
1418
<UHeader :ui="{ container: 'max-w-full !px-0 h-14', root: 'border-b border-[var(--ui-border)] h-max', left: 'gap-0 h-full', right: 'gap-0 h-full' }" to="/" :title="appConfig.header?.title || site.name">
1519
<template #title>
16-
<div class="flex items-center gap-3 px-5 border-r border-[var(--ui-border)] h-full">
20+
<div class="header-logo">
1721
<!-- NuxtHub -->
1822
<div class="flex items-center gap-1.5">
1923
<svg width="48" height="32" viewBox="0 0 48 32" fill="none" class="h-4 w-auto" xmlns="http://www.w3.org/2000/svg">
@@ -61,6 +65,9 @@ const navLinks = [
6165
</li>
6266
</nav>
6367

68+
<!-- Docs sidebar toggle (mobile) -->
69+
<UButton v-if="isDocsPage" color="neutral" variant="ghost" :icon="sidebarOpen ? 'i-lucide-x' : 'i-lucide-panel-left'" class="lg:hidden border-l border-[var(--ui-border)] h-full aspect-square rounded-none" @click="toggleSidebar" />
70+
6471
<UContentSearchButton class="lg:hidden" />
6572

6673
<ClientOnly>
@@ -97,3 +104,22 @@ const navLinks = [
97104
</template>
98105
</UHeader>
99106
</template>
107+
108+
<style scoped>
109+
.header-logo {
110+
display: flex;
111+
align-items: center;
112+
gap: 0.75rem;
113+
width: var(--fd-sidebar-width, 268px);
114+
height: 100%;
115+
padding-inline: 1.25rem;
116+
border-right: 1px solid var(--ui-border);
117+
box-sizing: border-box;
118+
}
119+
120+
@media (min-width: 1280px) {
121+
.header-logo {
122+
width: 286px;
123+
}
124+
}
125+
</style>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script setup lang="ts">
2+
const { open } = useContentSearch()
3+
</script>
4+
5+
<template>
6+
<button class="docs-search-button" @click="open = true">
7+
<UIcon name="i-lucide-search" class="search-icon" />
8+
<span class="search-text">Search documentation...</span>
9+
</button>
10+
</template>
11+
12+
<style scoped>
13+
.docs-search-button {
14+
display: flex;
15+
align-items: center;
16+
gap: 0.5rem;
17+
width: 100%;
18+
padding: 0.625rem 1.25rem;
19+
font-size: 0.875rem;
20+
color: var(--ui-text-muted);
21+
border-bottom: 1px solid var(--ui-border);
22+
transition: color 0.15s;
23+
text-align: left;
24+
}
25+
26+
.docs-search-button:hover {
27+
color: var(--ui-text);
28+
}
29+
30+
.search-icon {
31+
width: 1rem;
32+
height: 1rem;
33+
margin-inline: 0.125rem;
34+
flex-shrink: 0;
35+
}
36+
37+
.search-text {
38+
flex: 1;
39+
}
40+
</style>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<script setup lang="ts">
2+
const route = useRoute()
3+
const { sections } = useSidebarConfig()
4+
5+
const openSection = ref<string>('')
6+
7+
function isActive(href: string) {
8+
if (href.startsWith('http'))
9+
return false
10+
return route.path === href || route.path.startsWith(`${href}/`)
11+
}
12+
13+
function isSectionActive(sectionIndex: number) {
14+
return sections[sectionIndex]?.items.some(item => isActive(item.href)) ?? false
15+
}
16+
17+
// Build accordion items from sections
18+
const accordionItems = computed(() =>
19+
sections.map((section, index) => ({
20+
label: section.title,
21+
icon: section.icon,
22+
value: String(index),
23+
isNew: section.isNew,
24+
defaultOpen: isSectionActive(index),
25+
slot: `item-${index}` as const,
26+
})),
27+
)
28+
29+
// Set initial open section based on current route
30+
onMounted(() => {
31+
const activeIndex = sections.findIndex((_, index) => isSectionActive(index))
32+
if (activeIndex !== -1) {
33+
openSection.value = String(activeIndex)
34+
}
35+
})
36+
37+
// Update open section when route changes
38+
watch(() => route.path, () => {
39+
const activeIndex = sections.findIndex((_, index) => isSectionActive(index))
40+
if (activeIndex !== -1) {
41+
openSection.value = String(activeIndex)
42+
}
43+
})
44+
</script>
45+
46+
<template>
47+
<nav class="docs-sidebar-nav">
48+
<UAccordion
49+
v-model="openSection"
50+
type="single"
51+
collapsible
52+
:items="accordionItems"
53+
:ui="{
54+
item: 'border-b border-[var(--ui-border)]',
55+
trigger: 'px-5 py-2.5 text-sm font-normal hover:underline data-[state=open]:font-normal',
56+
trailingIcon: 'size-4 text-[var(--ui-text-muted)]',
57+
content: 'data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up',
58+
body: 'p-0',
59+
}"
60+
>
61+
<template #leading="{ item }">
62+
<UIcon :name="item.icon" class="size-5" />
63+
</template>
64+
65+
<template #default="{ item }">
66+
<span class="flex-1">{{ item.label }}</span>
67+
<UBadge v-if="item.isNew" size="xs" variant="outline" class="mr-2">
68+
New
69+
</UBadge>
70+
</template>
71+
72+
<template v-for="(section, index) in sections" :key="section.title" #[`item-${index}`]>
73+
<div class="section-items">
74+
<NuxtLink
75+
v-for="sectionItem in section.items"
76+
:key="sectionItem.href"
77+
:to="sectionItem.href"
78+
:target="sectionItem.href.startsWith('http') ? '_blank' : undefined"
79+
class="sidebar-item"
80+
:class="{ active: isActive(sectionItem.href) }"
81+
>
82+
<UIcon v-if="sectionItem.icon" :name="sectionItem.icon" class="item-icon" />
83+
<span>{{ sectionItem.title }}</span>
84+
<UBadge v-if="sectionItem.isNew" size="xs" variant="outline" class="ml-auto">
85+
New
86+
</UBadge>
87+
<UIcon v-if="sectionItem.href.startsWith('http')" name="i-lucide-external-link" class="external-icon" />
88+
</NuxtLink>
89+
</div>
90+
</template>
91+
</UAccordion>
92+
</nav>
93+
</template>
94+
95+
<style scoped>
96+
.docs-sidebar-nav {
97+
display: flex;
98+
flex-direction: column;
99+
}
100+
101+
/* Add top border to first accordion item */
102+
.docs-sidebar-nav :deep([data-accordion-item]:first-child) {
103+
border-top: 1px solid var(--ui-border);
104+
}
105+
106+
/* Sidebar item - matches Better Auth: px-5 py-1 gap-x-2.5 */
107+
.sidebar-item {
108+
display: flex;
109+
align-items: center;
110+
gap: 0.625rem;
111+
padding: 0.25rem 1.25rem;
112+
font-size: 0.875rem;
113+
color: var(--ui-text-muted);
114+
transition: all 0.15s;
115+
}
116+
117+
.sidebar-item:hover {
118+
color: var(--ui-text);
119+
background-color: color-mix(in srgb, var(--ui-primary) 10%, transparent);
120+
}
121+
122+
.sidebar-item.active {
123+
color: var(--ui-text);
124+
background-color: color-mix(in srgb, var(--ui-primary) 10%, transparent);
125+
}
126+
127+
/* Item icon container - min-w-4 */
128+
.item-icon {
129+
width: 1rem;
130+
height: 1rem;
131+
flex-shrink: 0;
132+
}
133+
134+
.external-icon {
135+
width: 0.75rem;
136+
height: 0.75rem;
137+
margin-left: auto;
138+
opacity: 0.5;
139+
}
140+
</style>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function useDocusSidebar() {
2+
const sidebarOpen = useState('docus-sidebar', () => false)
3+
4+
const toggle = () => {
5+
sidebarOpen.value = !sidebarOpen.value
6+
}
7+
8+
const open = () => {
9+
sidebarOpen.value = true
10+
}
11+
12+
const close = () => {
13+
sidebarOpen.value = false
14+
}
15+
16+
return { sidebarOpen, toggle, open, close }
17+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export interface SidebarItem {
2+
title: string
3+
href: string
4+
icon?: string
5+
isNew?: boolean
6+
}
7+
8+
export interface SidebarSection {
9+
title: string
10+
icon: string
11+
isNew?: boolean
12+
items: SidebarItem[]
13+
}
14+
15+
export function useSidebarConfig() {
16+
const sections: SidebarSection[] = [
17+
{
18+
title: 'Getting Started',
19+
icon: 'i-solar-play-circle-bold',
20+
items: [
21+
{ title: 'Quickstart', href: '/getting-started/quickstart', icon: 'i-solar-play-bold' },
22+
{ title: 'Installation', href: '/getting-started/installation', icon: 'i-solar-download-square-bold' },
23+
{ title: 'Configuration', href: '/getting-started/configuration', icon: 'i-solar-settings-bold' },
24+
{ title: 'Client Setup', href: '/getting-started/client-setup', icon: 'i-solar-monitor-bold' },
25+
{ title: 'Type Augmentation', href: '/getting-started/type-augmentation', icon: 'i-solar-code-file-bold' },
26+
{ title: 'Schema Generation', href: '/getting-started/schema-generation', icon: 'i-solar-database-bold' },
27+
],
28+
},
29+
{
30+
title: 'Core Concepts',
31+
icon: 'i-solar-book-bookmark-bold',
32+
items: [
33+
{ title: 'How It Works', href: '/core-concepts/how-it-works', icon: 'i-solar-lightbulb-bolt-bold' },
34+
{ title: 'Server Auth', href: '/core-concepts/server-auth', icon: 'i-solar-server-bold' },
35+
{ title: 'Sessions', href: '/core-concepts/sessions', icon: 'i-solar-key-bold' },
36+
{ title: 'Route Protection', href: '/core-concepts/route-protection', icon: 'i-solar-shield-check-bold' },
37+
{ title: 'Auto Imports & Aliases', href: '/core-concepts/auto-imports-aliases', icon: 'i-solar-box-bold' },
38+
{ title: 'Security Caveats', href: '/core-concepts/security-caveats', icon: 'i-solar-danger-triangle-bold' },
39+
],
40+
},
41+
{
42+
title: 'Guides',
43+
icon: 'i-solar-map-bold',
44+
items: [
45+
{ title: 'Role-Based Access', href: '/guides/role-based-access', icon: 'i-solar-users-group-rounded-bold' },
46+
{ title: 'API Protection', href: '/guides/api-protection', icon: 'i-solar-lock-bold' },
47+
{ title: 'Custom Dialects', href: '/guides/custom-dialects', icon: 'i-solar-database-bold' },
48+
],
49+
},
50+
{
51+
title: 'API',
52+
icon: 'i-solar-code-square-bold',
53+
items: [
54+
{ title: 'Server', href: '/api/server', icon: 'i-solar-server-square-bold' },
55+
{ title: 'Client', href: '/api/client', icon: 'i-solar-monitor-smartphone-bold' },
56+
],
57+
},
58+
{
59+
title: 'Troubleshooting',
60+
icon: 'i-solar-bug-bold',
61+
items: [
62+
{ title: 'Common Issues', href: '/troubleshooting/common-issues', icon: 'i-solar-question-circle-bold' },
63+
],
64+
},
65+
{
66+
title: 'Better Auth',
67+
icon: 'i-solar-link-round-angle-bold',
68+
items: [
69+
{ title: 'Documentation', href: 'https://www.better-auth.com/docs', icon: 'i-solar-book-2-bold' },
70+
],
71+
},
72+
]
73+
74+
return { sections }
75+
}

0 commit comments

Comments
 (0)