@drawnagency/primitives 0.1.49 → 0.1.51

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 (80) hide show
  1. package/dist/{chunk-BJ6FYGYP.js → chunk-24SUF2BC.js} +98 -9
  2. package/dist/{chunk-P3HO76OS.js → chunk-KDGYHU36.js} +6 -3
  3. package/dist/{chunk-5XYUO4HP.js → chunk-PUNXQK4M.js} +19 -2
  4. package/dist/components/editor/MoveSectionModal.d.ts +12 -0
  5. package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
  6. package/dist/components/editor/PagesModal.d.ts +18 -0
  7. package/dist/components/editor/PagesModal.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  11. package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
  12. package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
  13. package/dist/components/sections/Button/CTAButton.d.ts +3 -3
  14. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  15. package/dist/components/sections/Button/index.d.ts +10 -2
  16. package/dist/components/sections/Button/index.d.ts.map +1 -1
  17. package/dist/components/shared/Input.d.ts +1 -0
  18. package/dist/components/shared/Input.d.ts.map +1 -1
  19. package/dist/components/shared/LinkField.d.ts +9 -0
  20. package/dist/components/shared/LinkField.d.ts.map +1 -0
  21. package/dist/components/shared/Navigation.d.ts +4 -3
  22. package/dist/components/shared/Navigation.d.ts.map +1 -1
  23. package/dist/components/shared/PagesContext.d.ts +13 -0
  24. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  25. package/dist/components/shared/RadioGroup.d.ts +15 -0
  26. package/dist/components/shared/RadioGroup.d.ts.map +1 -0
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  29. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  30. package/dist/index.js +17 -3
  31. package/dist/lib/dexie.d.ts.map +1 -1
  32. package/dist/lib/dexie.js +16 -3
  33. package/dist/lib/events.d.ts +3 -1
  34. package/dist/lib/events.d.ts.map +1 -1
  35. package/dist/lib/index.js +2 -2
  36. package/dist/lib/links.d.ts +25 -0
  37. package/dist/lib/links.d.ts.map +1 -0
  38. package/dist/lib/loader.d.ts +2 -2
  39. package/dist/lib/loader.d.ts.map +1 -1
  40. package/dist/lib/nav.d.ts +23 -0
  41. package/dist/lib/nav.d.ts.map +1 -1
  42. package/dist/lib/pages.d.ts +31 -0
  43. package/dist/lib/pages.d.ts.map +1 -0
  44. package/dist/lib/registry.d.ts +7 -0
  45. package/dist/lib/registry.d.ts.map +1 -1
  46. package/dist/schemas/index.d.ts +1 -0
  47. package/dist/schemas/index.d.ts.map +1 -1
  48. package/dist/schemas/index.js +17 -3
  49. package/dist/schemas/link.d.ts +24 -0
  50. package/dist/schemas/link.d.ts.map +1 -0
  51. package/dist/schemas/site-config.d.ts +129 -3
  52. package/dist/schemas/site-config.d.ts.map +1 -1
  53. package/package.json +5 -1
  54. package/src/components/editor/MoveSectionModal.tsx +36 -0
  55. package/src/components/editor/PagesModal.tsx +400 -0
  56. package/src/components/editor/SectionWrapper.tsx +13 -0
  57. package/src/components/editor/SettingsForm.tsx +12 -0
  58. package/src/components/primitives/LinkPopover.tsx +99 -27
  59. package/src/components/primitives/tiptap-presets.ts +3 -1
  60. package/src/components/sections/Button/CTAButton.tsx +10 -11
  61. package/src/components/sections/Button/index.tsx +4 -9
  62. package/src/components/shared/Input.tsx +14 -3
  63. package/src/components/shared/LinkField.tsx +90 -0
  64. package/src/components/shared/Navigation.tsx +147 -137
  65. package/src/components/shared/PagesContext.tsx +12 -0
  66. package/src/components/shared/RadioGroup.tsx +71 -0
  67. package/src/components/shell/EditorShell.tsx +273 -78
  68. package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
  69. package/src/hooks/useBuildStatus.ts +19 -4
  70. package/src/hooks/useEditorPublish.ts +1 -1
  71. package/src/lib/dexie.ts +18 -5
  72. package/src/lib/events.ts +3 -1
  73. package/src/lib/links.ts +108 -0
  74. package/src/lib/loader.ts +5 -4
  75. package/src/lib/nav.ts +59 -0
  76. package/src/lib/pages.ts +209 -0
  77. package/src/lib/registry.ts +8 -0
  78. package/src/schemas/index.ts +1 -0
  79. package/src/schemas/link.ts +17 -0
  80. package/src/schemas/site-config.ts +119 -11
@@ -1,14 +1,14 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
3
  import { RectangleHorizontal } from "lucide-react";
4
+ import { LinkValueSchema, DEFAULT_LINK } from "../../../schemas/link";
4
5
  import CTAButton from "./CTAButton";
5
6
 
6
7
  const schema = z.object({
7
8
  type: z.literal("button"),
8
9
  content: z.object({
9
10
  text: z.string(),
10
- href: z.string().optional(),
11
- target: z.string().optional(),
11
+ link: LinkValueSchema.optional(),
12
12
  download: z.boolean().optional(),
13
13
  }),
14
14
  });
@@ -21,8 +21,7 @@ export default defineSection({
21
21
  component: ({ content, onChange }) => (
22
22
  <CTAButton
23
23
  text={content.content.text}
24
- href={content.content.href}
25
- target={content.content.target}
24
+ link={content.content.link}
26
25
  download={content.content.download}
27
26
  onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
28
27
  />
@@ -30,11 +29,7 @@ export default defineSection({
30
29
  defaults: () => ({ type: "button" as const, content: { text: "Button" } }),
31
30
  getLabel: (content) => content.content.text,
32
31
  settings: {
33
- href: { type: "text", label: "URL", default: "", target: "content", placeholder: "https://..." },
34
- target: {
35
- type: "select", label: "Target", default: "_self", target: "content",
36
- options: [{ label: "Same tab (_self)", value: "_self" }, { label: "New tab (_blank)", value: "_blank" }],
37
- },
32
+ link: { type: "link", label: "Link", default: DEFAULT_LINK, target: "content" },
38
33
  download: { type: "checkbox", label: "Download link", default: false, target: "content" },
39
34
  },
40
35
  });
@@ -5,13 +5,15 @@ import { FormLabel } from "./FormLabel";
5
5
  interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
6
6
  label: string;
7
7
  onChange: (value: string) => void;
8
+ error?: string;
8
9
  }
9
10
 
10
11
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
11
- { label, value, onChange, type = "text", className, disabled, ...rest },
12
+ { label, value, onChange, type = "text", className, disabled, error, ...rest },
12
13
  ref,
13
14
  ) {
14
15
  const id = useId();
16
+ const errorId = `${id}-error`;
15
17
  return (
16
18
  <div className={className}>
17
19
  <FormLabel htmlFor={id}>{label}</FormLabel>
@@ -22,13 +24,22 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
22
24
  value={value}
23
25
  onChange={(e) => onChange(e.target.value)}
24
26
  disabled={disabled}
27
+ aria-invalid={error ? true : undefined}
28
+ aria-describedby={error ? errorId : undefined}
25
29
  className={cn(
26
- "w-full rounded border border-base-200 bg-base px-3 py-2 text-sm text-base-contrast",
27
- "focus:border-base-contrast focus:outline-none focus:ring-1 focus:ring-base-contrast",
30
+ "w-full rounded border bg-base px-3 py-2 text-sm text-base-contrast focus:outline-none focus:ring-1",
31
+ error
32
+ ? "border-red-600 focus:border-red-600 focus:ring-red-600"
33
+ : "border-base-200 focus:border-base-contrast focus:ring-base-contrast",
28
34
  disabled && "cursor-not-allowed opacity-50",
29
35
  )}
30
36
  {...rest}
31
37
  />
38
+ {error && (
39
+ <p id={errorId} className="mt-1 text-xs text-red-600">
40
+ {error}
41
+ </p>
42
+ )}
32
43
  </div>
33
44
  );
34
45
  });
@@ -0,0 +1,90 @@
1
+ import { FormLabel } from "./FormLabel";
2
+ import { Input } from "./Input";
3
+ import { Select } from "./Select";
4
+ import { usePages } from "./PagesContext";
5
+ import type { LinkValue } from "../../schemas/link";
6
+ import { cn } from "../../lib/cn";
7
+
8
+ interface Props {
9
+ label: string;
10
+ value: LinkValue;
11
+ onChange: (value: LinkValue) => void;
12
+ }
13
+
14
+ const TARGET_OPTIONS = [
15
+ { label: "Same tab (_self)", value: "_self" },
16
+ { label: "New tab (_blank)", value: "_blank" },
17
+ ];
18
+
19
+ export function LinkField({ label, value, onChange }: Props) {
20
+ const { pages, getPageHeadings } = usePages();
21
+ const isInternal = value.kind === "internal";
22
+
23
+ function setMode(kind: "external" | "internal") {
24
+ if (kind === value.kind) return;
25
+ // Internal links always open in the same tab — no target choice offered.
26
+ onChange(kind === "external"
27
+ ? { kind: "external", href: "", target: value.target }
28
+ : { kind: "internal", pageId: "", anchorSectionId: null, target: "_self" });
29
+ }
30
+
31
+ const pageId = isInternal ? value.pageId : "";
32
+ const headings = pageId ? getPageHeadings(pageId) : [];
33
+
34
+ return (
35
+ <div className="flex flex-col gap-3">
36
+ <FormLabel>{label}</FormLabel>
37
+ <div className="flex gap-2" role="tablist">
38
+ {(["external", "internal"] as const).map((kind) => (
39
+ <button
40
+ key={kind}
41
+ type="button"
42
+ role="tab"
43
+ aria-selected={value.kind === kind}
44
+ onClick={() => setMode(kind)}
45
+ className={cn(
46
+ "cursor-pointer rounded border px-3 py-1 text-sm capitalize",
47
+ value.kind === kind ? "border-primary bg-primary text-primary-contrast" : "border-base-200 text-base-contrast",
48
+ )}
49
+ >
50
+ {kind === "external" ? "External URL" : "Internal page"}
51
+ </button>
52
+ ))}
53
+ </div>
54
+
55
+ {value.kind === "external" ? (
56
+ <Input
57
+ label="URL"
58
+ value={value.href}
59
+ placeholder="https://..."
60
+ onChange={(href) => onChange({ kind: "external", href, target: value.target })}
61
+ />
62
+ ) : (
63
+ <>
64
+ <Select
65
+ label="Page"
66
+ value={value.pageId}
67
+ options={[{ label: "Select a page…", value: "" }, ...pages.map((p) => ({ label: p.title, value: p.id }))]}
68
+ onChange={(pid) => onChange({ kind: "internal", pageId: pid, anchorSectionId: null, target: value.target })}
69
+ />
70
+ <Select
71
+ label="Section"
72
+ value={value.anchorSectionId ?? ""}
73
+ disabled={!pageId}
74
+ options={[{ label: "None (top of page)", value: "" }, ...headings.map((h) => ({ label: h.label, value: h.id }))]}
75
+ onChange={(anchor) => onChange({ kind: "internal", pageId, anchorSectionId: anchor || null, target: value.target })}
76
+ />
77
+ </>
78
+ )}
79
+
80
+ {value.kind === "external" && (
81
+ <Select
82
+ label="Opens in"
83
+ value={value.target}
84
+ options={TARGET_OPTIONS}
85
+ onChange={(t) => onChange({ ...value, target: t as "_self" | "_blank" })}
86
+ />
87
+ )}
88
+ </div>
89
+ );
90
+ }
@@ -1,38 +1,38 @@
1
1
  import { useState, useCallback, useEffect, useRef } from "react";
2
+ import { ChevronRight } from "lucide-react";
2
3
  import { cn } from "../../lib/cn";
3
4
  import { Toggle } from "./Toggle";
4
- import type { NavItem } from "../../lib/nav";
5
- import { editModeEvent, navChangeEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
5
+ import type { SiteNav, NavItem } from "../../lib/nav";
6
+ import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
6
7
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
7
8
  import { formatDate } from "../../lib/timestamp";
8
9
  import { Popover } from "./Popover";
9
10
  import { HistoryPopover } from "./HistoryPopover";
10
11
 
11
12
  interface Props {
12
- navLinks: NavItem[];
13
+ siteNav: SiteNav;
13
14
  siteName: string;
14
15
  darkMode: "light" | "dark" | "optional";
15
16
  lastUpdated: string | null;
17
+ // Optional same-island handler (tests). In production the editor nav is a
18
+ // separate island from EditorShell, so a click dispatches pageSelectEvent.
19
+ onPageSelect?: (pageId: string) => void;
16
20
  }
17
21
 
18
- export default function Navigation({ navLinks: initialNavLinks, siteName, darkMode, lastUpdated }: Props) {
22
+ export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, onPageSelect }: Props) {
19
23
  const [isOpen, setIsOpen] = useState(false);
20
- // Must start false to match SSR (where `window` is undefined). Reading window
21
- // in the initializer makes the first client render disagree with the server
22
- // (edit branch vs view branch) → React hydration mismatch (#418). Set it from
23
- // the pathname in the mount effect below instead.
24
24
  const [isEditMode, setIsEditMode] = useState(false);
25
25
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
26
26
  const [isDark, setIsDark] = useState(false);
27
- const [navLinks, setNavLinks] = useState<NavItem[]>(initialNavLinks);
27
+ const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
28
28
  const [showHistory, setShowHistory] = useState(false);
29
+ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
29
30
  const historyButtonRef = useRef<HTMLButtonElement>(null);
30
31
 
31
32
  useEffect(() => {
32
- // Resolve edit mode after mount (post-hydration) to avoid an SSR/client mismatch.
33
33
  setIsEditMode(window.location.pathname.startsWith("/edit"));
34
34
  const unlistenEdit = editModeEvent.listen(({ isEditMode }) => setIsEditMode(isEditMode));
35
- const unlistenNav = navChangeEvent.listen((links) => setNavLinks(links));
35
+ const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
36
36
  const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
37
37
  return () => { unlistenEdit(); unlistenNav(); unlistenDark(); };
38
38
  }, []);
@@ -40,9 +40,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
40
40
  useEffect(() => {
41
41
  const root = document.documentElement;
42
42
  setIsDark(root.classList.contains("dark"));
43
- const observer = new MutationObserver(() => {
44
- setIsDark(root.classList.contains("dark"));
45
- });
43
+ const observer = new MutationObserver(() => setIsDark(root.classList.contains("dark")));
46
44
  observer.observe(root, { attributes: true, attributeFilter: ["class"] });
47
45
  return () => observer.disconnect();
48
46
  }, []);
@@ -55,183 +53,195 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
55
53
  setTimeout(() => root.classList.remove("theme-changing"), 200);
56
54
  }, []);
57
55
 
58
- // Build ID maps for scroll tracking
59
- const parentIds = navLinks.map((item) => item.href.replace("#", ""));
56
+ // The active page can live in any tier (editors navigate to hidden and
57
+ // archived pages via the Pages modal).
58
+ const activePage =
59
+ [...siteNav.pages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
60
+ const headings: NavItem[] = activePage?.headings ?? [];
61
+
62
+ const parentIds = headings.map((h) => h.href.replace("#", ""));
60
63
  const childIdsByParent: Record<string, string[]> = {};
61
64
  const grandchildIdsByChild: Record<string, string[]> = {};
62
-
63
- for (const parent of navLinks) {
65
+ for (const parent of headings) {
64
66
  const pid = parent.href.replace("#", "");
65
67
  childIdsByParent[pid] = parent.children.map((c) => c.href.replace("#", ""));
66
68
  for (const child of parent.children) {
67
- const cid = child.href.replace("#", "");
68
- grandchildIdsByChild[cid] = child.children.map((gc) => gc.href.replace("#", ""));
69
+ grandchildIdsByChild[child.href.replace("#", "")] = child.children.map((gc) => gc.href.replace("#", ""));
69
70
  }
70
71
  }
71
-
72
72
  const { activeParentId, activeChildId, activeGrandchildId, setActiveSection } =
73
73
  useActiveHeadings(parentIds, childIdsByParent, grandchildIdsByChild);
74
74
 
75
- const handleNav = useCallback((href: string) => {
75
+ const handleHeadingNav = useCallback((href: string) => {
76
76
  const id = href.replace("#", "");
77
77
  const el = document.getElementById(id);
78
- if (el) {
79
- el.scrollIntoView({ behavior: "smooth" });
80
- setActiveSection(id);
81
- }
78
+ if (el) { el.scrollIntoView({ behavior: "smooth" }); setActiveSection(id); }
82
79
  setIsOpen(false);
83
80
  }, [setActiveSection]);
84
81
 
82
+ const renderHeadings = () => (
83
+ <ul className="ml-3 mt-1 space-y-1 border-l border-base-200 pl-3">
84
+ {headings.map((parent) => {
85
+ const pid = parent.href.replace("#", "");
86
+ return (
87
+ <li key={pid}>
88
+ <button
89
+ onClick={() => handleHeadingNav(parent.href)}
90
+ className={cn("cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
91
+ activeParentId === pid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
92
+ !isEditMode && parent.status && parent.status !== "live" && "opacity-50")}
93
+ >
94
+ {parent.label}
95
+ </button>
96
+ {activeParentId === pid && parent.children.length > 0 && (
97
+ <ul className="ml-3 mt-1 space-y-1">
98
+ {parent.children.map((child) => {
99
+ const cid = child.href.replace("#", "");
100
+ return (
101
+ <li key={cid}>
102
+ <button onClick={() => handleHeadingNav(child.href)}
103
+ className={cn("cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
104
+ activeChildId === cid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
105
+ !isEditMode && child.status && child.status !== "live" && "opacity-50")}>
106
+ {child.label}
107
+ </button>
108
+ {activeChildId === cid && child.children.length > 0 && (
109
+ <ul className="ml-3 mt-1 space-y-1">
110
+ {child.children.map((gc) => {
111
+ const gid = gc.href.replace("#", "");
112
+ return (
113
+ <li key={gid}>
114
+ <button onClick={() => handleHeadingNav(gc.href)}
115
+ className={cn("cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
116
+ activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
117
+ !isEditMode && gc.status && gc.status !== "live" && "opacity-50")}>
118
+ {gc.label}
119
+ </button>
120
+ </li>
121
+ );
122
+ })}
123
+ </ul>
124
+ )}
125
+ </li>
126
+ );
127
+ })}
128
+ </ul>
129
+ )}
130
+ </li>
131
+ );
132
+ })}
133
+ </ul>
134
+ );
135
+
136
+ const handlePageClick = useCallback((id: string) => {
137
+ if (onPageSelect) onPageSelect(id);
138
+ else pageSelectEvent.dispatch(id);
139
+ setIsOpen(false);
140
+ }, [onPageSelect]);
141
+
142
+ // Render an interactive button (no full navigation) when in the editor or when
143
+ // a same-island handler is supplied; otherwise a real <a> for the viewer.
144
+ const asButton = isEditMode || !!onPageSelect;
145
+
146
+ const renderPage = (page: SiteNav["pages"][number]) => (
147
+ <li key={page.id}>
148
+ {asButton ? (
149
+ <button
150
+ onClick={() => handlePageClick(page.id)}
151
+ className={cn("cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
152
+ page.isActive ? "text-primary" : "text-base-contrast hover:text-primary")}
153
+ >
154
+ {page.title}
155
+ </button>
156
+ ) : (
157
+ <a
158
+ href={page.href}
159
+ className={cn("block w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
160
+ page.isActive ? "text-primary" : "text-base-contrast hover:text-primary")}
161
+ >
162
+ {page.title}
163
+ </a>
164
+ )}
165
+ {page.isActive && headings.length > 0 && renderHeadings()}
166
+ </li>
167
+ );
168
+
169
+ const renderPageGroup = (label: string, pages: SiteNav["pages"]) => {
170
+ if (pages.length === 0) return null;
171
+ // Closed by default; auto-open when it holds the active page so the
172
+ // editor's current location (and its heading tree) stays visible.
173
+ const isOpen = openGroups[label] ?? pages.some((p) => p.isActive);
174
+ return (
175
+ <div className="mx-4 mt-2 border-t border-base-200 pt-2">
176
+ <button
177
+ type="button"
178
+ aria-expanded={isOpen}
179
+ onClick={() => setOpenGroups((g) => ({ ...g, [label]: !isOpen }))}
180
+ className="flex w-full cursor-pointer items-center justify-between rounded px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70 transition-colors hover:text-primary"
181
+ >
182
+ <span>{`${label} (${pages.length})`}</span>
183
+ <ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
184
+ </button>
185
+ {isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
186
+ </div>
187
+ );
188
+ };
189
+
85
190
  return (
86
191
  <>
87
- {/* Mobile header bar */}
88
192
  <header className="fixed top-0 left-0 right-0 z-50 bg-base lg:hidden">
89
193
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
90
194
  <span className="text-lg font-bold text-primary">{siteName}</span>
91
- <button
92
- onClick={() => setIsOpen(!isOpen)}
93
- className="cursor-pointer p-2 text-base-contrast"
94
- aria-label="Toggle navigation"
95
- >
195
+ <button onClick={() => setIsOpen(!isOpen)} className="cursor-pointer p-2 text-base-contrast" aria-label="Toggle navigation">
96
196
  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
97
197
  {isOpen
98
198
  ? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
99
- : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
100
- }
199
+ : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />}
101
200
  </svg>
102
201
  </button>
103
202
  </div>
104
203
  </header>
105
204
 
106
- {/* Backdrop */}
107
- {isOpen && (
108
- <div
109
- className="fixed inset-0 z-40 bg-black/50 lg:hidden"
110
- onClick={() => setIsOpen(false)}
111
- />
112
- )}
205
+ {isOpen && <div className="fixed inset-0 z-40 bg-black/50 lg:hidden" onClick={() => setIsOpen(false)} />}
113
206
 
114
- {/* Sidebar nav */}
115
- <nav
116
- className={cn(
117
- "fixed top-0 left-0 lg:left-auto z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform lg:translate-x-0 nav-sidebar",
118
- isOpen ? "translate-x-0" : "-translate-x-full",
119
- )}
120
- >
121
- {/* Desktop header */}
207
+ <nav className={cn("fixed top-0 left-0 lg:left-auto z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform lg:translate-x-0 nav-sidebar",
208
+ isOpen ? "translate-x-0" : "-translate-x-full")}>
122
209
  <div className="hidden px-4 py-4 lg:block">
123
210
  <span className="text-lg font-bold text-primary">{siteName}</span>
124
211
  </div>
125
212
 
126
213
  <ul className="space-y-1 px-4 py-2">
127
- {navLinks.map((parent) => {
128
- const pid = parent.href.replace("#", "");
129
- const isActiveParent = activeParentId === pid;
130
-
131
- return (
132
- <li key={pid}>
133
- <button
134
- onClick={() => handleNav(parent.href)}
135
- className={cn(
136
- "cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
137
- isActiveParent ? "text-primary" : "text-base-contrast hover:text-primary",
138
- !isEditMode && parent.status && parent.status !== "live" && "opacity-50",
139
- )}
140
- >
141
- {parent.label}
142
- </button>
143
-
144
- {isActiveParent && parent.children.length > 0 && (
145
- <ul className="ml-3 mt-1 space-y-1 border-l border-base-200 pl-3">
146
- {parent.children.map((child) => {
147
- const cid = child.href.replace("#", "");
148
- const isActiveChild = activeChildId === cid;
149
-
150
- return (
151
- <li key={cid}>
152
- <button
153
- onClick={() => handleNav(child.href)}
154
- className={cn(
155
- "cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
156
- isActiveChild ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
157
- !isEditMode && child.status && child.status !== "live" && "opacity-50",
158
- )}
159
- >
160
- {child.label}
161
- </button>
162
-
163
- {isActiveChild && child.children.length > 0 && (
164
- <ul className="ml-3 mt-1 space-y-1">
165
- {child.children.map((gc) => {
166
- const gid = gc.href.replace("#", "");
167
- return (
168
- <li key={gid}>
169
- <button
170
- onClick={() => handleNav(gc.href)}
171
- className={cn(
172
- "cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
173
- activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
174
- !isEditMode && gc.status && gc.status !== "live" && "opacity-50",
175
- )}
176
- >
177
- {gc.label}
178
- </button>
179
- </li>
180
- );
181
- })}
182
- </ul>
183
- )}
184
- </li>
185
- );
186
- })}
187
- </ul>
188
- )}
189
- </li>
190
- );
191
- })}
214
+ {siteNav.pages.map(renderPage)}
192
215
  </ul>
193
216
 
217
+ {isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
218
+ {isEditMode && renderPageGroup("Archive", siteNav.archive)}
219
+
194
220
  <div className="mt-auto">
195
221
  {currentDarkMode === "optional" && (
196
222
  <div className="mx-4 border-t border-base-200 py-4">
197
- <div className="flex items-center gap-2 text-sm text-base-contrast-light">
223
+ <div className="flex items-center justify-center gap-2 text-sm text-base-contrast-light">
198
224
  <span className={cn(!isDark && "text-base-contrast")}>Light</span>
199
225
  <Toggle checked={isDark} onChange={handleThemeToggle} label="Toggle dark mode" />
200
226
  <span className={cn(isDark && "text-base-contrast")}>Dark</span>
201
227
  </div>
202
228
  </div>
203
229
  )}
204
-
205
230
  {lastUpdated && (
206
231
  <div className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
207
232
  {isEditMode ? (
208
233
  <div className="relative">
209
- <button
210
- ref={historyButtonRef}
211
- onClick={() => setShowHistory((prev) => !prev)}
212
- className="cursor-pointer text-xs text-base-contrast-light hover:text-primary transition-colors"
213
- aria-label="View history"
214
- >
234
+ <button ref={historyButtonRef} onClick={() => setShowHistory((p) => !p)}
235
+ className="cursor-pointer text-xs text-base-contrast-light hover:text-primary transition-colors" aria-label="View history">
215
236
  Last updated {formatDate(lastUpdated)}
216
237
  </button>
217
- <Popover
218
- isOpen={showHistory}
219
- onClose={() => setShowHistory(false)}
220
- anchorRef={historyButtonRef}
221
- className="w-56 !bottom-full !top-auto !mb-1 !mt-0"
222
- >
223
- <HistoryPopover
224
- onSelectCommit={(sha, date) => {
225
- setShowHistory(false);
226
- historySelectEvent.dispatch({ sha, date });
227
- }}
228
- />
238
+ <Popover isOpen={showHistory} onClose={() => setShowHistory(false)} anchorRef={historyButtonRef}
239
+ className="w-56 !bottom-full !top-auto !mb-1 !mt-0">
240
+ <HistoryPopover onSelectCommit={(sha, date) => { setShowHistory(false); historySelectEvent.dispatch({ sha, date }); }} />
229
241
  </Popover>
230
242
  </div>
231
243
  ) : (
232
- <p className="text-xs text-base-contrast-light">
233
- Last updated {formatDate(lastUpdated)}
234
- </p>
244
+ <p className="text-xs text-base-contrast-light">Last updated {formatDate(lastUpdated)}</p>
235
245
  )}
236
246
  </div>
237
247
  )}
@@ -0,0 +1,12 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ export interface PagesContextValue {
4
+ pages: { id: string; title: string }[];
5
+ getPageHeadings: (pageId: string) => { id: string; label: string }[];
6
+ }
7
+
8
+ export const PagesContext = createContext<PagesContextValue>({ pages: [], getPageHeadings: () => [] });
9
+
10
+ export function usePages(): PagesContextValue {
11
+ return useContext(PagesContext);
12
+ }
@@ -0,0 +1,71 @@
1
+ import { useId } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { FormLabel } from "./FormLabel";
4
+
5
+ export interface RadioGroupOption<T extends string = string> {
6
+ label: string;
7
+ value: T;
8
+ }
9
+
10
+ interface RadioGroupProps<T extends string = string> {
11
+ label: string;
12
+ options: RadioGroupOption<T>[];
13
+ value: T;
14
+ onChange: (value: T) => void;
15
+ disabled?: boolean;
16
+ className?: string;
17
+ }
18
+
19
+ export function RadioGroup<T extends string = string>({
20
+ label,
21
+ options,
22
+ value,
23
+ onChange,
24
+ disabled,
25
+ className,
26
+ }: RadioGroupProps<T>) {
27
+ const name = useId();
28
+ return (
29
+ <div className={className} role="radiogroup" aria-label={label}>
30
+ <FormLabel>{label}</FormLabel>
31
+ <div className="flex gap-4">
32
+ {options.map((option) => {
33
+ const checked = value === option.value;
34
+ return (
35
+ <label
36
+ key={option.value}
37
+ className={cn(
38
+ "flex cursor-pointer items-center gap-2 select-none hover:opacity-80",
39
+ disabled && "cursor-not-allowed opacity-50",
40
+ )}
41
+ >
42
+ <span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
43
+ <input
44
+ type="radio"
45
+ name={name}
46
+ checked={checked}
47
+ disabled={disabled}
48
+ onChange={() => {
49
+ if (!disabled) onChange(option.value);
50
+ }}
51
+ className="sr-only"
52
+ />
53
+ <span
54
+ aria-hidden="true"
55
+ className={cn(
56
+ "flex h-5 w-5 items-center justify-center rounded-full border transition-colors",
57
+ checked ? "border-primary" : "border-base-300 bg-base hover:border-primary",
58
+ disabled && "pointer-events-none",
59
+ )}
60
+ >
61
+ {checked && <span className="h-2.5 w-2.5 rounded-full bg-primary" />}
62
+ </span>
63
+ </span>
64
+ <span className="text-sm font-medium text-base-contrast">{option.label}</span>
65
+ </label>
66
+ );
67
+ })}
68
+ </div>
69
+ </div>
70
+ );
71
+ }