Skip to content

Commit b433288

Browse files
committed
feat: implement theme switch and more features
1 parent 9d7d089 commit b433288

14 files changed

Lines changed: 347 additions & 593 deletions

File tree

SPEC.md

Lines changed: 0 additions & 582 deletions
This file was deleted.

apps/blog/src/layouts/base.astro

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import '../styles/globals.css'
3+
import { ThemeToggle } from '@explainer/ui'
34
45
interface Props {
56
title: string
@@ -16,6 +17,14 @@ const { title, description = 'Explainer Blog' } = Astro.props
1617
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1718
<meta name="description" content={description} />
1819
<title>{title} — Blog</title>
20+
<script is:inline>
21+
(function () {
22+
var theme = localStorage.getItem('theme')
23+
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
24+
document.documentElement.classList.add('dark')
25+
}
26+
})()
27+
</script>
1928
</head>
2029
<body class="min-h-screen antialiased">
2130
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -25,6 +34,7 @@ const { title, description = 'Explainer Blog' } = Astro.props
2534
<a href="/" class="hover:text-foreground transition-colors">Articles</a>
2635
<a href="/tags" class="hover:text-foreground transition-colors">Tags</a>
2736
<a href="/rss.xml" class="hover:text-foreground transition-colors">RSS</a>
37+
<ThemeToggle client:load />
2838
</nav>
2939
</div>
3040
</header>

apps/blog/src/layouts/post.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const { title, description, date, tags, cover } = Astro.props
4141
)}
4242
</div>
4343
</header>
44-
<div class="prose prose-neutral max-w-none">
44+
<div class="prose prose-neutral dark:prose-invert max-w-none">
4545
<slot />
4646
</div>
4747
</article>

apps/docs/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "astro dev",
8-
"build": "astro build",
8+
"build": "astro build && pagefind --site dist",
99
"preview": "astro preview"
1010
},
1111
"dependencies": {
@@ -23,6 +23,7 @@
2323
"devDependencies": {
2424
"@types/react": "^19.0.0",
2525
"@types/react-dom": "^19.0.0",
26-
"typescript": "^5.8.0"
26+
"typescript": "^5.8.0",
27+
"pagefind": "^1.4.0"
2728
}
2829
}

apps/docs/src/components/header.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ThemeToggle } from '@explainer/ui'
12
import type { ProjectInfo } from '../lib/docs'
23
import { ProjectSwitcher } from './project-switcher'
34
import { VersionSwitcher } from './version-switcher'
@@ -51,11 +52,14 @@ export function Header({
5152
)}
5253
</div>
5354
</div>
54-
<LocaleSwitcher
55-
locales={locales}
56-
currentLocale={currentLocale}
57-
switchUrls={localeSwitchUrls}
58-
/>
55+
<div className="flex items-center gap-2">
56+
<LocaleSwitcher
57+
locales={locales}
58+
currentLocale={currentLocale}
59+
switchUrls={localeSwitchUrls}
60+
/>
61+
<ThemeToggle />
62+
</div>
5963
</div>
6064
</header>
6165
)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
---
3+
4+
<div id="search" class="hidden lg:block"></div>
5+
6+
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" />
7+
8+
<script is:inline>
9+
window.addEventListener('DOMContentLoaded', () => {
10+
if (typeof PagefindUI !== 'undefined') {
11+
new PagefindUI({
12+
element: '#search',
13+
showSubResults: true,
14+
showImages: false,
15+
})
16+
}
17+
})
18+
</script>
19+
20+
<style is:global>
21+
.pagefind-ui .pagefind-ui__search-input {
22+
border-radius: 0.375rem;
23+
border: 1px solid var(--color-border);
24+
background: var(--color-background);
25+
color: var(--color-foreground);
26+
font-size: 0.875rem;
27+
padding: 0.5rem 0.75rem;
28+
height: 2.25rem;
29+
}
30+
31+
.pagefind-ui .pagefind-ui__result-link {
32+
color: var(--color-primary);
33+
}
34+
35+
.pagefind-ui .pagefind-ui__result {
36+
border-top: 1px solid var(--color-border);
37+
padding: 0.75rem 0;
38+
}
39+
40+
.pagefind-ui .pagefind-ui__message {
41+
color: var(--color-muted-foreground);
42+
font-size: 0.875rem;
43+
}
44+
</style>
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import * as React from 'react'
2+
import { cn } from '@explainer/ui'
3+
import { Icon } from '@iconify/react'
4+
5+
interface PagefindResult {
6+
url: string
7+
meta: { title: string }
8+
excerpt: string
9+
}
10+
11+
let pagefindInstance: any = null
12+
let pagefindFailed = false
13+
14+
async function getPagefind() {
15+
if (pagefindInstance) return pagefindInstance
16+
if (pagefindFailed) return null
17+
try {
18+
const module = await new Function("return import('/pagefind/pagefind.js')")()
19+
await module.init()
20+
pagefindInstance = module
21+
return pagefindInstance
22+
} catch {
23+
pagefindFailed = true
24+
return null
25+
}
26+
}
27+
28+
export function Search() {
29+
const [query, setQuery] = React.useState('')
30+
const [results, setResults] = React.useState<PagefindResult[]>([])
31+
const [open, setOpen] = React.useState(false)
32+
const [loading, setLoading] = React.useState(false)
33+
const [unavailable, setUnavailable] = React.useState(false)
34+
const containerRef = React.useRef<HTMLDivElement>(null)
35+
36+
React.useEffect(() => {
37+
const handler = (e: MouseEvent) => {
38+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
39+
setOpen(false)
40+
}
41+
}
42+
document.addEventListener('mousedown', handler)
43+
return () => document.removeEventListener('mousedown', handler)
44+
}, [])
45+
46+
React.useEffect(() => {
47+
if (!query.trim()) {
48+
setResults([])
49+
setOpen(false)
50+
return
51+
}
52+
53+
const timeout = setTimeout(async () => {
54+
setLoading(true)
55+
const pagefind = await getPagefind()
56+
if (!pagefind) {
57+
setLoading(false)
58+
setUnavailable(true)
59+
setOpen(true)
60+
return
61+
}
62+
63+
const search = await pagefind.search(query)
64+
const data = await Promise.all(search.results.slice(0, 8).map((r: any) => r.data()))
65+
setResults(data)
66+
setOpen(true)
67+
setLoading(false)
68+
}, 200)
69+
70+
return () => clearTimeout(timeout)
71+
}, [query])
72+
73+
return (
74+
<div ref={containerRef} className="relative px-3">
75+
<div className="relative">
76+
<Icon
77+
icon="lucide:search"
78+
className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
79+
/>
80+
<input
81+
type="text"
82+
placeholder="Search docs..."
83+
value={query}
84+
onChange={(e) => setQuery(e.target.value)}
85+
onFocus={() => results.length > 0 && setOpen(true)}
86+
className={cn(
87+
'w-full rounded-md border bg-background px-3 py-2 pl-9 text-sm',
88+
'placeholder:text-muted-foreground',
89+
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
90+
)}
91+
/>
92+
{loading && (
93+
<Icon
94+
icon="lucide:loader-2"
95+
className="absolute right-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground animate-spin"
96+
/>
97+
)}
98+
</div>
99+
100+
{open && results.length > 0 && (
101+
<div className="absolute left-3 right-3 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
102+
{results.map((result, i) => (
103+
<a
104+
key={i}
105+
href={result.url}
106+
className="block rounded-sm px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
107+
onClick={() => setOpen(false)}
108+
>
109+
<div className="font-medium">{result.meta.title}</div>
110+
{result.excerpt && (
111+
<div
112+
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
113+
dangerouslySetInnerHTML={{ __html: result.excerpt }}
114+
/>
115+
)}
116+
</a>
117+
))}
118+
</div>
119+
)}
120+
121+
{open && query.trim() && results.length === 0 && !loading && (
122+
<div className="absolute left-3 right-3 top-full z-50 mt-1 rounded-md border bg-popover p-3 shadow-md">
123+
<p className="text-sm text-muted-foreground text-center">
124+
{unavailable ? 'Search is available after building the site' : 'No results found'}
125+
</p>
126+
</div>
127+
)}
128+
</div>
129+
)
130+
}

apps/docs/src/components/sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface SidebarProps {
1010

1111
export function Sidebar({ items, currentPath }: SidebarProps) {
1212
return (
13-
<nav className="w-64 shrink-0 border-r h-[calc(100vh-4rem)] overflow-y-auto sticky top-16 py-6 pr-4">
13+
<nav className="flex-1 overflow-y-auto">
1414
<ul className="space-y-1">
1515
{items.map((item) => (
1616
<SidebarItem key={item.slug} item={item} currentPath={currentPath} />

apps/docs/src/layouts/docs.astro

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Header } from '../components/header'
44
import { Sidebar } from '../components/sidebar'
55
import { TableOfContents, type TocHeading } from '../components/toc'
66
import { Pagination } from '../components/pagination'
7+
import { Search } from '../components/search'
78
import type { NavItem, ProjectInfo } from '../lib/docs'
89
910
interface Props {
@@ -50,6 +51,14 @@ const {
5051
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
5152
{description && <meta name="description" content={description} />}
5253
<title>{title} — Explainer</title>
54+
<script is:inline>
55+
(function () {
56+
var theme = localStorage.getItem('theme')
57+
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
58+
document.documentElement.classList.add('dark')
59+
}
60+
})()
61+
</script>
5362
</head>
5463
<body class="min-h-screen antialiased">
5564
<Header
@@ -64,9 +73,12 @@ const {
6473
client:load
6574
/>
6675
<div class="flex max-w-[1400px] mx-auto">
67-
<Sidebar items={navItems} currentPath={currentPath} client:load />
76+
<div class="w-64 shrink-0 border-r h-[calc(100vh-4rem)] overflow-y-auto sticky top-16 py-6 pr-4 flex flex-col gap-4">
77+
<Search client:load />
78+
<Sidebar items={navItems} currentPath={currentPath} client:load />
79+
</div>
6880
<main class="flex-1 min-w-0 px-8 py-6">
69-
<article class="prose prose-neutral max-w-none">
81+
<article class="prose prose-neutral dark:prose-invert max-w-none" data-pagefind-body>
7082
<slot />
7183
</article>
7284
<Pagination prev={prev} next={next} client:load />

apps/website/src/layouts/base.astro

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const { title, description = 'Explainer v2' } = Astro.props
1616
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1717
<meta name="description" content={description} />
1818
<title>{title}</title>
19+
<script is:inline>
20+
(function () {
21+
var theme = localStorage.getItem('theme')
22+
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
23+
document.documentElement.classList.add('dark')
24+
}
25+
})()
26+
</script>
1927
</head>
2028
<body class="min-h-screen antialiased">
2129
<slot />

0 commit comments

Comments
 (0)