@drawnagency/primitives 0.1.49 → 0.1.50

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 (69) hide show
  1. package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
  2. package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
  3. package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
  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/sections/Button/CTAButton.d.ts +3 -3
  12. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  13. package/dist/components/sections/Button/index.d.ts +10 -2
  14. package/dist/components/sections/Button/index.d.ts.map +1 -1
  15. package/dist/components/shared/Input.d.ts +1 -0
  16. package/dist/components/shared/Input.d.ts.map +1 -1
  17. package/dist/components/shared/LinkField.d.ts +9 -0
  18. package/dist/components/shared/LinkField.d.ts.map +1 -0
  19. package/dist/components/shared/Navigation.d.ts +4 -3
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shared/PagesContext.d.ts +13 -0
  22. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  23. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  24. package/dist/index.js +17 -3
  25. package/dist/lib/dexie.d.ts.map +1 -1
  26. package/dist/lib/dexie.js +16 -3
  27. package/dist/lib/events.d.ts +3 -1
  28. package/dist/lib/events.d.ts.map +1 -1
  29. package/dist/lib/index.js +2 -2
  30. package/dist/lib/links.d.ts +14 -0
  31. package/dist/lib/links.d.ts.map +1 -0
  32. package/dist/lib/loader.d.ts +2 -2
  33. package/dist/lib/loader.d.ts.map +1 -1
  34. package/dist/lib/nav.d.ts +23 -0
  35. package/dist/lib/nav.d.ts.map +1 -1
  36. package/dist/lib/pages.d.ts +31 -0
  37. package/dist/lib/pages.d.ts.map +1 -0
  38. package/dist/lib/registry.d.ts +7 -0
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/schemas/index.d.ts +1 -0
  41. package/dist/schemas/index.d.ts.map +1 -1
  42. package/dist/schemas/index.js +17 -3
  43. package/dist/schemas/link.d.ts +24 -0
  44. package/dist/schemas/link.d.ts.map +1 -0
  45. package/dist/schemas/site-config.d.ts +128 -3
  46. package/dist/schemas/site-config.d.ts.map +1 -1
  47. package/package.json +5 -1
  48. package/src/components/editor/MoveSectionModal.tsx +38 -0
  49. package/src/components/editor/PagesModal.tsx +392 -0
  50. package/src/components/editor/SectionWrapper.tsx +13 -0
  51. package/src/components/editor/SettingsForm.tsx +12 -0
  52. package/src/components/sections/Button/CTAButton.tsx +10 -11
  53. package/src/components/sections/Button/index.tsx +4 -9
  54. package/src/components/shared/Input.tsx +14 -3
  55. package/src/components/shared/LinkField.tsx +87 -0
  56. package/src/components/shared/Navigation.tsx +131 -136
  57. package/src/components/shared/PagesContext.tsx +12 -0
  58. package/src/components/shell/EditorShell.tsx +273 -78
  59. package/src/hooks/useEditorPublish.ts +1 -1
  60. package/src/lib/dexie.ts +18 -5
  61. package/src/lib/events.ts +3 -1
  62. package/src/lib/links.ts +41 -0
  63. package/src/lib/loader.ts +5 -4
  64. package/src/lib/nav.ts +59 -0
  65. package/src/lib/pages.ts +209 -0
  66. package/src/lib/registry.ts +8 -0
  67. package/src/schemas/index.ts +1 -0
  68. package/src/schemas/link.ts +17 -0
  69. package/src/schemas/site-config.ts +113 -11
@@ -1,38 +1,36 @@
1
1
  import { useState, useCallback, useEffect, useRef } from "react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { Toggle } from "./Toggle";
4
- import type { NavItem } from "../../lib/nav";
5
- import { editModeEvent, navChangeEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
4
+ import type { SiteNav, NavItem } from "../../lib/nav";
5
+ import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
6
6
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
7
7
  import { formatDate } from "../../lib/timestamp";
8
8
  import { Popover } from "./Popover";
9
9
  import { HistoryPopover } from "./HistoryPopover";
10
10
 
11
11
  interface Props {
12
- navLinks: NavItem[];
12
+ siteNav: SiteNav;
13
13
  siteName: string;
14
14
  darkMode: "light" | "dark" | "optional";
15
15
  lastUpdated: string | null;
16
+ // Optional same-island handler (tests). In production the editor nav is a
17
+ // separate island from EditorShell, so a click dispatches pageSelectEvent.
18
+ onPageSelect?: (pageId: string) => void;
16
19
  }
17
20
 
18
- export default function Navigation({ navLinks: initialNavLinks, siteName, darkMode, lastUpdated }: Props) {
21
+ export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, onPageSelect }: Props) {
19
22
  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
23
  const [isEditMode, setIsEditMode] = useState(false);
25
24
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
26
25
  const [isDark, setIsDark] = useState(false);
27
- const [navLinks, setNavLinks] = useState<NavItem[]>(initialNavLinks);
26
+ const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
28
27
  const [showHistory, setShowHistory] = useState(false);
29
28
  const historyButtonRef = useRef<HTMLButtonElement>(null);
30
29
 
31
30
  useEffect(() => {
32
- // Resolve edit mode after mount (post-hydration) to avoid an SSR/client mismatch.
33
31
  setIsEditMode(window.location.pathname.startsWith("/edit"));
34
32
  const unlistenEdit = editModeEvent.listen(({ isEditMode }) => setIsEditMode(isEditMode));
35
- const unlistenNav = navChangeEvent.listen((links) => setNavLinks(links));
33
+ const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
36
34
  const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
37
35
  return () => { unlistenEdit(); unlistenNav(); unlistenDark(); };
38
36
  }, []);
@@ -40,9 +38,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
40
38
  useEffect(() => {
41
39
  const root = document.documentElement;
42
40
  setIsDark(root.classList.contains("dark"));
43
- const observer = new MutationObserver(() => {
44
- setIsDark(root.classList.contains("dark"));
45
- });
41
+ const observer = new MutationObserver(() => setIsDark(root.classList.contains("dark")));
46
42
  observer.observe(root, { attributes: true, attributeFilter: ["class"] });
47
43
  return () => observer.disconnect();
48
44
  }, []);
@@ -55,142 +51,157 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
55
51
  setTimeout(() => root.classList.remove("theme-changing"), 200);
56
52
  }, []);
57
53
 
58
- // Build ID maps for scroll tracking
59
- const parentIds = navLinks.map((item) => item.href.replace("#", ""));
54
+ // The active page can live in any tier (editors navigate to hidden and
55
+ // archived pages via the Pages modal).
56
+ const activePage =
57
+ [...siteNav.pages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
58
+ const headings: NavItem[] = activePage?.headings ?? [];
59
+
60
+ const parentIds = headings.map((h) => h.href.replace("#", ""));
60
61
  const childIdsByParent: Record<string, string[]> = {};
61
62
  const grandchildIdsByChild: Record<string, string[]> = {};
62
-
63
- for (const parent of navLinks) {
63
+ for (const parent of headings) {
64
64
  const pid = parent.href.replace("#", "");
65
65
  childIdsByParent[pid] = parent.children.map((c) => c.href.replace("#", ""));
66
66
  for (const child of parent.children) {
67
- const cid = child.href.replace("#", "");
68
- grandchildIdsByChild[cid] = child.children.map((gc) => gc.href.replace("#", ""));
67
+ grandchildIdsByChild[child.href.replace("#", "")] = child.children.map((gc) => gc.href.replace("#", ""));
69
68
  }
70
69
  }
71
-
72
70
  const { activeParentId, activeChildId, activeGrandchildId, setActiveSection } =
73
71
  useActiveHeadings(parentIds, childIdsByParent, grandchildIdsByChild);
74
72
 
75
- const handleNav = useCallback((href: string) => {
73
+ const handleHeadingNav = useCallback((href: string) => {
76
74
  const id = href.replace("#", "");
77
75
  const el = document.getElementById(id);
78
- if (el) {
79
- el.scrollIntoView({ behavior: "smooth" });
80
- setActiveSection(id);
81
- }
76
+ if (el) { el.scrollIntoView({ behavior: "smooth" }); setActiveSection(id); }
82
77
  setIsOpen(false);
83
78
  }, [setActiveSection]);
84
79
 
80
+ const renderHeadings = () => (
81
+ <ul className="ml-3 mt-1 space-y-1 border-l border-base-200 pl-3">
82
+ {headings.map((parent) => {
83
+ const pid = parent.href.replace("#", "");
84
+ return (
85
+ <li key={pid}>
86
+ <button
87
+ onClick={() => handleHeadingNav(parent.href)}
88
+ className={cn("cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
89
+ activeParentId === pid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
90
+ !isEditMode && parent.status && parent.status !== "live" && "opacity-50")}
91
+ >
92
+ {parent.label}
93
+ </button>
94
+ {activeParentId === pid && parent.children.length > 0 && (
95
+ <ul className="ml-3 mt-1 space-y-1">
96
+ {parent.children.map((child) => {
97
+ const cid = child.href.replace("#", "");
98
+ return (
99
+ <li key={cid}>
100
+ <button onClick={() => handleHeadingNav(child.href)}
101
+ className={cn("cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
102
+ activeChildId === cid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
103
+ !isEditMode && child.status && child.status !== "live" && "opacity-50")}>
104
+ {child.label}
105
+ </button>
106
+ {activeChildId === cid && child.children.length > 0 && (
107
+ <ul className="ml-3 mt-1 space-y-1">
108
+ {child.children.map((gc) => {
109
+ const gid = gc.href.replace("#", "");
110
+ return (
111
+ <li key={gid}>
112
+ <button onClick={() => handleHeadingNav(gc.href)}
113
+ className={cn("cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
114
+ activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
115
+ !isEditMode && gc.status && gc.status !== "live" && "opacity-50")}>
116
+ {gc.label}
117
+ </button>
118
+ </li>
119
+ );
120
+ })}
121
+ </ul>
122
+ )}
123
+ </li>
124
+ );
125
+ })}
126
+ </ul>
127
+ )}
128
+ </li>
129
+ );
130
+ })}
131
+ </ul>
132
+ );
133
+
134
+ const handlePageClick = useCallback((id: string) => {
135
+ if (onPageSelect) onPageSelect(id);
136
+ else pageSelectEvent.dispatch(id);
137
+ setIsOpen(false);
138
+ }, [onPageSelect]);
139
+
140
+ // Render an interactive button (no full navigation) when in the editor or when
141
+ // a same-island handler is supplied; otherwise a real <a> for the viewer.
142
+ const asButton = isEditMode || !!onPageSelect;
143
+
144
+ const renderPage = (page: SiteNav["pages"][number]) => (
145
+ <li key={page.id}>
146
+ {asButton ? (
147
+ <button
148
+ onClick={() => handlePageClick(page.id)}
149
+ className={cn("cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
150
+ page.isActive ? "text-primary" : "text-base-contrast hover:text-primary")}
151
+ >
152
+ {page.title}
153
+ </button>
154
+ ) : (
155
+ <a
156
+ href={page.href}
157
+ className={cn("block w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
158
+ page.isActive ? "text-primary" : "text-base-contrast hover:text-primary")}
159
+ >
160
+ {page.title}
161
+ </a>
162
+ )}
163
+ {page.isActive && headings.length > 0 && renderHeadings()}
164
+ </li>
165
+ );
166
+
167
+ const renderPageGroup = (label: string, pages: SiteNav["pages"]) =>
168
+ pages.length > 0 && (
169
+ <div className="mx-4 mt-2 border-t border-base-200 pt-2">
170
+ <p className="px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">{label}</p>
171
+ <ul className="space-y-1">{pages.map(renderPage)}</ul>
172
+ </div>
173
+ );
174
+
85
175
  return (
86
176
  <>
87
- {/* Mobile header bar */}
88
177
  <header className="fixed top-0 left-0 right-0 z-50 bg-base lg:hidden">
89
178
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
90
179
  <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
- >
180
+ <button onClick={() => setIsOpen(!isOpen)} className="cursor-pointer p-2 text-base-contrast" aria-label="Toggle navigation">
96
181
  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
97
182
  {isOpen
98
183
  ? <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
- }
184
+ : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />}
101
185
  </svg>
102
186
  </button>
103
187
  </div>
104
188
  </header>
105
189
 
106
- {/* Backdrop */}
107
- {isOpen && (
108
- <div
109
- className="fixed inset-0 z-40 bg-black/50 lg:hidden"
110
- onClick={() => setIsOpen(false)}
111
- />
112
- )}
190
+ {isOpen && <div className="fixed inset-0 z-40 bg-black/50 lg:hidden" onClick={() => setIsOpen(false)} />}
113
191
 
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 */}
192
+ <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",
193
+ isOpen ? "translate-x-0" : "-translate-x-full")}>
122
194
  <div className="hidden px-4 py-4 lg:block">
123
195
  <span className="text-lg font-bold text-primary">{siteName}</span>
124
196
  </div>
125
197
 
126
198
  <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
- })}
199
+ {siteNav.pages.map(renderPage)}
192
200
  </ul>
193
201
 
202
+ {renderPageGroup("Hidden from Menu", siteNav.hidden)}
203
+ {renderPageGroup("Archive", siteNav.archive)}
204
+
194
205
  <div className="mt-auto">
195
206
  {currentDarkMode === "optional" && (
196
207
  <div className="mx-4 border-t border-base-200 py-4">
@@ -201,37 +212,21 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
201
212
  </div>
202
213
  </div>
203
214
  )}
204
-
205
215
  {lastUpdated && (
206
216
  <div className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
207
217
  {isEditMode ? (
208
218
  <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
- >
219
+ <button ref={historyButtonRef} onClick={() => setShowHistory((p) => !p)}
220
+ className="cursor-pointer text-xs text-base-contrast-light hover:text-primary transition-colors" aria-label="View history">
215
221
  Last updated {formatDate(lastUpdated)}
216
222
  </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
- />
223
+ <Popover isOpen={showHistory} onClose={() => setShowHistory(false)} anchorRef={historyButtonRef}
224
+ className="w-56 !bottom-full !top-auto !mb-1 !mt-0">
225
+ <HistoryPopover onSelectCommit={(sha, date) => { setShowHistory(false); historySelectEvent.dispatch({ sha, date }); }} />
229
226
  </Popover>
230
227
  </div>
231
228
  ) : (
232
- <p className="text-xs text-base-contrast-light">
233
- Last updated {formatDate(lastUpdated)}
234
- </p>
229
+ <p className="text-xs text-base-contrast-light">Last updated {formatDate(lastUpdated)}</p>
235
230
  )}
236
231
  </div>
237
232
  )}
@@ -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
+ }