@barodoc/theme-docs 0.0.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +68 -0
  3. package/src/components/CodeCopy.astro +173 -0
  4. package/src/components/DocHeader.tsx +166 -0
  5. package/src/components/DocsSidebar.tsx +84 -0
  6. package/src/components/Header.astro +32 -0
  7. package/src/components/LanguageSwitcher.astro +77 -0
  8. package/src/components/MobileNav.astro +61 -0
  9. package/src/components/MobileNavSheet.tsx +73 -0
  10. package/src/components/Search.tsx +210 -0
  11. package/src/components/SearchDialog.tsx +83 -0
  12. package/src/components/Sidebar.astro +56 -0
  13. package/src/components/SidebarWrapper.tsx +24 -0
  14. package/src/components/TableOfContents.astro +98 -0
  15. package/src/components/ThemeScript.astro +11 -0
  16. package/src/components/ThemeToggle.tsx +57 -0
  17. package/src/components/api/ApiEndpoint.astro +36 -0
  18. package/src/components/api/ApiParam.astro +26 -0
  19. package/src/components/api/ApiParams.astro +16 -0
  20. package/src/components/api/ApiResponse.astro +35 -0
  21. package/src/components/index.ts +30 -0
  22. package/src/components/mdx/Accordion.tsx +61 -0
  23. package/src/components/mdx/Badge.tsx +33 -0
  24. package/src/components/mdx/Callout.astro +79 -0
  25. package/src/components/mdx/Card.astro +66 -0
  26. package/src/components/mdx/CardGroup.astro +18 -0
  27. package/src/components/mdx/CodeGroup.astro +63 -0
  28. package/src/components/mdx/CodeGroup.tsx +51 -0
  29. package/src/components/mdx/Columns.tsx +31 -0
  30. package/src/components/mdx/DocAccordion.tsx +87 -0
  31. package/src/components/mdx/DocCallout.tsx +65 -0
  32. package/src/components/mdx/DocCard.tsx +70 -0
  33. package/src/components/mdx/DocTabs.tsx +48 -0
  34. package/src/components/mdx/Expandable.tsx +107 -0
  35. package/src/components/mdx/FileTree.tsx +72 -0
  36. package/src/components/mdx/Frame.tsx +23 -0
  37. package/src/components/mdx/Icon.tsx +59 -0
  38. package/src/components/mdx/Mermaid.tsx +94 -0
  39. package/src/components/mdx/ParamField.tsx +76 -0
  40. package/src/components/mdx/ResponseField.tsx +62 -0
  41. package/src/components/mdx/Step.astro +14 -0
  42. package/src/components/mdx/Steps.astro +37 -0
  43. package/src/components/mdx/Steps.tsx +49 -0
  44. package/src/components/mdx/Tabs.tsx +67 -0
  45. package/src/components/mdx/Tooltip.tsx +36 -0
  46. package/src/components/ui/accordion.tsx +54 -0
  47. package/src/components/ui/alert.tsx +60 -0
  48. package/src/components/ui/button.tsx +57 -0
  49. package/src/components/ui/card.tsx +75 -0
  50. package/src/components/ui/collapsible.tsx +9 -0
  51. package/src/components/ui/dialog.tsx +119 -0
  52. package/src/components/ui/index.ts +11 -0
  53. package/src/components/ui/scroll-area.tsx +45 -0
  54. package/src/components/ui/separator.tsx +28 -0
  55. package/src/components/ui/sheet.tsx +137 -0
  56. package/src/components/ui/tabs.tsx +52 -0
  57. package/src/components/ui/tooltip.tsx +29 -0
  58. package/src/index.ts +74 -0
  59. package/src/layouts/BaseLayout.astro +28 -0
  60. package/src/layouts/DocsLayout.astro +121 -0
  61. package/src/lib/utils.ts +6 -0
  62. package/src/pages/docs/[...slug].astro +116 -0
  63. package/src/pages/index.astro +217 -0
  64. package/src/styles/global.css +342 -0
@@ -0,0 +1,73 @@
1
+ import * as React from "react";
2
+ import {
3
+ Sheet,
4
+ SheetContent,
5
+ SheetHeader,
6
+ SheetTitle,
7
+ } from "./ui/sheet";
8
+ import { ScrollArea } from "./ui/scroll-area";
9
+ import { DocsSidebar } from "./DocsSidebar";
10
+ import { cn } from "../lib/utils";
11
+
12
+ interface NavItem {
13
+ title: string;
14
+ href: string;
15
+ isActive?: boolean;
16
+ }
17
+
18
+ interface NavGroup {
19
+ title: string;
20
+ items: NavItem[];
21
+ defaultOpen?: boolean;
22
+ }
23
+
24
+ interface MobileNavSheetProps {
25
+ groups: NavGroup[];
26
+ siteName: string;
27
+ logo?: string;
28
+ }
29
+
30
+ export function MobileNavSheet({ groups, siteName, logo }: MobileNavSheetProps) {
31
+ const [open, setOpen] = React.useState(false);
32
+
33
+ React.useEffect(() => {
34
+ const handleToggle = () => setOpen((prev) => !prev);
35
+ document.addEventListener("toggle-mobile-nav", handleToggle);
36
+ return () => document.removeEventListener("toggle-mobile-nav", handleToggle);
37
+ }, []);
38
+
39
+ // Close on navigation
40
+ React.useEffect(() => {
41
+ const handleClick = (e: MouseEvent) => {
42
+ const target = e.target as HTMLElement;
43
+ if (target.closest("a")) {
44
+ setOpen(false);
45
+ }
46
+ };
47
+
48
+ if (open) {
49
+ document.addEventListener("click", handleClick);
50
+ return () => document.removeEventListener("click", handleClick);
51
+ }
52
+ }, [open]);
53
+
54
+ return (
55
+ <Sheet open={open} onOpenChange={setOpen}>
56
+ <SheetContent side="left" className="w-80 p-0">
57
+ <SheetHeader className="px-6 py-4 border-b border-[var(--color-border)]">
58
+ <SheetTitle className="flex items-center gap-2">
59
+ {logo && <img src={logo} alt={siteName} className="h-6 w-6" />}
60
+ <span>{siteName}</span>
61
+ </SheetTitle>
62
+ </SheetHeader>
63
+ <ScrollArea className="h-[calc(100vh-65px)]">
64
+ <div className="px-2 py-4">
65
+ <DocsSidebar groups={groups} />
66
+ </div>
67
+ </ScrollArea>
68
+ </SheetContent>
69
+ </Sheet>
70
+ );
71
+ }
72
+
73
+ export default MobileNavSheet;
@@ -0,0 +1,210 @@
1
+ import { useEffect, useState, useRef, useCallback } from "react";
2
+
3
+ interface SearchResult {
4
+ url: string;
5
+ meta: {
6
+ title: string;
7
+ };
8
+ excerpt: string;
9
+ }
10
+
11
+ // Pagefind loader - only runs on client side
12
+ async function loadPagefind() {
13
+ if (typeof window === "undefined") return null;
14
+
15
+ try {
16
+ // @ts-expect-error - Pagefind exposes itself on window when loaded
17
+ if (window.pagefind) {
18
+ // @ts-expect-error
19
+ return window.pagefind;
20
+ }
21
+
22
+ // Dynamic import using Function constructor to avoid Vite bundling
23
+ const pagefindPath = "/pagefind/pagefind.js";
24
+ const pagefind = await new Function(`return import("${pagefindPath}")`)();
25
+ await pagefind.init();
26
+ // @ts-expect-error
27
+ window.pagefind = pagefind;
28
+ return pagefind;
29
+ } catch (e) {
30
+ console.warn("Pagefind not available:", e);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function Search() {
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const [query, setQuery] = useState("");
38
+ const [results, setResults] = useState<SearchResult[]>([]);
39
+ const [selectedIndex, setSelectedIndex] = useState(0);
40
+ const [pagefind, setPagefind] = useState<any>(null);
41
+ const inputRef = useRef<HTMLInputElement>(null);
42
+ const dialogRef = useRef<HTMLDialogElement>(null);
43
+
44
+ // Load Pagefind
45
+ useEffect(() => {
46
+ loadPagefind().then(setPagefind);
47
+ }, []);
48
+
49
+ // Listen for toggle-search event
50
+ useEffect(() => {
51
+ const handler = () => setIsOpen(true);
52
+ document.addEventListener("toggle-search", handler);
53
+ return () => document.removeEventListener("toggle-search", handler);
54
+ }, []);
55
+
56
+ // Handle keyboard shortcuts
57
+ useEffect(() => {
58
+ const handleKeyDown = (e: KeyboardEvent) => {
59
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
60
+ e.preventDefault();
61
+ setIsOpen(true);
62
+ }
63
+ if (e.key === "Escape") {
64
+ setIsOpen(false);
65
+ }
66
+ };
67
+ document.addEventListener("keydown", handleKeyDown);
68
+ return () => document.removeEventListener("keydown", handleKeyDown);
69
+ }, []);
70
+
71
+ // Focus input when opening
72
+ useEffect(() => {
73
+ if (isOpen) {
74
+ inputRef.current?.focus();
75
+ dialogRef.current?.showModal();
76
+ } else {
77
+ dialogRef.current?.close();
78
+ setQuery("");
79
+ setResults([]);
80
+ setSelectedIndex(0);
81
+ }
82
+ }, [isOpen]);
83
+
84
+ // Search with debounce
85
+ const search = useCallback(
86
+ async (searchQuery: string) => {
87
+ if (!pagefind || !searchQuery.trim()) {
88
+ setResults([]);
89
+ return;
90
+ }
91
+ try {
92
+ const searchResults = await pagefind.search(searchQuery);
93
+ const data = await Promise.all(
94
+ searchResults.results.slice(0, 8).map((r: any) => r.data())
95
+ );
96
+ setResults(data);
97
+ setSelectedIndex(0);
98
+ } catch (e) {
99
+ console.error("Search error:", e);
100
+ setResults([]);
101
+ }
102
+ },
103
+ [pagefind]
104
+ );
105
+
106
+ useEffect(() => {
107
+ const timer = setTimeout(() => search(query), 150);
108
+ return () => clearTimeout(timer);
109
+ }, [query, search]);
110
+
111
+ // Navigate results with keyboard
112
+ const handleKeyDown = (e: React.KeyboardEvent) => {
113
+ if (e.key === "ArrowDown") {
114
+ e.preventDefault();
115
+ setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
116
+ } else if (e.key === "ArrowUp") {
117
+ e.preventDefault();
118
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
119
+ } else if (e.key === "Enter" && results[selectedIndex]) {
120
+ window.location.href = results[selectedIndex].url;
121
+ }
122
+ };
123
+
124
+ if (!isOpen) return null;
125
+
126
+ return (
127
+ <dialog
128
+ ref={dialogRef}
129
+ className="fixed inset-0 z-50 bg-transparent p-0 m-0 max-w-none max-h-none w-full h-full"
130
+ onClick={(e) => {
131
+ if (e.target === dialogRef.current) setIsOpen(false);
132
+ }}
133
+ >
134
+ <div className="fixed inset-0 bg-black/50" aria-hidden="true" />
135
+ <div className="fixed inset-x-4 top-[15%] md:inset-x-auto md:left-1/2 md:-translate-x-1/2 md:w-full md:max-w-lg">
136
+ <div className="bg-[var(--color-bg)] rounded-xl shadow-2xl border border-[var(--color-border)] overflow-hidden">
137
+ <div className="flex items-center px-4 border-b border-[var(--color-border)]">
138
+ <svg
139
+ className="w-5 h-5 text-[var(--color-text-secondary)]"
140
+ fill="none"
141
+ stroke="currentColor"
142
+ viewBox="0 0 24 24"
143
+ >
144
+ <path
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ strokeWidth={2}
148
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
149
+ />
150
+ </svg>
151
+ <input
152
+ ref={inputRef}
153
+ type="text"
154
+ value={query}
155
+ onChange={(e) => setQuery(e.target.value)}
156
+ onKeyDown={handleKeyDown}
157
+ placeholder="Search documentation..."
158
+ className="flex-1 px-4 py-4 bg-transparent text-[var(--color-text)] placeholder-[var(--color-text-secondary)] focus:outline-none"
159
+ />
160
+ <button
161
+ onClick={() => setIsOpen(false)}
162
+ className="px-2 py-1 text-xs text-[var(--color-text-secondary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded"
163
+ >
164
+ ESC
165
+ </button>
166
+ </div>
167
+
168
+ {query.trim() && (
169
+ <div className="max-h-[60vh] overflow-y-auto">
170
+ {results.length === 0 ? (
171
+ <div className="px-4 py-8 text-center text-[var(--color-text-secondary)]">
172
+ {pagefind ? "No results found" : "Search index not available"}
173
+ </div>
174
+ ) : (
175
+ <ul>
176
+ {results.map((result, index) => (
177
+ <li key={result.url}>
178
+ <a
179
+ href={result.url}
180
+ className={`block px-4 py-3 transition-colors ${
181
+ index === selectedIndex
182
+ ? "bg-primary-50 dark:bg-primary-900/30"
183
+ : "hover:bg-[var(--color-bg-secondary)]"
184
+ }`}
185
+ >
186
+ <div className="font-medium text-[var(--color-text)]">
187
+ {result.meta.title}
188
+ </div>
189
+ <div
190
+ className="text-sm text-[var(--color-text-secondary)] line-clamp-2 mt-0.5"
191
+ dangerouslySetInnerHTML={{ __html: result.excerpt }}
192
+ />
193
+ </a>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ )}
198
+ </div>
199
+ )}
200
+
201
+ {!query.trim() && (
202
+ <div className="px-4 py-6 text-center text-[var(--color-text-secondary)] text-sm">
203
+ Start typing to search...
204
+ </div>
205
+ )}
206
+ </div>
207
+ </div>
208
+ </dialog>
209
+ );
210
+ }
@@ -0,0 +1,83 @@
1
+ import * as React from "react";
2
+ import { Search as SearchIcon } from "lucide-react";
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "./ui/dialog";
9
+ import { cn } from "../lib/utils";
10
+
11
+ interface SearchDialogProps {
12
+ className?: string;
13
+ }
14
+
15
+ export function SearchDialog({ className }: SearchDialogProps) {
16
+ const [open, setOpen] = React.useState(false);
17
+ const [query, setQuery] = React.useState("");
18
+
19
+ React.useEffect(() => {
20
+ const handleToggle = () => setOpen((prev) => !prev);
21
+ document.addEventListener("toggle-search", handleToggle);
22
+
23
+ const down = (e: KeyboardEvent) => {
24
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
25
+ e.preventDefault();
26
+ setOpen((prev) => !prev);
27
+ }
28
+ };
29
+ document.addEventListener("keydown", down);
30
+
31
+ return () => {
32
+ document.removeEventListener("toggle-search", handleToggle);
33
+ document.removeEventListener("keydown", down);
34
+ };
35
+ }, []);
36
+
37
+ React.useEffect(() => {
38
+ if (open && typeof window !== "undefined" && (window as any).pagefind) {
39
+ // Initialize pagefind when dialog opens
40
+ }
41
+ }, [open]);
42
+
43
+ return (
44
+ <Dialog open={open} onOpenChange={setOpen}>
45
+ <DialogContent className={cn("sm:max-w-2xl p-0 gap-0", className)}>
46
+ <DialogHeader className="px-4 py-3 border-b border-[var(--color-border)]">
47
+ <DialogTitle className="sr-only">Search documentation</DialogTitle>
48
+ <div className="flex items-center gap-3">
49
+ <SearchIcon className="h-5 w-5 text-[var(--color-text-muted)]" />
50
+ <input
51
+ type="text"
52
+ placeholder="Search documentation..."
53
+ value={query}
54
+ onChange={(e) => setQuery(e.target.value)}
55
+ className="flex-1 bg-transparent text-base outline-none placeholder:text-[var(--color-text-muted)]"
56
+ autoFocus
57
+ />
58
+ <kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-muted)]">
59
+ Esc
60
+ </kbd>
61
+ </div>
62
+ </DialogHeader>
63
+ <div className="min-h-[300px] max-h-[60vh] overflow-y-auto p-4">
64
+ {query ? (
65
+ <div id="pagefind-results" className="pagefind-ui" />
66
+ ) : (
67
+ <div className="flex flex-col items-center justify-center h-full py-12 text-center">
68
+ <SearchIcon className="h-12 w-12 text-[var(--color-text-muted)] mb-4" />
69
+ <p className="text-sm text-[var(--color-text-secondary)]">
70
+ Start typing to search the documentation
71
+ </p>
72
+ <p className="text-xs text-[var(--color-text-muted)] mt-2">
73
+ Press <kbd className="px-1.5 py-0.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-xs">⌘K</kbd> to open search
74
+ </p>
75
+ </div>
76
+ )}
77
+ </div>
78
+ </DialogContent>
79
+ </Dialog>
80
+ );
81
+ }
82
+
83
+ export default SearchDialog;
@@ -0,0 +1,56 @@
1
+ ---
2
+ import config from "virtual:barodoc/config";
3
+ import { defaultLocale } from "virtual:barodoc/i18n";
4
+ import { getLocalizedNavGroup } from "@barodoc/core";
5
+ import { SidebarWrapper } from "./SidebarWrapper";
6
+
7
+ interface Props {
8
+ currentPath: string;
9
+ currentLocale?: string;
10
+ }
11
+
12
+ const { currentPath, currentLocale = defaultLocale } = Astro.props;
13
+
14
+ // Normalize path for comparison
15
+ function normalizePath(path: string): string {
16
+ return path.replace(/\/$/, '').replace(/^\//, '');
17
+ }
18
+
19
+ function isActive(page: string): boolean {
20
+ const normalized = normalizePath(currentPath);
21
+ const pagePath = normalizePath(page);
22
+ return normalized === pagePath ||
23
+ normalized === `docs/${pagePath}` ||
24
+ normalized === `${currentLocale}/docs/${pagePath}`;
25
+ }
26
+
27
+ function getPageHref(page: string): string {
28
+ if (currentLocale === defaultLocale) {
29
+ return `/docs/${page}`;
30
+ }
31
+ return `/${currentLocale}/docs/${page}`;
32
+ }
33
+
34
+ // Get page title from the page slug
35
+ function getPageTitle(page: string): string {
36
+ const parts = page.split('/');
37
+ const name = parts[parts.length - 1];
38
+ return name
39
+ .split('-')
40
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
41
+ .join(' ');
42
+ }
43
+
44
+ // Transform config.navigation to the format expected by DocsSidebar
45
+ const groups = config.navigation.map((group) => ({
46
+ title: getLocalizedNavGroup(group, currentLocale, defaultLocale),
47
+ defaultOpen: true,
48
+ items: group.pages.map((page) => ({
49
+ title: getPageTitle(page),
50
+ href: getPageHref(page),
51
+ isActive: isActive(page),
52
+ })),
53
+ }));
54
+ ---
55
+
56
+ <SidebarWrapper groups={groups} client:load />
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { DocsSidebar } from "./DocsSidebar";
3
+
4
+ interface NavItem {
5
+ title: string;
6
+ href: string;
7
+ isActive?: boolean;
8
+ }
9
+
10
+ interface NavGroup {
11
+ title: string;
12
+ items: NavItem[];
13
+ defaultOpen?: boolean;
14
+ }
15
+
16
+ interface SidebarWrapperProps {
17
+ groups: NavGroup[];
18
+ }
19
+
20
+ export function SidebarWrapper({ groups }: SidebarWrapperProps) {
21
+ return <DocsSidebar groups={groups} />;
22
+ }
23
+
24
+ export default SidebarWrapper;
@@ -0,0 +1,98 @@
1
+ ---
2
+ interface Heading {
3
+ depth: number;
4
+ slug: string;
5
+ text: string;
6
+ }
7
+
8
+ interface Props {
9
+ headings: Heading[];
10
+ }
11
+
12
+ const { headings } = Astro.props;
13
+
14
+ // Filter to only show h2 and h3
15
+ const filteredHeadings = headings.filter(h => h.depth >= 2 && h.depth <= 3);
16
+ ---
17
+
18
+ {filteredHeadings.length > 0 && (
19
+ <div class="toc-container">
20
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)] mb-4">
21
+ On this page
22
+ </h4>
23
+ <nav class="relative">
24
+ <div class="absolute left-0 top-0 bottom-0 w-px bg-[var(--color-border)]"></div>
25
+ <ul class="space-y-1">
26
+ {filteredHeadings.map((heading) => (
27
+ <li class="relative">
28
+ <a
29
+ href={`#${heading.slug}`}
30
+ class:list={[
31
+ "toc-link block text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-all duration-150",
32
+ heading.depth === 2 ? "py-1.5 pl-4" : "py-1 pl-6 text-xs"
33
+ ]}
34
+ data-heading={heading.slug}
35
+ >
36
+ <span class="relative">{heading.text}</span>
37
+ </a>
38
+ </li>
39
+ ))}
40
+ </ul>
41
+ </nav>
42
+ </div>
43
+ )}
44
+
45
+ <style>
46
+ .toc-link.active {
47
+ color: var(--color-primary-600);
48
+ font-weight: 500;
49
+ }
50
+
51
+ .toc-link.active::before {
52
+ content: '';
53
+ position: absolute;
54
+ left: 0;
55
+ top: 50%;
56
+ transform: translateY(-50%);
57
+ width: 2px;
58
+ height: 1.25rem;
59
+ background: var(--color-primary-500);
60
+ border-radius: 1px;
61
+ }
62
+
63
+ :global(.dark) .toc-link.active {
64
+ color: var(--color-primary-400);
65
+ }
66
+ </style>
67
+
68
+ <script>
69
+ function initTOCHighlight() {
70
+ const headings = document.querySelectorAll('article h2, article h3');
71
+ const tocLinks = document.querySelectorAll('.toc-link');
72
+
73
+ if (headings.length === 0 || tocLinks.length === 0) return;
74
+
75
+ const observer = new IntersectionObserver(
76
+ (entries) => {
77
+ entries.forEach((entry) => {
78
+ if (entry.isIntersecting) {
79
+ const id = entry.target.id;
80
+ tocLinks.forEach((link) => {
81
+ const isActive = link.getAttribute('data-heading') === id;
82
+ link.classList.toggle('active', isActive);
83
+ });
84
+ }
85
+ });
86
+ },
87
+ {
88
+ rootMargin: '-80px 0px -80% 0px',
89
+ threshold: 0
90
+ }
91
+ );
92
+
93
+ headings.forEach((heading) => observer.observe(heading));
94
+ }
95
+
96
+ initTOCHighlight();
97
+ document.addEventListener('astro:page-load', initTOCHighlight);
98
+ </script>
@@ -0,0 +1,11 @@
1
+ ---
2
+ // This script runs before the page renders to prevent flash of wrong theme
3
+ ---
4
+ <script is:inline>
5
+ (function() {
6
+ const theme = localStorage.getItem('theme');
7
+ if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
8
+ document.documentElement.classList.add('dark');
9
+ }
10
+ })();
11
+ </script>
@@ -0,0 +1,57 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Moon, Sun } from "lucide-react";
3
+
4
+ export function ThemeToggle() {
5
+ const [theme, setTheme] = useState<"light" | "dark">("light");
6
+ const [mounted, setMounted] = useState(false);
7
+
8
+ useEffect(() => {
9
+ setMounted(true);
10
+ const stored = localStorage.getItem("theme");
11
+ if (stored === "dark" || stored === "light") {
12
+ setTheme(stored);
13
+ } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
14
+ setTheme("dark");
15
+ }
16
+ }, []);
17
+
18
+ useEffect(() => {
19
+ if (!mounted) return;
20
+
21
+ if (theme === "dark") {
22
+ document.documentElement.classList.add("dark");
23
+ } else {
24
+ document.documentElement.classList.remove("dark");
25
+ }
26
+ localStorage.setItem("theme", theme);
27
+ }, [theme, mounted]);
28
+
29
+ const toggleTheme = () => {
30
+ setTheme((prev) => (prev === "light" ? "dark" : "light"));
31
+ };
32
+
33
+ if (!mounted) {
34
+ return (
35
+ <button
36
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
37
+ aria-label="Toggle theme"
38
+ >
39
+ <div className="w-5 h-5" />
40
+ </button>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <button
46
+ onClick={toggleTheme}
47
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
48
+ aria-label={theme === "light" ? "Switch to dark mode" : "Switch to light mode"}
49
+ >
50
+ {theme === "light" ? (
51
+ <Moon className="w-5 h-5 text-gray-600" />
52
+ ) : (
53
+ <Sun className="w-5 h-5 text-gray-300" />
54
+ )}
55
+ </button>
56
+ );
57
+ }
@@ -0,0 +1,36 @@
1
+ ---
2
+ interface Props {
3
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
4
+ path: string;
5
+ description?: string;
6
+ }
7
+
8
+ const { method, path, description } = Astro.props;
9
+
10
+ const methodColors = {
11
+ GET: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
12
+ POST: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400",
13
+ PUT: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400",
14
+ PATCH: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400",
15
+ DELETE: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400",
16
+ };
17
+ ---
18
+
19
+ <div class="not-prose my-6 rounded-lg border border-[var(--color-border)] overflow-hidden">
20
+ <div class="flex items-center gap-3 px-4 py-3 bg-[var(--color-bg-secondary)] border-b border-[var(--color-border)]">
21
+ <span class:list={["px-2 py-1 text-xs font-bold rounded uppercase", methodColors[method]]}>
22
+ {method}
23
+ </span>
24
+ <code class="text-sm font-mono text-[var(--color-text)]">{path}</code>
25
+ </div>
26
+
27
+ {description && (
28
+ <div class="px-4 py-3 border-b border-[var(--color-border)]">
29
+ <p class="text-sm text-[var(--color-text-secondary)]">{description}</p>
30
+ </div>
31
+ )}
32
+
33
+ <div class="p-4">
34
+ <slot />
35
+ </div>
36
+ </div>
@@ -0,0 +1,26 @@
1
+ ---
2
+ interface Props {
3
+ name: string;
4
+ type: string;
5
+ required?: boolean;
6
+ description?: string;
7
+ }
8
+
9
+ const { name, type, required = false, description } = Astro.props;
10
+ ---
11
+
12
+ <div class="flex flex-col gap-1 py-3 border-b border-[var(--color-border)] last:border-b-0">
13
+ <div class="flex items-center gap-2">
14
+ <code class="text-sm font-semibold text-[var(--color-text)]">{name}</code>
15
+ <span class="text-xs text-[var(--color-text-secondary)]">{type}</span>
16
+ {required && (
17
+ <span class="px-1.5 py-0.5 text-xs font-medium text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/30 rounded">
18
+ required
19
+ </span>
20
+ )}
21
+ </div>
22
+ {description && (
23
+ <p class="text-sm text-[var(--color-text-secondary)]">{description}</p>
24
+ )}
25
+ <slot />
26
+ </div>