coding-friend-cli 1.0.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 (61) hide show
  1. package/dist/chunk-6CGGT2FD.js +32 -0
  2. package/dist/chunk-6DUFTBTO.js +14 -0
  3. package/dist/chunk-AQXTNLQD.js +39 -0
  4. package/dist/chunk-HRVSKMNA.js +31 -0
  5. package/dist/chunk-IUTXHCP7.js +28 -0
  6. package/dist/chunk-KZT4AFDW.js +44 -0
  7. package/dist/chunk-VHZQ6KEU.js +73 -0
  8. package/dist/host-3GAEZKKJ.js +83 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.js +34 -0
  11. package/dist/init-ONRXFOZ5.js +431 -0
  12. package/dist/json-2XS56OJY.js +10 -0
  13. package/dist/mcp-LMMIFH4B.js +104 -0
  14. package/dist/postinstall.d.ts +1 -0
  15. package/dist/postinstall.js +8 -0
  16. package/dist/statusline-7D6YU5YM.js +64 -0
  17. package/dist/update-K5PYOB52.js +160 -0
  18. package/lib/learn-host/next-env.d.ts +6 -0
  19. package/lib/learn-host/next.config.ts +9 -0
  20. package/lib/learn-host/package-lock.json +3943 -0
  21. package/lib/learn-host/package.json +31 -0
  22. package/lib/learn-host/postcss.config.mjs +7 -0
  23. package/lib/learn-host/scripts/build-search-index.ts +11 -0
  24. package/lib/learn-host/src/app/[category]/[slug]/page.tsx +61 -0
  25. package/lib/learn-host/src/app/[category]/page.tsx +35 -0
  26. package/lib/learn-host/src/app/globals.css +31 -0
  27. package/lib/learn-host/src/app/layout.tsx +32 -0
  28. package/lib/learn-host/src/app/page.tsx +63 -0
  29. package/lib/learn-host/src/app/search/page.tsx +94 -0
  30. package/lib/learn-host/src/app/search/search-index.json +42 -0
  31. package/lib/learn-host/src/components/Breadcrumbs.tsx +28 -0
  32. package/lib/learn-host/src/components/DocCard.tsx +32 -0
  33. package/lib/learn-host/src/components/MarkdownRenderer.tsx +13 -0
  34. package/lib/learn-host/src/components/MobileNav.tsx +56 -0
  35. package/lib/learn-host/src/components/SearchBar.tsx +36 -0
  36. package/lib/learn-host/src/components/Sidebar.tsx +44 -0
  37. package/lib/learn-host/src/components/TagBadge.tsx +12 -0
  38. package/lib/learn-host/src/components/ThemeToggle.tsx +30 -0
  39. package/lib/learn-host/src/lib/docs.ts +113 -0
  40. package/lib/learn-host/src/lib/search.ts +27 -0
  41. package/lib/learn-host/src/lib/types.ts +31 -0
  42. package/lib/learn-host/tsconfig.json +21 -0
  43. package/lib/learn-mcp/package-lock.json +1829 -0
  44. package/lib/learn-mcp/package.json +24 -0
  45. package/lib/learn-mcp/src/bin/learn-mcp.ts +2 -0
  46. package/lib/learn-mcp/src/index.ts +17 -0
  47. package/lib/learn-mcp/src/lib/docs.ts +199 -0
  48. package/lib/learn-mcp/src/lib/knowledge.ts +95 -0
  49. package/lib/learn-mcp/src/lib/types.ts +36 -0
  50. package/lib/learn-mcp/src/server.ts +22 -0
  51. package/lib/learn-mcp/src/tools/create-doc.ts +29 -0
  52. package/lib/learn-mcp/src/tools/get-review-list.ts +29 -0
  53. package/lib/learn-mcp/src/tools/improve-doc.ts +95 -0
  54. package/lib/learn-mcp/src/tools/list-categories.ts +19 -0
  55. package/lib/learn-mcp/src/tools/list-docs.ts +30 -0
  56. package/lib/learn-mcp/src/tools/read-doc.ts +29 -0
  57. package/lib/learn-mcp/src/tools/search-docs.ts +23 -0
  58. package/lib/learn-mcp/src/tools/track-knowledge.ts +35 -0
  59. package/lib/learn-mcp/src/tools/update-doc.ts +43 -0
  60. package/lib/learn-mcp/tsconfig.json +15 -0
  61. package/package.json +47 -0
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "coding-friend-learn-host",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "prebuild": "tsx scripts/build-search-index.ts",
8
+ "build": "npm run prebuild && next build",
9
+ "start": "npx serve out"
10
+ },
11
+ "dependencies": {
12
+ "next": "^15.1.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "react-markdown": "^9.0.0",
16
+ "remark-gfm": "^4.0.0",
17
+ "rehype-highlight": "^7.0.0",
18
+ "gray-matter": "^4.0.3",
19
+ "next-themes": "^0.4.0"
20
+ },
21
+ "devDependencies": {
22
+ "typescript": "^5.7.0",
23
+ "@types/node": "^22.0.0",
24
+ "@types/react": "^19.0.0",
25
+ "@types/react-dom": "^19.0.0",
26
+ "tailwindcss": "^4.0.0",
27
+ "@tailwindcss/postcss": "^4.0.0",
28
+ "@tailwindcss/typography": "^0.5.0",
29
+ "tsx": "^4.0.0"
30
+ }
31
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,11 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { buildSearchIndex } from "../src/lib/search";
4
+
5
+ const index = buildSearchIndex();
6
+ const outPath = path.join(process.cwd(), "src/app/search/search-index.json");
7
+
8
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
9
+ fs.writeFileSync(outPath, JSON.stringify(index, null, 2));
10
+
11
+ console.log(`Search index built: ${index.length} entries -> ${outPath}`);
@@ -0,0 +1,61 @@
1
+ import { getAllDocs, getDocBySlug } from "@/lib/docs";
2
+ import Breadcrumbs from "@/components/Breadcrumbs";
3
+ import MarkdownRenderer from "@/components/MarkdownRenderer";
4
+ import TagBadge from "@/components/TagBadge";
5
+ import { notFound } from "next/navigation";
6
+
7
+ export async function generateStaticParams() {
8
+ return getAllDocs().map((d) => ({
9
+ category: d.category,
10
+ slug: d.slug,
11
+ }));
12
+ }
13
+
14
+ export const dynamicParams = false;
15
+
16
+ export default async function DocPage({
17
+ params,
18
+ }: {
19
+ params: Promise<{ category: string; slug: string }>;
20
+ }) {
21
+ const { category, slug } = await params;
22
+ const doc = getDocBySlug(category, slug);
23
+ if (!doc) notFound();
24
+
25
+ const categoryDisplay = category.replace(/[_-]/g, " ");
26
+
27
+ return (
28
+ <article>
29
+ <Breadcrumbs
30
+ crumbs={[
31
+ { label: categoryDisplay, href: `/${category}/` },
32
+ { label: doc.frontmatter.title },
33
+ ]}
34
+ />
35
+
36
+ <header className="mb-8">
37
+ <h1 className="text-3xl font-bold mb-2">{doc.frontmatter.title}</h1>
38
+ <div className="flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
39
+ <span className="capitalize px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">
40
+ {categoryDisplay}
41
+ </span>
42
+ {doc.frontmatter.created && (
43
+ <span>Created: {doc.frontmatter.created}</span>
44
+ )}
45
+ {doc.frontmatter.updated && (
46
+ <span>Updated: {doc.frontmatter.updated}</span>
47
+ )}
48
+ </div>
49
+ {doc.frontmatter.tags.length > 0 && (
50
+ <div className="flex flex-wrap gap-1.5 mt-3">
51
+ {doc.frontmatter.tags.map((tag) => (
52
+ <TagBadge key={tag} tag={tag} />
53
+ ))}
54
+ </div>
55
+ )}
56
+ </header>
57
+
58
+ <MarkdownRenderer content={doc.content} />
59
+ </article>
60
+ );
61
+ }
@@ -0,0 +1,35 @@
1
+ import { getAllCategories, getDocsByCategory } from "@/lib/docs";
2
+ import DocCard from "@/components/DocCard";
3
+ import Breadcrumbs from "@/components/Breadcrumbs";
4
+
5
+ export async function generateStaticParams() {
6
+ return getAllCategories().map((c) => ({ category: c.name }));
7
+ }
8
+
9
+ export const dynamicParams = false;
10
+
11
+ export default async function CategoryPage({
12
+ params,
13
+ }: {
14
+ params: Promise<{ category: string }>;
15
+ }) {
16
+ const { category } = await params;
17
+ const docs = getDocsByCategory(category);
18
+ const displayName = category.replace(/[_-]/g, " ");
19
+
20
+ return (
21
+ <div>
22
+ <Breadcrumbs crumbs={[{ label: displayName }]} />
23
+ <h1 className="text-2xl font-bold capitalize mb-1">{displayName}</h1>
24
+ <p className="text-gray-500 dark:text-gray-400 mb-6">
25
+ {docs.length} {docs.length === 1 ? "doc" : "docs"}
26
+ </p>
27
+
28
+ <div className="grid gap-3">
29
+ {docs.map((doc) => (
30
+ <DocCard key={doc.slug} doc={doc} />
31
+ ))}
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+
4
+ @custom-variant dark (&:where(.dark, .dark *));
5
+
6
+ @theme {
7
+ --color-primary: #3b82f6;
8
+ --color-primary-dark: #60a5fa;
9
+ --color-surface: #ffffff;
10
+ --color-surface-dark: #111827;
11
+ --color-surface-secondary: #f9fafb;
12
+ --color-surface-secondary-dark: #1f2937;
13
+ --color-border: #e5e7eb;
14
+ --color-border-dark: #374151;
15
+ --color-text: #111827;
16
+ --color-text-dark: #f9fafb;
17
+ --color-text-muted: #6b7280;
18
+ --color-text-muted-dark: #9ca3af;
19
+ }
20
+
21
+ html {
22
+ scroll-behavior: smooth;
23
+ }
24
+
25
+ /* Syntax highlighting */
26
+ pre code.hljs {
27
+ padding: 1rem;
28
+ border-radius: 0.5rem;
29
+ font-size: 0.875rem;
30
+ line-height: 1.7;
31
+ }
@@ -0,0 +1,32 @@
1
+ import type { Metadata } from "next";
2
+ import { ThemeProvider } from "next-themes";
3
+ import { getAllCategories } from "@/lib/docs";
4
+ import Sidebar from "@/components/Sidebar";
5
+ import MobileNav from "@/components/MobileNav";
6
+ import "./globals.css";
7
+ import "highlight.js/styles/github-dark.css";
8
+
9
+ export const metadata: Metadata = {
10
+ title: "Learning Notes",
11
+ description: "Personal learning knowledge base",
12
+ };
13
+
14
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
15
+ const categories = getAllCategories();
16
+
17
+ return (
18
+ <html lang="en" suppressHydrationWarning>
19
+ <body className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
20
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
21
+ <MobileNav categories={categories} />
22
+ <div className="flex">
23
+ <Sidebar categories={categories} />
24
+ <main className="flex-1 min-h-screen p-6 md:p-8 max-w-4xl">
25
+ {children}
26
+ </main>
27
+ </div>
28
+ </ThemeProvider>
29
+ </body>
30
+ </html>
31
+ );
32
+ }
@@ -0,0 +1,63 @@
1
+ import { getAllCategories, getAllDocs, getAllTags } from "@/lib/docs";
2
+ import DocCard from "@/components/DocCard";
3
+ import TagBadge from "@/components/TagBadge";
4
+ import Link from "next/link";
5
+
6
+ export default function HomePage() {
7
+ const categories = getAllCategories();
8
+ const docs = getAllDocs();
9
+ const tags = getAllTags();
10
+ const recentDocs = docs.slice(0, 10);
11
+
12
+ return (
13
+ <div>
14
+ <h1 className="text-3xl font-bold mb-2">Learning Notes</h1>
15
+ <p className="text-gray-500 dark:text-gray-400 mb-8">
16
+ {docs.length} docs across {categories.length} categories
17
+ </p>
18
+
19
+ {/* Categories */}
20
+ <section className="mb-8">
21
+ <h2 className="text-lg font-semibold mb-3">Categories</h2>
22
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
23
+ {categories.map((cat) => (
24
+ <Link
25
+ key={cat.name}
26
+ href={`/${cat.name}/`}
27
+ className="p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 transition-colors"
28
+ >
29
+ <div className="font-medium capitalize text-gray-900 dark:text-gray-100">
30
+ {cat.name.replace(/[_-]/g, " ")}
31
+ </div>
32
+ <div className="text-sm text-gray-500 dark:text-gray-400">
33
+ {cat.docCount} {cat.docCount === 1 ? "doc" : "docs"}
34
+ </div>
35
+ </Link>
36
+ ))}
37
+ </div>
38
+ </section>
39
+
40
+ {/* Tags */}
41
+ {tags.length > 0 && (
42
+ <section className="mb-8">
43
+ <h2 className="text-lg font-semibold mb-3">Tags</h2>
44
+ <div className="flex flex-wrap gap-2">
45
+ {tags.slice(0, 20).map(({ tag }) => (
46
+ <TagBadge key={tag} tag={tag} />
47
+ ))}
48
+ </div>
49
+ </section>
50
+ )}
51
+
52
+ {/* Recent Docs */}
53
+ <section>
54
+ <h2 className="text-lg font-semibold mb-3">Recently Updated</h2>
55
+ <div className="grid gap-3">
56
+ {recentDocs.map((doc) => (
57
+ <DocCard key={`${doc.category}/${doc.slug}`} doc={doc} />
58
+ ))}
59
+ </div>
60
+ </section>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { Suspense } from "react";
6
+ import type { SearchIndexEntry } from "@/lib/types";
7
+
8
+ // Search index embedded at build time
9
+ import searchIndexData from "./search-index.json";
10
+
11
+ function SearchContent() {
12
+ const searchParams = useSearchParams();
13
+ const initialQuery = searchParams.get("q") ?? "";
14
+ const [query, setQuery] = useState(initialQuery);
15
+ const [results, setResults] = useState<SearchIndexEntry[]>([]);
16
+
17
+ useEffect(() => {
18
+ if (!query.trim()) {
19
+ setResults([]);
20
+ return;
21
+ }
22
+ const lower = query.toLowerCase();
23
+ const filtered = (searchIndexData as SearchIndexEntry[]).filter(
24
+ (entry) =>
25
+ entry.title.toLowerCase().includes(lower) ||
26
+ entry.tags.some((t) => t.toLowerCase().includes(lower)) ||
27
+ entry.excerpt.toLowerCase().includes(lower) ||
28
+ entry.category.toLowerCase().includes(lower),
29
+ );
30
+ setResults(filtered);
31
+ }, [query]);
32
+
33
+ useEffect(() => {
34
+ setQuery(initialQuery);
35
+ }, [initialQuery]);
36
+
37
+ return (
38
+ <div>
39
+ <h1 className="text-2xl font-bold mb-4">Search</h1>
40
+ <input
41
+ type="text"
42
+ value={query}
43
+ onChange={(e) => setQuery(e.target.value)}
44
+ placeholder="Search docs..."
45
+ 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"
46
+ autoFocus
47
+ />
48
+
49
+ {query.trim() && (
50
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
51
+ {results.length} {results.length === 1 ? "result" : "results"} for &ldquo;{query}&rdquo;
52
+ </p>
53
+ )}
54
+
55
+ <div className="grid gap-3">
56
+ {results.map((entry) => (
57
+ <a
58
+ key={`${entry.category}/${entry.slug}`}
59
+ href={`/${entry.category}/${entry.slug}/`}
60
+ 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"
61
+ >
62
+ <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
63
+ {entry.title}
64
+ </h3>
65
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-2 line-clamp-2">
66
+ {entry.excerpt}
67
+ </p>
68
+ <div className="flex items-center gap-2">
69
+ <span className="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded capitalize">
70
+ {entry.category.replace(/[_-]/g, " ")}
71
+ </span>
72
+ {entry.tags.slice(0, 3).map((tag) => (
73
+ <span
74
+ key={tag}
75
+ className="text-xs px-2 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full"
76
+ >
77
+ {tag}
78
+ </span>
79
+ ))}
80
+ </div>
81
+ </a>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export default function SearchPage() {
89
+ return (
90
+ <Suspense fallback={<div>Loading...</div>}>
91
+ <SearchContent />
92
+ </Suspense>
93
+ );
94
+ }
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "slug": "claude-code-plugin-hooks-system",
4
+ "category": "Tools_Workflow",
5
+ "title": "Claude Code Plugin & Hooks System",
6
+ "tags": [
7
+ "claude-code",
8
+ "plugins",
9
+ "hooks",
10
+ "configuration"
11
+ ],
12
+ "excerpt": "Claude Code has a plugin system that can load hooks (scripts that run on specific events like `SessionStart`, `PreToolUse`, etc.) from **multiple sources simult..."
13
+ },
14
+ {
15
+ "slug": "tauri-desktop-app-gotchas",
16
+ "category": "Mobile_Dev",
17
+ "title": "Tauri Desktop App Gotchas",
18
+ "tags": [
19
+ "tauri",
20
+ "desktop",
21
+ "webview",
22
+ "sidecar",
23
+ "macos",
24
+ "gatekeeper",
25
+ "race-condition"
26
+ ],
27
+ "excerpt": "Tổng hợp các vấn đề thường gặp khi build và distribute desktop app bằng Tauri v2, đặc biệt trên macOS. **What:** Khi user tải app `.dmg` từ GitHub Releases và c..."
28
+ },
29
+ {
30
+ "slug": "typescript-type-casting-patterns",
31
+ "category": "Programming_Languages",
32
+ "title": "TypeScript Type Casting Patterns & Gotchas",
33
+ "tags": [
34
+ "typescript",
35
+ "type-casting",
36
+ "type-narrowing",
37
+ "window",
38
+ "strict-mode"
39
+ ],
40
+ "excerpt": "Tổng hợp các pattern cast type phổ biến và gotchas khi dùng TypeScript strict mode. **What:** Khi TypeScript không cho phép cast trực tiếp giữa 2 type vì chúng ..."
41
+ }
42
+ ]
@@ -0,0 +1,28 @@
1
+ import Link from "next/link";
2
+
3
+ interface Crumb {
4
+ label: string;
5
+ href?: string;
6
+ }
7
+
8
+ export default function Breadcrumbs({ crumbs }: { crumbs: Crumb[] }) {
9
+ return (
10
+ <nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
11
+ <Link href="/" className="hover:text-blue-600 dark:hover:text-blue-400">
12
+ Home
13
+ </Link>
14
+ {crumbs.map((crumb, i) => (
15
+ <span key={i} className="flex items-center gap-2">
16
+ <span>/</span>
17
+ {crumb.href ? (
18
+ <Link href={crumb.href} className="hover:text-blue-600 dark:hover:text-blue-400 capitalize">
19
+ {crumb.label}
20
+ </Link>
21
+ ) : (
22
+ <span className="text-gray-700 dark:text-gray-300">{crumb.label}</span>
23
+ )}
24
+ </span>
25
+ ))}
26
+ </nav>
27
+ );
28
+ }
@@ -0,0 +1,32 @@
1
+ import Link from "next/link";
2
+ import TagBadge from "./TagBadge";
3
+ import type { DocMeta } from "@/lib/types";
4
+
5
+ export default function DocCard({ doc }: { doc: DocMeta }) {
6
+ return (
7
+ <Link
8
+ href={`/${doc.category}/${doc.slug}/`}
9
+ 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"
10
+ >
11
+ <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
12
+ {doc.frontmatter.title}
13
+ </h3>
14
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-2 line-clamp-2">
15
+ {doc.excerpt}
16
+ </p>
17
+ <div className="flex items-center justify-between">
18
+ <div className="flex gap-1 flex-wrap">
19
+ {doc.frontmatter.tags.slice(0, 3).map((tag) => (
20
+ <TagBadge key={tag} tag={tag} />
21
+ ))}
22
+ {doc.frontmatter.tags.length > 3 && (
23
+ <span className="text-xs text-gray-400">+{doc.frontmatter.tags.length - 3}</span>
24
+ )}
25
+ </div>
26
+ <span className="text-xs text-gray-400">
27
+ {doc.frontmatter.updated || doc.frontmatter.created}
28
+ </span>
29
+ </div>
30
+ </Link>
31
+ );
32
+ }
@@ -0,0 +1,13 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import rehypeHighlight from "rehype-highlight";
4
+
5
+ export default function MarkdownRenderer({ content }: { content: string }) {
6
+ return (
7
+ <div className="prose prose-gray dark:prose-invert max-w-none prose-headings:scroll-mt-20 prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:before:content-none prose-code:after:content-none">
8
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
9
+ {content}
10
+ </ReactMarkdown>
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useState } from "react";
5
+ import type { CategoryInfo } from "@/lib/types";
6
+ import SearchBar from "./SearchBar";
7
+ import ThemeToggle from "./ThemeToggle";
8
+
9
+ export default function MobileNav({ categories }: { categories: CategoryInfo[] }) {
10
+ const [open, setOpen] = useState(false);
11
+
12
+ 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>
35
+
36
+ {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) => (
41
+ <Link
42
+ key={cat.name}
43
+ href={`/${cat.name}/`}
44
+ 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"
46
+ >
47
+ <span className="capitalize">{cat.name.replace(/[_-]/g, " ")}</span>
48
+ <span className="text-xs text-gray-400">{cat.docCount}</span>
49
+ </Link>
50
+ ))}
51
+ </nav>
52
+ </div>
53
+ )}
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState } from "react";
5
+
6
+ export default function SearchBar() {
7
+ const [query, setQuery] = useState("");
8
+ const router = useRouter();
9
+
10
+ function handleSubmit(e: React.FormEvent) {
11
+ e.preventDefault();
12
+ if (query.trim()) {
13
+ router.push(`/search/?q=${encodeURIComponent(query.trim())}`);
14
+ }
15
+ }
16
+
17
+ return (
18
+ <form onSubmit={handleSubmit} className="relative">
19
+ <svg
20
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
21
+ fill="none"
22
+ viewBox="0 0 24 24"
23
+ stroke="currentColor"
24
+ >
25
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
26
+ </svg>
27
+ <input
28
+ type="text"
29
+ value={query}
30
+ onChange={(e) => setQuery(e.target.value)}
31
+ placeholder="Search docs..."
32
+ className="w-full pl-10 pr-4 py-2 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 dark:focus:ring-blue-400"
33
+ />
34
+ </form>
35
+ );
36
+ }
@@ -0,0 +1,44 @@
1
+ import Link from "next/link";
2
+ import type { CategoryInfo } from "@/lib/types";
3
+ import ThemeToggle from "./ThemeToggle";
4
+ import SearchBar from "./SearchBar";
5
+
6
+ export default function Sidebar({ categories }: { categories: CategoryInfo[] }) {
7
+ const totalDocs = categories.reduce((sum, c) => sum + c.docCount, 0);
8
+
9
+ 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) => (
25
+ <Link
26
+ key={cat.name}
27
+ 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"
29
+ >
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
+ {cat.docCount}
33
+ </span>
34
+ </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>
42
+ </aside>
43
+ );
44
+ }
@@ -0,0 +1,12 @@
1
+ import Link from "next/link";
2
+
3
+ export default function TagBadge({ tag }: { tag: string }) {
4
+ return (
5
+ <Link
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"
8
+ >
9
+ {tag}
10
+ </Link>
11
+ );
12
+ }