@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.
- package/dist/{chunk-BJ6FYGYP.js → chunk-24SUF2BC.js} +98 -9
- package/dist/{chunk-P3HO76OS.js → chunk-KDGYHU36.js} +6 -3
- package/dist/{chunk-5XYUO4HP.js → chunk-PUNXQK4M.js} +19 -2
- package/dist/components/editor/MoveSectionModal.d.ts +12 -0
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +18 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
- package/dist/components/sections/Button/CTAButton.d.ts +3 -3
- package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
- package/dist/components/sections/Button/index.d.ts +10 -2
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/shared/Input.d.ts +1 -0
- package/dist/components/shared/Input.d.ts.map +1 -1
- package/dist/components/shared/LinkField.d.ts +9 -0
- package/dist/components/shared/LinkField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts +4 -3
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/PagesContext.d.ts +13 -0
- package/dist/components/shared/PagesContext.d.ts.map +1 -0
- package/dist/components/shared/RadioGroup.d.ts +15 -0
- package/dist/components/shared/RadioGroup.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +16 -3
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +25 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav.d.ts +23 -0
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +31 -0
- package/dist/lib/pages.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +17 -3
- package/dist/schemas/link.d.ts +24 -0
- package/dist/schemas/link.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +129 -3
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/MoveSectionModal.tsx +36 -0
- package/src/components/editor/PagesModal.tsx +400 -0
- package/src/components/editor/SectionWrapper.tsx +13 -0
- package/src/components/editor/SettingsForm.tsx +12 -0
- package/src/components/primitives/LinkPopover.tsx +99 -27
- package/src/components/primitives/tiptap-presets.ts +3 -1
- package/src/components/sections/Button/CTAButton.tsx +10 -11
- package/src/components/sections/Button/index.tsx +4 -9
- package/src/components/shared/Input.tsx +14 -3
- package/src/components/shared/LinkField.tsx +90 -0
- package/src/components/shared/Navigation.tsx +147 -137
- package/src/components/shared/PagesContext.tsx +12 -0
- package/src/components/shared/RadioGroup.tsx +71 -0
- package/src/components/shell/EditorShell.tsx +273 -78
- package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
- package/src/hooks/useBuildStatus.ts +19 -4
- package/src/hooks/useEditorPublish.ts +1 -1
- package/src/lib/dexie.ts +18 -5
- package/src/lib/events.ts +3 -1
- package/src/lib/links.ts +108 -0
- package/src/lib/loader.ts +5 -4
- package/src/lib/nav.ts +59 -0
- package/src/lib/pages.ts +209 -0
- package/src/lib/registry.ts +8 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/link.ts +17 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
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,
|
|
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
|
-
|
|
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({
|
|
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 [
|
|
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 =
|
|
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
|
-
//
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
115
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
+
}
|