coding-friend-cli 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +15 -0
  2. package/dist/{chunk-KZT4AFDW.js → chunk-5HZJX47M.js} +1 -1
  3. package/dist/{chunk-AQXTNLQD.js → chunk-6OI37OZX.js} +9 -1
  4. package/dist/chunk-R6ZYK4UX.js +128 -0
  5. package/dist/dev-LZASFXZZ.js +243 -0
  6. package/dist/{host-JBTJCWM2.js → host-BK6DYFWF.js} +2 -2
  7. package/dist/index.js +26 -5
  8. package/dist/{init-E6CL3UZQ.js → init-2UKYE2KV.js} +2 -2
  9. package/dist/{mcp-MWESK6UX.js → mcp-CH4SKZSX.js} +2 -2
  10. package/dist/postinstall.js +1 -1
  11. package/dist/{statusline-7D6YU5YM.js → statusline-ARI7I5YM.js} +1 -1
  12. package/dist/{update-IH3G4SN5.js → update-5A2OP6EY.js} +58 -37
  13. package/lib/learn-host/.prettierignore +3 -0
  14. package/lib/learn-host/.prettierrc +8 -0
  15. package/lib/learn-host/CHANGELOG.md +9 -0
  16. package/lib/learn-host/eslint.config.mjs +6 -0
  17. package/lib/learn-host/next-env.d.ts +1 -1
  18. package/lib/learn-host/next.config.ts +1 -0
  19. package/lib/learn-host/package-lock.json +6039 -391
  20. package/lib/learn-host/package.json +30 -15
  21. package/lib/learn-host/public/_pagefind/fragment/en_1172b3c.pf_fragment +0 -0
  22. package/lib/learn-host/public/_pagefind/fragment/en_118ad1c.pf_fragment +0 -0
  23. package/lib/learn-host/public/_pagefind/fragment/en_32ab3d8.pf_fragment +0 -0
  24. package/lib/learn-host/public/_pagefind/fragment/en_441f1e1.pf_fragment +0 -0
  25. package/lib/learn-host/public/_pagefind/fragment/en_4452de4.pf_fragment +0 -0
  26. package/lib/learn-host/public/_pagefind/fragment/en_4ae396d.pf_fragment +0 -0
  27. package/lib/learn-host/public/_pagefind/fragment/en_58ee89d.pf_fragment +0 -0
  28. package/lib/learn-host/public/_pagefind/fragment/en_6dd2225.pf_fragment +0 -0
  29. package/lib/learn-host/public/_pagefind/fragment/en_765a297.pf_fragment +0 -0
  30. package/lib/learn-host/public/_pagefind/fragment/en_7a4cc4a.pf_fragment +0 -0
  31. package/lib/learn-host/public/_pagefind/fragment/en_8050261.pf_fragment +0 -0
  32. package/lib/learn-host/public/_pagefind/fragment/en_83eaedf.pf_fragment +0 -0
  33. package/lib/learn-host/public/_pagefind/fragment/en_925bc5f.pf_fragment +0 -0
  34. package/lib/learn-host/public/_pagefind/fragment/en_95f3dd5.pf_fragment +0 -0
  35. package/lib/learn-host/public/_pagefind/fragment/en_96d7a02.pf_fragment +0 -0
  36. package/lib/learn-host/public/_pagefind/fragment/en_971f951.pf_fragment +0 -0
  37. package/lib/learn-host/public/_pagefind/fragment/en_a446c32.pf_fragment +0 -0
  38. package/lib/learn-host/public/_pagefind/fragment/en_a5ee367.pf_fragment +0 -0
  39. package/lib/learn-host/public/_pagefind/fragment/en_b11c248.pf_fragment +0 -0
  40. package/lib/learn-host/public/_pagefind/fragment/en_b13c52e.pf_fragment +0 -0
  41. package/lib/learn-host/public/_pagefind/fragment/en_b5bd69b.pf_fragment +0 -0
  42. package/lib/learn-host/public/_pagefind/fragment/en_b625d7d.pf_fragment +0 -0
  43. package/lib/learn-host/public/_pagefind/fragment/en_bf63915.pf_fragment +0 -0
  44. package/lib/learn-host/public/_pagefind/fragment/en_c52b25b.pf_fragment +0 -0
  45. package/lib/learn-host/public/_pagefind/fragment/en_c9db556.pf_fragment +0 -0
  46. package/lib/learn-host/public/_pagefind/fragment/en_d1537ee.pf_fragment +0 -0
  47. package/lib/learn-host/public/_pagefind/fragment/en_d2e6412.pf_fragment +0 -0
  48. package/lib/learn-host/public/_pagefind/fragment/en_d2f47a4.pf_fragment +0 -0
  49. package/lib/learn-host/public/_pagefind/fragment/en_d361292.pf_fragment +0 -0
  50. package/lib/learn-host/public/_pagefind/fragment/en_d727ec8.pf_fragment +0 -0
  51. package/lib/learn-host/public/_pagefind/fragment/en_e11cd8f.pf_fragment +0 -0
  52. package/lib/learn-host/public/_pagefind/fragment/en_e481f19.pf_fragment +0 -0
  53. package/lib/learn-host/public/_pagefind/fragment/en_eee2805.pf_fragment +0 -0
  54. package/lib/learn-host/public/_pagefind/fragment/en_f4de6c4.pf_fragment +0 -0
  55. package/lib/learn-host/public/_pagefind/index/en_1ecb9d5.pf_index +0 -0
  56. package/lib/learn-host/public/_pagefind/index/en_37e362b.pf_index +0 -0
  57. package/lib/learn-host/public/_pagefind/index/en_538eee7.pf_index +0 -0
  58. package/lib/learn-host/public/_pagefind/index/en_5751dc8.pf_index +0 -0
  59. package/lib/learn-host/public/_pagefind/index/en_67f794d.pf_index +0 -0
  60. package/lib/learn-host/public/_pagefind/index/en_7458f81.pf_index +0 -0
  61. package/lib/learn-host/public/_pagefind/index/en_e21f7e1.pf_index +0 -0
  62. package/lib/learn-host/public/_pagefind/pagefind-entry.json +1 -0
  63. package/lib/learn-host/public/_pagefind/pagefind-highlight.js +1064 -0
  64. package/lib/learn-host/public/_pagefind/pagefind-modular-ui.css +214 -0
  65. package/lib/learn-host/public/_pagefind/pagefind-modular-ui.js +8 -0
  66. package/lib/learn-host/public/_pagefind/pagefind-ui.css +1 -0
  67. package/lib/learn-host/public/_pagefind/pagefind-ui.js +2 -0
  68. package/lib/learn-host/public/_pagefind/pagefind.en_104569cceb.pf_meta +0 -0
  69. package/lib/learn-host/public/_pagefind/pagefind.en_1075df6f16.pf_meta +0 -0
  70. package/lib/learn-host/public/_pagefind/pagefind.en_139f35f6e5.pf_meta +0 -0
  71. package/lib/learn-host/public/_pagefind/pagefind.en_46bfc9f7e1.pf_meta +0 -0
  72. package/lib/learn-host/public/_pagefind/pagefind.en_76b8937bbc.pf_meta +0 -0
  73. package/lib/learn-host/public/_pagefind/pagefind.en_83cbfb0fd5.pf_meta +0 -0
  74. package/lib/learn-host/public/_pagefind/pagefind.en_b1d168d536.pf_meta +0 -0
  75. package/lib/learn-host/public/_pagefind/pagefind.js +6 -0
  76. package/lib/learn-host/public/_pagefind/wasm.en.pagefind +0 -0
  77. package/lib/learn-host/public/_pagefind/wasm.unknown.pagefind +0 -0
  78. package/lib/learn-host/public/logo.svg +1 -0
  79. package/lib/learn-host/src/app/[category]/[slug]/page.tsx +36 -32
  80. package/lib/learn-host/src/app/[category]/page.tsx +2 -3
  81. package/lib/learn-host/src/app/apple-icon.svg +1 -0
  82. package/lib/learn-host/src/app/globals.css +74 -14
  83. package/lib/learn-host/src/app/icon.svg +1 -0
  84. package/lib/learn-host/src/app/layout.tsx +29 -9
  85. package/lib/learn-host/src/app/page.tsx +9 -11
  86. package/lib/learn-host/src/components/Breadcrumbs.tsx +12 -4
  87. package/lib/learn-host/src/components/DocCard.tsx +28 -10
  88. package/lib/learn-host/src/components/MarkdownRenderer.tsx +6 -2
  89. package/lib/learn-host/src/components/MobileNav.tsx +43 -35
  90. package/lib/learn-host/src/components/PagefindSearch.tsx +177 -54
  91. package/lib/learn-host/src/components/Sidebar.tsx +27 -29
  92. package/lib/learn-host/src/components/TableOfContents.tsx +62 -0
  93. package/lib/learn-host/src/components/TagBadge.tsx +1 -1
  94. package/lib/learn-host/src/components/ThemeToggle.tsx +36 -9
  95. package/lib/learn-host/src/components/layout/Footer.tsx +41 -0
  96. package/lib/learn-host/src/components/layout/Header.tsx +117 -0
  97. package/lib/learn-host/src/lib/docs.ts +98 -8
  98. package/lib/learn-host/src/lib/types.ts +7 -1
  99. package/lib/learn-host/tsconfig.json +8 -2
  100. package/lib/learn-host/tsconfig.tsbuildinfo +1 -0
  101. package/lib/learn-mcp/CHANGELOG.md +7 -0
  102. package/lib/learn-mcp/package.json +1 -1
  103. package/package.json +13 -5
  104. package/dist/chunk-VHZQ6KEU.js +0 -73
  105. package/lib/learn-host/src/app/search/page.tsx +0 -19
  106. package/lib/learn-host/src/components/SearchBar.tsx +0 -36
@@ -1,55 +1,63 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
4
5
  import { useState } from "react";
5
6
  import type { CategoryInfo } from "@/lib/types";
6
- import SearchBar from "./SearchBar";
7
- import ThemeToggle from "./ThemeToggle";
8
7
 
9
- export default function MobileNav({ categories }: { categories: CategoryInfo[] }) {
8
+ export default function MobileNav({
9
+ categories,
10
+ }: {
11
+ categories: CategoryInfo[];
12
+ }) {
13
+ const pathname = usePathname();
10
14
  const [open, setOpen] = useState(false);
11
15
 
12
16
  return (
13
- <div className="md:hidden">
14
- <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
15
- <Link href="/" className="font-bold text-gray-900 dark:text-gray-100">
16
- Learning Notes
17
- </Link>
18
- <div className="flex items-center gap-2">
19
- <ThemeToggle />
20
- <button
21
- onClick={() => setOpen(!open)}
22
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
23
- aria-label="Toggle menu"
24
- >
25
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
26
- {open ? (
27
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
28
- ) : (
29
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
30
- )}
31
- </svg>
32
- </button>
33
- </div>
34
- </div>
17
+ <div className="dark:bg-navy-950 border-b border-slate-200 bg-slate-50 dark:border-[#a0a0a01c]">
18
+ <button
19
+ onClick={() => setOpen(!open)}
20
+ className="flex w-full cursor-pointer items-center justify-between px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-300"
21
+ >
22
+ <span>Navigation</span>
23
+ <svg
24
+ className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
25
+ fill="none"
26
+ viewBox="0 0 24 24"
27
+ stroke="currentColor"
28
+ >
29
+ <path
30
+ strokeLinecap="round"
31
+ strokeLinejoin="round"
32
+ strokeWidth={2}
33
+ d="M19 9l-7 7-7-7"
34
+ />
35
+ </svg>
36
+ </button>
35
37
 
36
38
  {open && (
37
- <div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
38
- <SearchBar />
39
- <nav className="mt-4 space-y-1">
40
- {categories.map((cat) => (
39
+ <nav className="max-h-[60vh] space-y-0.5 overflow-y-auto px-4 pb-4">
40
+ {categories.map((cat) => {
41
+ const isActive = pathname === `/${cat.name}/`;
42
+ return (
41
43
  <Link
42
44
  key={cat.name}
43
45
  href={`/${cat.name}/`}
44
46
  onClick={() => setOpen(false)}
45
- className="flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
47
+ className={`flex items-center justify-between rounded-md px-3 py-1.5 text-sm capitalize ${
48
+ isActive
49
+ ? "font-medium text-violet-600 dark:text-violet-400"
50
+ : "text-slate-600 dark:text-slate-400"
51
+ }`}
46
52
  >
47
- <span className="capitalize">{cat.name.replace(/[_-]/g, " ")}</span>
48
- <span className="text-xs text-gray-400">{cat.docCount}</span>
53
+ <span>{cat.name.replace(/[_-]/g, " ")}</span>
54
+ <span className="text-xs text-slate-400 dark:text-slate-500">
55
+ {cat.docCount}
56
+ </span>
49
57
  </Link>
50
- ))}
51
- </nav>
52
- </div>
58
+ );
59
+ })}
60
+ </nav>
53
61
  )}
54
62
  </div>
55
63
  );
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from "react";
4
+ import { Command } from "cmdk";
5
+ import { useRouter } from "next/navigation";
6
+ import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
7
+ import { DialogTitle, DialogDescription } from "@radix-ui/react-dialog";
4
8
 
5
9
  interface PagefindResult {
6
10
  id: string;
@@ -8,7 +12,6 @@ interface PagefindResult {
8
12
  url: string;
9
13
  meta: { title?: string };
10
14
  excerpt: string;
11
- sub_results?: { url: string; title: string; excerpt: string }[];
12
15
  }>;
13
16
  }
14
17
 
@@ -29,13 +32,32 @@ interface Pagefind {
29
32
  ) => Promise<{ results: PagefindResult[] } | null>;
30
33
  }
31
34
 
32
- export default function PagefindSearch({ initialQuery = "" }: { initialQuery?: string }) {
33
- const [query, setQuery] = useState(initialQuery);
35
+ function normalizePagefindUrl(url: string): string {
36
+ return url.replace(/\.html$/, "/");
37
+ }
38
+
39
+ // Pagefind excerpts only contain <mark> tags for highlighting — safe to render
40
+ // This is a trusted source (local pagefind index), not user input
41
+ function ExcerptMarkup({ html }: { html: string }) {
42
+ return (
43
+ <p
44
+ className="mt-0.5 line-clamp-2 text-sm text-slate-500 dark:text-slate-400 [&_mark]:bg-transparent [&_mark]:!text-yellow-600 dark:[&_mark]:!text-yellow-200"
45
+ dangerouslySetInnerHTML={{ __html: html }}
46
+ />
47
+ );
48
+ }
49
+
50
+ export default function PagefindSearch() {
51
+ const [open, setOpen] = useState(false);
52
+ const [query, setQuery] = useState("");
34
53
  const [results, setResults] = useState<SearchResult[]>([]);
35
54
  const [loading, setLoading] = useState(false);
36
55
  const [ready, setReady] = useState(false);
37
56
  const pagefindRef = useRef<Pagefind | null>(null);
57
+ const dialogRef = useRef<HTMLDivElement>(null);
58
+ const router = useRouter();
38
59
 
60
+ // Load pagefind
39
61
  useEffect(() => {
40
62
  async function load() {
41
63
  try {
@@ -47,26 +69,60 @@ export default function PagefindSearch({ initialQuery = "" }: { initialQuery?: s
47
69
  pagefindRef.current = pf;
48
70
  setReady(true);
49
71
  } catch {
50
- // Pagefind not available (dev mode or first run)
72
+ // Pagefind not available (dev mode)
51
73
  }
52
74
  }
53
75
  load();
54
76
  }, []);
55
77
 
78
+ // Keyboard shortcut: Cmd+K / Ctrl+K
79
+ useEffect(() => {
80
+ function handleKeyDown(e: KeyboardEvent) {
81
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
82
+ e.preventDefault();
83
+ setOpen((prev) => !prev);
84
+ }
85
+ }
86
+ document.addEventListener("keydown", handleKeyDown);
87
+ return () => document.removeEventListener("keydown", handleKeyDown);
88
+ }, []);
89
+
90
+ // Click outside to close
91
+ useEffect(() => {
92
+ if (!open) return;
93
+ function handlePointerDown(e: PointerEvent) {
94
+ if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
95
+ setOpen(false);
96
+ }
97
+ }
98
+ document.addEventListener("pointerdown", handlePointerDown);
99
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
100
+ }, [open]);
101
+
102
+ // Reset state when dialog closes
103
+ useEffect(() => {
104
+ if (!open) {
105
+ setQuery("");
106
+ setResults([]);
107
+ }
108
+ }, [open]);
109
+
56
110
  const doSearch = useCallback(async (q: string) => {
57
111
  const pf = pagefindRef.current;
58
112
  if (!pf || !q.trim()) {
59
113
  setResults([]);
114
+ setLoading(false);
60
115
  return;
61
116
  }
62
117
 
118
+ const response = await pf.debouncedSearch(q, {}, 200);
119
+ if (!response) return;
120
+
63
121
  setLoading(true);
122
+ setResults([]);
64
123
  try {
65
- const response = await pf.debouncedSearch(q, {}, 200);
66
- if (!response) return; // debounced away
67
-
68
124
  const items: SearchResult[] = [];
69
- for (const result of response.results.slice(0, 20)) {
125
+ for (const result of response.results.slice(0, 10)) {
70
126
  const data = await result.data();
71
127
  items.push({
72
128
  id: result.id,
@@ -82,53 +138,120 @@ export default function PagefindSearch({ initialQuery = "" }: { initialQuery?: s
82
138
  }, []);
83
139
 
84
140
  useEffect(() => {
85
- if (ready) doSearch(query);
86
- }, [query, ready, doSearch]);
141
+ if (ready && open) doSearch(query);
142
+ }, [query, ready, open, doSearch]);
87
143
 
88
144
  return (
89
- <div>
90
- <h1 className="text-2xl font-bold mb-4">Search</h1>
91
- <input
92
- type="text"
93
- value={query}
94
- onChange={(e) => setQuery(e.target.value)}
95
- placeholder="Search docs..."
96
- className="w-full px-4 py-2 mb-6 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
97
- autoFocus
98
- />
99
-
100
- {!ready && query.trim() && (
101
- <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
102
- Search index not available. Run a build first.
103
- </p>
104
- )}
105
-
106
- {ready && query.trim() && !loading && (
107
- <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
108
- {results.length} {results.length === 1 ? "result" : "results"} for &ldquo;{query}&rdquo;
109
- </p>
110
- )}
111
-
112
- <div className="grid gap-3">
113
- {results.map((entry) => {
114
- // Pagefind excerpts contain only <mark> tags for highlighting — safe to render
115
- return (
116
- <a
117
- key={entry.id}
118
- href={entry.url}
119
- className="block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 hover:shadow-sm transition-all bg-white dark:bg-gray-800/50"
120
- >
121
- <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
122
- {entry.title}
123
- </h3>
124
- <p
125
- className="text-sm text-gray-500 dark:text-gray-400 mb-2 line-clamp-2 [&_mark]:bg-yellow-200 dark:[&_mark]:bg-yellow-800 [&_mark]:rounded [&_mark]:px-0.5"
126
- dangerouslySetInnerHTML={{ __html: entry.excerpt }}
127
- />
128
- </a>
129
- );
130
- })}
131
- </div>
132
- </div>
145
+ <>
146
+ {/* Search trigger button */}
147
+ <button
148
+ onClick={() => setOpen(true)}
149
+ className="flex cursor-pointer items-center gap-1.5 rounded-lg p-2 text-slate-500 transition-colors duration-200 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
150
+ aria-label="Search docs"
151
+ >
152
+ <svg
153
+ className="h-5 w-5"
154
+ fill="none"
155
+ viewBox="0 0 24 24"
156
+ stroke="currentColor"
157
+ >
158
+ <path
159
+ strokeLinecap="round"
160
+ strokeLinejoin="round"
161
+ strokeWidth={2}
162
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
163
+ />
164
+ </svg>
165
+ <kbd className="dark:bg-navy-800/80 hidden items-center gap-0.5 rounded border border-slate-300 px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-flex dark:border-[#a0a0a01c]">
166
+ <span className="text-xs">&#8984;</span>K
167
+ </kbd>
168
+ </button>
169
+
170
+ {/* cmdk dialog */}
171
+ <Command.Dialog
172
+ open={open}
173
+ onOpenChange={setOpen}
174
+ shouldFilter={false}
175
+ loop
176
+ label="Search documentation"
177
+ overlayClassName="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
178
+ contentClassName="fixed inset-0 z-50 flex items-start justify-center pt-[15vh] pointer-events-none"
179
+ ref={dialogRef}
180
+ className="dark:bg-navy-900/80 pointer-events-auto mx-4 w-full max-w-lg overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-[#a0a0a01c]"
181
+ >
182
+ <VisuallyHidden>
183
+ <DialogTitle>Search documentation</DialogTitle>
184
+ <DialogDescription>
185
+ Search through the documentation pages
186
+ </DialogDescription>
187
+ </VisuallyHidden>
188
+ {/* Search input */}
189
+ <div className="flex items-center gap-3 border-b border-slate-200 px-4 dark:border-[#a0a0a01c]">
190
+ <svg
191
+ className="h-6 w-6 shrink-0 text-slate-400 dark:text-slate-500"
192
+ fill="none"
193
+ viewBox="0 0 24 24"
194
+ stroke="currentColor"
195
+ >
196
+ <path
197
+ strokeLinecap="round"
198
+ strokeLinejoin="round"
199
+ strokeWidth={2}
200
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
201
+ />
202
+ </svg>
203
+ <Command.Input
204
+ value={query}
205
+ onValueChange={setQuery}
206
+ placeholder="Search documentation..."
207
+ className="flex-1 bg-transparent py-3 text-sm text-slate-900 placeholder-slate-400 outline-none dark:text-white dark:placeholder-slate-500"
208
+ />
209
+ <kbd className="rounded border border-slate-300 px-1.5 py-0.5 text-xs text-slate-400 dark:border-[#a0a0a01c] dark:text-slate-500">
210
+ ESC
211
+ </kbd>
212
+ </div>
213
+
214
+ {/* Results */}
215
+ <Command.List className="max-h-[50vh] overflow-y-auto">
216
+ {loading && (
217
+ <Command.Loading className="px-4 py-6 text-center text-sm text-slate-400">
218
+ Searching...
219
+ </Command.Loading>
220
+ )}
221
+
222
+ {!ready && query.trim() && (
223
+ <p className="px-4 py-6 text-center text-sm text-slate-400">
224
+ Search index not available. Run a production build first.
225
+ </p>
226
+ )}
227
+
228
+ <Command.Empty className="px-4 py-6 text-center text-sm text-slate-400">
229
+ {query.trim() ? (
230
+ <>No results found for &ldquo;{query}&rdquo;</>
231
+ ) : (
232
+ "Start typing to search..."
233
+ )}
234
+ </Command.Empty>
235
+
236
+ {!loading &&
237
+ results.map((entry) => (
238
+ <Command.Item
239
+ key={entry.id}
240
+ value={entry.id}
241
+ onSelect={() => {
242
+ setOpen(false);
243
+ router.push(normalizePagefindUrl(entry.url));
244
+ }}
245
+ className="dark:data-[selected=true]:bg-navy-800 cursor-pointer px-4 py-3 transition-colors data-[selected=true]:bg-slate-100"
246
+ >
247
+ <div className="text-base font-medium text-slate-900 dark:text-white">
248
+ {entry.title}
249
+ </div>
250
+ <ExcerptMarkup html={entry.excerpt} />
251
+ </Command.Item>
252
+ ))}
253
+ </Command.List>
254
+ </Command.Dialog>
255
+ </>
133
256
  );
134
257
  }
@@ -1,44 +1,42 @@
1
+ "use client";
2
+
1
3
  import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
2
5
  import type { CategoryInfo } from "@/lib/types";
3
- import ThemeToggle from "./ThemeToggle";
4
- import SearchBar from "./SearchBar";
5
6
 
6
- export default function Sidebar({ categories }: { categories: CategoryInfo[] }) {
7
- const totalDocs = categories.reduce((sum, c) => sum + c.docCount, 0);
7
+ export default function Sidebar({
8
+ categories,
9
+ }: {
10
+ categories: CategoryInfo[];
11
+ }) {
12
+ const pathname = usePathname();
8
13
 
9
14
  return (
10
- <aside className="w-64 shrink-0 border-r border-gray-200 dark:border-gray-700 h-screen sticky top-0 overflow-y-auto bg-gray-50 dark:bg-gray-900 hidden md:block">
11
- <div className="p-4">
12
- <Link href="/" className="block mb-4">
13
- <h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">
14
- Learning Notes
15
- </h1>
16
- <p className="text-xs text-gray-500 dark:text-gray-400">
17
- {totalDocs} docs &middot; {categories.length} categories
18
- </p>
19
- </Link>
20
-
21
- <SearchBar />
22
-
23
- <nav className="mt-6 space-y-1">
24
- {categories.map((cat) => (
15
+ <aside className="dark:bg-navy-950 fixed top-14 left-0 z-10 hidden h-[calc(100vh-3.5rem)] w-64 shrink-0 border-r border-slate-200 bg-slate-50 md:flex md:flex-col lg:w-[300px] dark:border-[#a0a0a01c]">
16
+ <nav
17
+ className="scrollbar-none flex-1 space-y-1 overflow-y-auto p-4"
18
+ style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
19
+ >
20
+ {categories.map((cat) => {
21
+ const isActive = pathname === `/${cat.name}/`;
22
+ return (
25
23
  <Link
26
24
  key={cat.name}
27
25
  href={`/${cat.name}/`}
28
- className="flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 transition-colors"
26
+ className={`flex items-center justify-between rounded-full px-3 py-2 text-sm capitalize transition-colors duration-200 ${
27
+ isActive
28
+ ? "font-medium text-violet-600 dark:text-violet-400"
29
+ : "dark:hover:bg-navy-800/50 text-slate-600 hover:bg-slate-200/50 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
30
+ }`}
29
31
  >
30
- <span className="capitalize">{cat.name.replace(/[_-]/g, " ")}</span>
31
- <span className="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
32
+ <span>{cat.name.replace(/[_-]/g, " ")}</span>
33
+ <span className="dark:bg-navy-800 rounded-full bg-slate-200 px-1.5 py-0.5 text-xs text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-[#a0a0a01c]">
32
34
  {cat.docCount}
33
35
  </span>
34
36
  </Link>
35
- ))}
36
- </nav>
37
- </div>
38
-
39
- <div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200 dark:border-gray-700">
40
- <ThemeToggle />
41
- </div>
37
+ );
38
+ })}
39
+ </nav>
42
40
  </aside>
43
41
  );
44
42
  }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { TocItem } from "@/lib/types";
5
+
6
+ interface Props {
7
+ headings: TocItem[];
8
+ }
9
+
10
+ export default function TableOfContents({ headings }: Props) {
11
+ const [activeId, setActiveId] = useState("");
12
+
13
+ useEffect(() => {
14
+ const observer = new IntersectionObserver(
15
+ (entries) => {
16
+ for (const entry of entries) {
17
+ if (entry.isIntersecting) {
18
+ setActiveId(entry.target.id);
19
+ }
20
+ }
21
+ },
22
+ { rootMargin: "-80px 0px -60% 0px", threshold: 0.1 },
23
+ );
24
+
25
+ for (const heading of headings) {
26
+ const el = document.getElementById(heading.id);
27
+ if (el) observer.observe(el);
28
+ }
29
+
30
+ return () => observer.disconnect();
31
+ }, [headings]);
32
+
33
+ if (headings.length === 0) return null;
34
+
35
+ return (
36
+ <aside className="sticky top-16 hidden h-[calc(100vh-4rem)] w-56 shrink-0 overflow-y-auto lg:block">
37
+ <div className="p-4">
38
+ <h4 className="mb-3 text-xs font-semibold tracking-wider text-slate-500 uppercase dark:text-slate-400">
39
+ On this page
40
+ </h4>
41
+ <ul className="space-y-1.5">
42
+ {headings.map((h) => (
43
+ <li key={h.id}>
44
+ <a
45
+ href={`#${h.id}`}
46
+ className={`block text-xs transition-colors duration-200 ${
47
+ h.level === 3 ? "pl-3" : ""
48
+ } ${
49
+ activeId === h.id
50
+ ? "font-medium text-violet-600 dark:text-violet-400"
51
+ : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
52
+ }`}
53
+ >
54
+ {h.text}
55
+ </a>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ </div>
60
+ </aside>
61
+ );
62
+ }
@@ -4,7 +4,7 @@ export default function TagBadge({ tag }: { tag: string }) {
4
4
  return (
5
5
  <Link
6
6
  href={`/search/?q=${encodeURIComponent(tag)}`}
7
- className="inline-block px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
7
+ className="inline-block rounded-full border border-slate-300 px-2.5 py-0.5 text-xs text-violet-600 transition-colors hover:border-violet-400 hover:bg-violet-50 dark:border-slate-600 dark:text-violet-400 dark:hover:border-violet-500 dark:hover:bg-violet-900/20"
8
8
  >
9
9
  {tag}
10
10
  </Link>
@@ -1,28 +1,55 @@
1
1
  "use client";
2
2
 
3
3
  import { useTheme } from "next-themes";
4
- import { useEffect, useState } from "react";
4
+ import { useSyncExternalStore } from "react";
5
+
6
+ const emptySubscribe = () => () => {};
7
+ const getSnapshot = () => true;
8
+ const getServerSnapshot = () => false;
5
9
 
6
10
  export default function ThemeToggle() {
7
11
  const { theme, setTheme } = useTheme();
8
- const [mounted, setMounted] = useState(false);
12
+ const mounted = useSyncExternalStore(
13
+ emptySubscribe,
14
+ getSnapshot,
15
+ getServerSnapshot,
16
+ );
9
17
 
10
- useEffect(() => setMounted(true), []);
11
- if (!mounted) return <div className="w-9 h-9" />;
18
+ if (!mounted) return <div className="h-9 w-9" />;
12
19
 
13
20
  return (
14
21
  <button
15
22
  onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
16
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
23
+ className="dark:hover:bg-navy-800 cursor-pointer rounded-lg p-2 transition-colors hover:bg-slate-100"
17
24
  aria-label="Toggle theme"
18
25
  >
19
26
  {theme === "dark" ? (
20
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
21
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
27
+ <svg
28
+ className="h-5 w-5"
29
+ fill="none"
30
+ viewBox="0 0 24 24"
31
+ stroke="currentColor"
32
+ >
33
+ <path
34
+ strokeLinecap="round"
35
+ strokeLinejoin="round"
36
+ strokeWidth={2}
37
+ d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
38
+ />
22
39
  </svg>
23
40
  ) : (
24
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
25
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
41
+ <svg
42
+ className="h-5 w-5"
43
+ fill="none"
44
+ viewBox="0 0 24 24"
45
+ stroke="currentColor"
46
+ >
47
+ <path
48
+ strokeLinecap="round"
49
+ strokeLinejoin="round"
50
+ strokeWidth={2}
51
+ d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
52
+ />
26
53
  </svg>
27
54
  )}
28
55
  </button>
@@ -0,0 +1,41 @@
1
+ import Image from "next/image";
2
+
3
+ export default function Footer() {
4
+ return (
5
+ <footer className="dark:bg-navy-950 fixed right-0 bottom-0 left-0 z-40 border-t border-slate-200 bg-slate-50 md:left-64 lg:left-[300px] dark:border-[#a0a0a01c]">
6
+ <div className="flex flex-row flex-wrap items-center gap-1 px-6 py-3 text-center text-xs text-slate-500 dark:text-slate-500">
7
+ <div className="flex items-center gap-2">
8
+ <Image src="/logo.svg" alt="Coding Friend" width={20} height={20} />
9
+ <span>
10
+ Powered by{" "}
11
+ <a
12
+ href="https://github.com/dinhanhthi/coding-friend"
13
+ target="_blank"
14
+ rel="noopener noreferrer"
15
+ className="text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
16
+ >
17
+ Coding Friend
18
+ </a>
19
+ , developed by{" "}
20
+ <a
21
+ href="https://dinhanhthi.com"
22
+ target="_blank"
23
+ rel="noopener noreferrer"
24
+ className="text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
25
+ >
26
+ Anh-Thi Dinh
27
+ </a>
28
+ .
29
+ </span>
30
+ </div>
31
+ <div>
32
+ Learning notes hosted locally with{" "}
33
+ <code className="rounded border border-slate-300 px-1 py-0.5 text-xs dark:border-slate-600">
34
+ cf host
35
+ </code>
36
+ .
37
+ </div>
38
+ </div>
39
+ </footer>
40
+ );
41
+ }