@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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Check } from "lucide-react";
|
|
3
|
+
import type { SiteIndex, Page } from "../../schemas/site-config";
|
|
4
|
+
import type { Audience } from "../../auth/types";
|
|
5
|
+
import { Button } from "../shared/Button";
|
|
6
|
+
import { Input } from "../shared/Input";
|
|
7
|
+
import { Checkbox } from "../shared/Checkbox";
|
|
8
|
+
import { AudienceIndicator } from "./AudienceIndicator";
|
|
9
|
+
import { DragHandle } from "./DragHandle";
|
|
10
|
+
import { slugifyPageSlug, uniquePageSlug, pageDisplayTitle } from "../../lib/pages";
|
|
11
|
+
import { RESERVED_SLUGS } from "../../schemas/site-config";
|
|
12
|
+
import { cn } from "../../lib/cn";
|
|
13
|
+
|
|
14
|
+
export interface PagesModalProps {
|
|
15
|
+
index: SiteIndex;
|
|
16
|
+
audiences: Audience[];
|
|
17
|
+
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
18
|
+
/** Creates the page and returns its id so the modal can expand the new row. */
|
|
19
|
+
onAddPage: () => string;
|
|
20
|
+
onSetHome: (pageId: string) => void;
|
|
21
|
+
onSetArchived: (pageId: string, archived: boolean) => void;
|
|
22
|
+
onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
23
|
+
onSetAudience: (pageId: string, access: string[]) => void;
|
|
24
|
+
onConfirmDelete: (pageId: string) => void;
|
|
25
|
+
/** Navigate the editor to this page (the shell also closes the modal). */
|
|
26
|
+
onNavigate: (pageId: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PagesModal(props: PagesModalProps) {
|
|
30
|
+
const { index, audiences } = props;
|
|
31
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
32
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
33
|
+
// The page created via "+ Add page" this session: its title gets auto-focus and
|
|
34
|
+
// its slug follows the title until the user edits the slug by hand.
|
|
35
|
+
const [newPageId, setNewPageId] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
if (deletingId) {
|
|
38
|
+
const page = index.pages.find((p) => p.id === deletingId);
|
|
39
|
+
if (page) return <DeleteConfirm page={page} onCancel={() => setDeletingId(null)} onConfirm={() => { props.onConfirmDelete(page.id); setDeletingId(null); }} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const live = index.pages.filter((p) => p.status === "live");
|
|
43
|
+
const archived = index.pages.filter((p) => p.status === "archived");
|
|
44
|
+
|
|
45
|
+
// Collapsing the new row — or expanding any other — ends its "new page"
|
|
46
|
+
// editing session (auto-focus + slug-follows-title).
|
|
47
|
+
const toggleExpand = (pageId: string) => {
|
|
48
|
+
const next = expandedId === pageId ? null : pageId;
|
|
49
|
+
setExpandedId(next);
|
|
50
|
+
if (newPageId !== null && next !== newPageId) setNewPageId(null);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const renderPage = (page: Page, dragIndex?: number) => (
|
|
54
|
+
<PageRow
|
|
55
|
+
key={page.id}
|
|
56
|
+
page={page}
|
|
57
|
+
audiences={audiences}
|
|
58
|
+
expanded={expandedId === page.id}
|
|
59
|
+
isNew={newPageId === page.id}
|
|
60
|
+
otherSlugs={index.pages.filter((p) => p.id !== page.id).map((p) => p.slug)}
|
|
61
|
+
dragIndex={dragIndex}
|
|
62
|
+
onReorder={props.onReorder}
|
|
63
|
+
onToggleExpand={() => toggleExpand(page.id)}
|
|
64
|
+
onNavigate={() => props.onNavigate(page.id)}
|
|
65
|
+
onSetHome={() => props.onSetHome(page.id)}
|
|
66
|
+
onSetArchived={(v) => {
|
|
67
|
+
// Archiving/restoring remounts the row in its new group, so end the
|
|
68
|
+
// "new page" session — its blank-draft state must not survive the move.
|
|
69
|
+
if (newPageId === page.id) setNewPageId(null);
|
|
70
|
+
props.onSetArchived(page.id, v);
|
|
71
|
+
}}
|
|
72
|
+
onSetFields={(patch) => props.onSetFields(page.id, patch)}
|
|
73
|
+
onSetAudience={(access) => props.onSetAudience(page.id, access)}
|
|
74
|
+
onRequestDelete={() =>
|
|
75
|
+
page.order.length === 0 ? props.onConfirmDelete(page.id) : setDeletingId(page.id)
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex flex-col gap-2 overflow-y-auto">
|
|
82
|
+
<div className="flex flex-col gap-2">
|
|
83
|
+
{live.map((page) => renderPage(page, index.pages.indexOf(page)))}
|
|
84
|
+
</div>
|
|
85
|
+
<Button
|
|
86
|
+
variant="secondary"
|
|
87
|
+
size="md"
|
|
88
|
+
onClick={() => {
|
|
89
|
+
const id = props.onAddPage();
|
|
90
|
+
setNewPageId(id);
|
|
91
|
+
setExpandedId(id);
|
|
92
|
+
}}
|
|
93
|
+
className="mt-2 self-start"
|
|
94
|
+
>
|
|
95
|
+
+ Add page
|
|
96
|
+
</Button>
|
|
97
|
+
{archived.length > 0 && (
|
|
98
|
+
<div className="mt-4 border-t border-base-200 pt-3">
|
|
99
|
+
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">Archive</p>
|
|
100
|
+
<div className="flex flex-col gap-2">{archived.map((page) => renderPage(page))}</div>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function PageRow({
|
|
108
|
+
page, audiences, expanded, isNew, otherSlugs, dragIndex, onReorder, onToggleExpand, onNavigate, onSetHome, onSetArchived, onSetFields, onSetAudience, onRequestDelete,
|
|
109
|
+
}: {
|
|
110
|
+
page: Page; audiences: Audience[]; expanded: boolean;
|
|
111
|
+
isNew: boolean; otherSlugs: string[];
|
|
112
|
+
dragIndex?: number; onReorder: (fromIndex: number, toIndex: number) => void;
|
|
113
|
+
onToggleExpand: () => void; onNavigate: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => void;
|
|
114
|
+
onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
115
|
+
onSetAudience: (access: string[]) => void; onRequestDelete: () => void;
|
|
116
|
+
}) {
|
|
117
|
+
const rowRef = useRef<HTMLDivElement>(null);
|
|
118
|
+
const handleRef = useRef<HTMLButtonElement>(null);
|
|
119
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
120
|
+
const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
|
|
121
|
+
const draggable = dragIndex !== undefined;
|
|
122
|
+
|
|
123
|
+
// The slug input edits a local draft; only valid values commit to the index,
|
|
124
|
+
// which must always hold a non-empty, unique, non-reserved slug.
|
|
125
|
+
// Blank draft only while the new page has no title yet; once a slug has been
|
|
126
|
+
// committed it must survive row remounts (e.g. moving between nav groups).
|
|
127
|
+
const [slugDraft, setSlugDraft] = useState(isNew && !page.title ? "" : page.slug);
|
|
128
|
+
const [autoSlug, setAutoSlug] = useState(isNew);
|
|
129
|
+
const [titleDirty, setTitleDirty] = useState(false);
|
|
130
|
+
const [slugDirty, setSlugDirty] = useState(false);
|
|
131
|
+
const [showErrors, setShowErrors] = useState(false);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!expanded) return;
|
|
135
|
+
setSlugDraft(isNew && !page.title ? "" : page.slug);
|
|
136
|
+
setAutoSlug(isNew);
|
|
137
|
+
setTitleDirty(false);
|
|
138
|
+
setSlugDirty(false);
|
|
139
|
+
setShowErrors(false);
|
|
140
|
+
// Reset editing state each time the row expands; page.slug is read at that moment.
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
+
}, [expanded]);
|
|
143
|
+
|
|
144
|
+
const committedSlug = slugifyPageSlug(slugDraft);
|
|
145
|
+
const titleError = page.title.trim() === "" ? "Title is required." : null;
|
|
146
|
+
const slugError = page.isHome
|
|
147
|
+
? null
|
|
148
|
+
: committedSlug === ""
|
|
149
|
+
? "Slug is required."
|
|
150
|
+
: (RESERVED_SLUGS as readonly string[]).includes(committedSlug)
|
|
151
|
+
? `"${committedSlug}" is a reserved URL.`
|
|
152
|
+
: otherSlugs.includes(committedSlug)
|
|
153
|
+
? "Another page already uses this slug."
|
|
154
|
+
: null;
|
|
155
|
+
|
|
156
|
+
const handleTitleChange = (title: string) => {
|
|
157
|
+
setTitleDirty(true);
|
|
158
|
+
if (autoSlug && !page.isHome) {
|
|
159
|
+
const base = slugifyPageSlug(title);
|
|
160
|
+
if (base !== "") {
|
|
161
|
+
const slug = uniquePageSlug(base, otherSlugs);
|
|
162
|
+
setSlugDraft(slug);
|
|
163
|
+
onSetFields({ title, slug });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setSlugDraft("");
|
|
167
|
+
}
|
|
168
|
+
onSetFields({ title });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleSlugChange = (raw: string) => {
|
|
172
|
+
setSlugDirty(true);
|
|
173
|
+
setAutoSlug(false);
|
|
174
|
+
// Normalize for typing but keep a trailing hyphen so "my-page" can be typed.
|
|
175
|
+
const draft = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+/, "");
|
|
176
|
+
setSlugDraft(draft);
|
|
177
|
+
const slug = slugifyPageSlug(draft);
|
|
178
|
+
if (
|
|
179
|
+
slug !== "" &&
|
|
180
|
+
!(RESERVED_SLUGS as readonly string[]).includes(slug) &&
|
|
181
|
+
!otherSlugs.includes(slug)
|
|
182
|
+
) {
|
|
183
|
+
onSetFields({ slug });
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const handleHeaderClick = () => {
|
|
188
|
+
if (expanded && (titleError || slugError)) {
|
|
189
|
+
setShowErrors(true);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
onToggleExpand();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (dragIndex === undefined) return;
|
|
197
|
+
const row = rowRef.current;
|
|
198
|
+
const handle = handleRef.current;
|
|
199
|
+
if (!row || !handle) return;
|
|
200
|
+
|
|
201
|
+
let cleanup: (() => void) | undefined;
|
|
202
|
+
let cancelled = false;
|
|
203
|
+
|
|
204
|
+
Promise.all([
|
|
205
|
+
import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
|
|
206
|
+
import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
|
|
207
|
+
]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
|
|
208
|
+
if (cancelled) return;
|
|
209
|
+
|
|
210
|
+
const cleanupDraggable = draggable({
|
|
211
|
+
element: row,
|
|
212
|
+
dragHandle: handle,
|
|
213
|
+
getInitialData: () => ({ dragType: "page-ordering-row", index: dragIndex }),
|
|
214
|
+
onGenerateDragPreview: () => {
|
|
215
|
+
row.style.opacity = "0.4";
|
|
216
|
+
requestAnimationFrame(() => {
|
|
217
|
+
row.style.opacity = "";
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
onDragStart: () => setIsDragging(true),
|
|
221
|
+
onDrop: () => setIsDragging(false),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const cleanupDropTarget = dropTargetForElements({
|
|
225
|
+
element: row,
|
|
226
|
+
canDrop: ({ source }) => source.data.dragType === "page-ordering-row",
|
|
227
|
+
getData: ({ input, element }) =>
|
|
228
|
+
attachClosestEdge({ index: dragIndex }, { input, element, allowedEdges: ["top", "bottom"] }),
|
|
229
|
+
onDragEnter: ({ self }) => {
|
|
230
|
+
const edge = extractClosestEdge(self.data);
|
|
231
|
+
setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
|
|
232
|
+
},
|
|
233
|
+
onDrag: ({ self }) => {
|
|
234
|
+
const edge = extractClosestEdge(self.data);
|
|
235
|
+
setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
|
|
236
|
+
},
|
|
237
|
+
onDragLeave: () => setClosestEdge(null),
|
|
238
|
+
onDrop: ({ source, self }) => {
|
|
239
|
+
setClosestEdge(null);
|
|
240
|
+
const fromIndex = source.data.index as number;
|
|
241
|
+
const edge = extractClosestEdge(self.data);
|
|
242
|
+
let toIndex = dragIndex;
|
|
243
|
+
if (edge === "bottom") toIndex = dragIndex + 1;
|
|
244
|
+
if (fromIndex < toIndex) toIndex--;
|
|
245
|
+
if (fromIndex !== toIndex) {
|
|
246
|
+
onReorder(fromIndex, toIndex);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
cleanup = () => {
|
|
252
|
+
cleanupDraggable();
|
|
253
|
+
cleanupDropTarget();
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return () => {
|
|
258
|
+
cancelled = true;
|
|
259
|
+
cleanup?.();
|
|
260
|
+
};
|
|
261
|
+
}, [dragIndex, onReorder]);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
ref={rowRef}
|
|
266
|
+
className={cn("relative rounded-md border border-base-200", isDragging && "opacity-50")}
|
|
267
|
+
>
|
|
268
|
+
{closestEdge === "top" && (
|
|
269
|
+
<div className="absolute top-0 right-0 left-0 z-10 h-0.5 -translate-y-1/2 bg-primary" />
|
|
270
|
+
)}
|
|
271
|
+
{closestEdge === "bottom" && (
|
|
272
|
+
<div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 translate-y-1/2 bg-primary" />
|
|
273
|
+
)}
|
|
274
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
275
|
+
{draggable && (
|
|
276
|
+
<div className="shrink-0 [&_[role=tooltip]]:hidden">
|
|
277
|
+
<DragHandle ref={handleRef} />
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
aria-label={`Go to ${pageDisplayTitle(page.title)}`}
|
|
283
|
+
onClick={onNavigate}
|
|
284
|
+
className="group min-w-0 flex-1 cursor-pointer text-left"
|
|
285
|
+
>
|
|
286
|
+
<div className="flex items-center gap-2">
|
|
287
|
+
<span className={cn("truncate text-sm font-medium text-base-contrast transition-colors group-hover:text-primary", !page.title && "italic text-base-contrast/60")}>
|
|
288
|
+
{pageDisplayTitle(page.title)}
|
|
289
|
+
</span>
|
|
290
|
+
{page.isHome && <span className="rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-contrast">Home page</span>}
|
|
291
|
+
{!page.showInNav && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Hidden</span>}
|
|
292
|
+
{page.status === "archived" && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Archived</span>}
|
|
293
|
+
</div>
|
|
294
|
+
<div className="text-xs text-base-contrast/70 transition-colors group-hover:text-primary">{page.isHome ? "/" : `/${page.slug}`}</div>
|
|
295
|
+
</button>
|
|
296
|
+
<button
|
|
297
|
+
type="button"
|
|
298
|
+
aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
|
|
299
|
+
onClick={handleHeaderClick}
|
|
300
|
+
className="cursor-pointer rounded px-2 py-1 text-sm text-base-contrast/70 hover:text-primary"
|
|
301
|
+
>
|
|
302
|
+
{expanded ? "Done" : "Edit"}
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{expanded && (
|
|
307
|
+
<div className="flex flex-col gap-3 border-t border-base-200 px-3 py-3">
|
|
308
|
+
<Input
|
|
309
|
+
label="Title"
|
|
310
|
+
value={page.title}
|
|
311
|
+
placeholder="Page title"
|
|
312
|
+
autoFocus={isNew}
|
|
313
|
+
error={(titleDirty || showErrors) && titleError ? titleError : undefined}
|
|
314
|
+
onChange={handleTitleChange}
|
|
315
|
+
/>
|
|
316
|
+
{!page.isHome && (
|
|
317
|
+
<Input
|
|
318
|
+
label="Slug"
|
|
319
|
+
value={slugDraft}
|
|
320
|
+
placeholder="page-url"
|
|
321
|
+
error={(slugDirty || showErrors) && slugError ? slugError : undefined}
|
|
322
|
+
onChange={handleSlugChange}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
<div className="flex items-start justify-between gap-4">
|
|
326
|
+
<div>
|
|
327
|
+
<label className="mb-1 block text-sm text-base-contrast">Audience</label>
|
|
328
|
+
<AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
|
|
329
|
+
{page.access.length === 0 && (
|
|
330
|
+
<p className="mt-1 text-xs text-base-contrast/70">
|
|
331
|
+
Viewers see this page; sections still apply their own audience rules.
|
|
332
|
+
</p>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
<Checkbox
|
|
336
|
+
label="Hide from Menu"
|
|
337
|
+
align="center"
|
|
338
|
+
checked={!page.showInNav}
|
|
339
|
+
onChange={(hidden) => onSetFields({ showInNav: !hidden })}
|
|
340
|
+
className="shrink-0"
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
<div className="flex items-center gap-2">
|
|
344
|
+
<Button
|
|
345
|
+
variant="secondary"
|
|
346
|
+
size="sm"
|
|
347
|
+
disabled={page.isHome}
|
|
348
|
+
onClick={onSetHome}
|
|
349
|
+
className={cn(
|
|
350
|
+
page.isHome &&
|
|
351
|
+
"cursor-default gap-1 border-primary bg-primary text-primary-contrast opacity-100 hover:bg-primary hover:opacity-90",
|
|
352
|
+
)}
|
|
353
|
+
>
|
|
354
|
+
{page.isHome && <Check size={12} strokeWidth={3} aria-hidden="true" />}
|
|
355
|
+
{page.isHome ? "Home page" : "Set as home"}
|
|
356
|
+
</Button>
|
|
357
|
+
<Button
|
|
358
|
+
variant="secondary"
|
|
359
|
+
size="sm"
|
|
360
|
+
disabled={page.isHome}
|
|
361
|
+
onClick={() => onSetArchived(page.status !== "archived")}
|
|
362
|
+
aria-label={page.status === "archived" ? "Restore" : "Archive"}
|
|
363
|
+
>
|
|
364
|
+
{page.status === "archived" ? "Restore" : "Archive"}
|
|
365
|
+
</Button>
|
|
366
|
+
<Button variant="destructive" size="sm" disabled={page.isHome} aria-label="Delete page" onClick={onRequestDelete} className="ml-auto">
|
|
367
|
+
Delete
|
|
368
|
+
</Button>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function DeleteConfirm({ page, onCancel, onConfirm }: { page: Page; onCancel: () => void; onConfirm: () => void }) {
|
|
377
|
+
const [typed, setTyped] = useState("");
|
|
378
|
+
const n = page.order.length;
|
|
379
|
+
// Compare against the display title: a blank title must not leave the
|
|
380
|
+
// type-the-name guard pre-satisfied ("" === "").
|
|
381
|
+
const confirmName = pageDisplayTitle(page.title);
|
|
382
|
+
return (
|
|
383
|
+
<div className="flex flex-col gap-3">
|
|
384
|
+
<p className="text-sm text-base-contrast">
|
|
385
|
+
Deleting <strong>{confirmName}</strong> permanently removes the page and its{" "}
|
|
386
|
+
<strong>{n} section{n === 1 ? "" : "s"}</strong>. This is recoverable only via GitHub version history.
|
|
387
|
+
</p>
|
|
388
|
+
<Input
|
|
389
|
+
label={`Type the page name "${confirmName}" to confirm`}
|
|
390
|
+
value={typed}
|
|
391
|
+
onChange={setTyped}
|
|
392
|
+
placeholder={confirmName}
|
|
393
|
+
/>
|
|
394
|
+
<div className="flex justify-end gap-3">
|
|
395
|
+
<Button variant="secondary" size="md" onClick={onCancel}>Cancel</Button>
|
|
396
|
+
<Button variant="destructive" size="md" disabled={typed !== confirmName} onClick={onConfirm}>Delete</Button>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { ArrowRightLeft } from "lucide-react";
|
|
2
3
|
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
|
3
4
|
import { DragHandle } from "./DragHandle";
|
|
4
5
|
import { InsertButton } from "./InsertButton";
|
|
@@ -29,6 +30,7 @@ export function SectionWrapper({
|
|
|
29
30
|
onReorder,
|
|
30
31
|
onRequestInsert,
|
|
31
32
|
onDelete,
|
|
33
|
+
onMoveSection,
|
|
32
34
|
mainStatus,
|
|
33
35
|
contentDiffersFromMain,
|
|
34
36
|
isLocalOnly,
|
|
@@ -286,6 +288,17 @@ export function SectionWrapper({
|
|
|
286
288
|
<SettingsButton onClick={handleSettingsClick} />
|
|
287
289
|
)}
|
|
288
290
|
|
|
291
|
+
{onMoveSection && (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
onClick={onMoveSection}
|
|
295
|
+
aria-label="Move to page"
|
|
296
|
+
className="pointer-events-auto cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
|
|
297
|
+
>
|
|
298
|
+
<ArrowRightLeft size={16} />
|
|
299
|
+
</button>
|
|
300
|
+
)}
|
|
301
|
+
|
|
289
302
|
{onDelete && <DeleteButton onDelete={onDelete} />}
|
|
290
303
|
</div>
|
|
291
304
|
|
|
@@ -4,6 +4,8 @@ import { Input } from "../shared/Input";
|
|
|
4
4
|
import { Select } from "../shared/Select";
|
|
5
5
|
import { Checkbox } from "../shared/Checkbox";
|
|
6
6
|
import { FormLabel } from "../shared/FormLabel";
|
|
7
|
+
import { LinkField } from "../shared/LinkField";
|
|
8
|
+
import type { LinkValue } from "../../schemas/link";
|
|
7
9
|
import { cn } from "../../lib/cn";
|
|
8
10
|
|
|
9
11
|
export interface SettingsFormResult {
|
|
@@ -150,6 +152,16 @@ export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
|
|
|
150
152
|
/>
|
|
151
153
|
);
|
|
152
154
|
|
|
155
|
+
case "link":
|
|
156
|
+
return (
|
|
157
|
+
<LinkField
|
|
158
|
+
key={key}
|
|
159
|
+
label={field.label}
|
|
160
|
+
value={(value as LinkValue) ?? field.default}
|
|
161
|
+
onChange={(v) => handleFieldChange(key, v)}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
|
|
153
165
|
default:
|
|
154
166
|
return null;
|
|
155
167
|
}
|
|
@@ -1,27 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import type { Editor } from "@tiptap/core";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
+
import { usePages } from "../shared/PagesContext";
|
|
5
|
+
import { internalHref, parseInternalHref } from "../../lib/links";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
4
7
|
|
|
5
8
|
interface LinkPopoverProps {
|
|
6
9
|
editor: Editor;
|
|
7
10
|
onClose: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
const selectClasses =
|
|
14
|
+
"w-full rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast focus:outline-none focus:ring-1 focus:ring-primary";
|
|
15
|
+
|
|
10
16
|
export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
17
|
+
const { pages, getPageHeadings } = usePages();
|
|
11
18
|
const existingAttrs = editor.getAttributes("link");
|
|
12
|
-
const
|
|
19
|
+
const existingInternal = parseInternalHref(existingAttrs.href ?? "");
|
|
20
|
+
|
|
21
|
+
const [mode, setMode] = useState<"external" | "internal">(existingInternal ? "internal" : "external");
|
|
22
|
+
const [url, setUrl] = useState<string>(existingInternal ? "" : existingAttrs.href ?? "");
|
|
23
|
+
const [pageId, setPageId] = useState<string>(existingInternal?.pageId ?? "");
|
|
24
|
+
const [anchorId, setAnchorId] = useState<string>(existingInternal?.anchorSectionId ?? "");
|
|
13
25
|
const [openInNewTab, setOpenInNewTab] = useState<boolean>(
|
|
14
26
|
existingAttrs.target === "_blank"
|
|
15
27
|
);
|
|
16
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
17
28
|
const hasLink = Boolean(existingAttrs.href);
|
|
18
|
-
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
inputRef.current?.focus();
|
|
21
|
-
}, []);
|
|
29
|
+
const headings = pageId ? getPageHeadings(pageId) : [];
|
|
22
30
|
|
|
23
31
|
const handleApply = () => {
|
|
24
|
-
if (
|
|
32
|
+
if (mode === "internal") {
|
|
33
|
+
if (pageId) {
|
|
34
|
+
editor
|
|
35
|
+
.chain()
|
|
36
|
+
.focus()
|
|
37
|
+
.extendMarkRange("link")
|
|
38
|
+
.setLink({ href: internalHref(pageId, anchorId || null), target: null })
|
|
39
|
+
.run();
|
|
40
|
+
}
|
|
41
|
+
} else if (url.trim()) {
|
|
25
42
|
editor
|
|
26
43
|
.chain()
|
|
27
44
|
.focus()
|
|
@@ -49,27 +66,82 @@ export function LinkPopover({ editor, onClose }: LinkPopoverProps) {
|
|
|
49
66
|
|
|
50
67
|
return (
|
|
51
68
|
<div
|
|
52
|
-
className="flex flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
69
|
+
className="flex w-64 flex-col gap-2 rounded bg-base-accent p-3 shadow-lg"
|
|
53
70
|
onMouseDown={(e) => e.stopPropagation()}
|
|
71
|
+
onKeyDown={handleKeyDown}
|
|
54
72
|
>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
<div className="flex gap-2" role="tablist">
|
|
74
|
+
{(["external", "internal"] as const).map((kind) => (
|
|
75
|
+
<button
|
|
76
|
+
key={kind}
|
|
77
|
+
type="button"
|
|
78
|
+
role="tab"
|
|
79
|
+
aria-selected={mode === kind}
|
|
80
|
+
onClick={() => setMode(kind)}
|
|
81
|
+
className={cn(
|
|
82
|
+
"cursor-pointer rounded border px-2 py-0.5 text-xs",
|
|
83
|
+
mode === kind
|
|
84
|
+
? "border-primary bg-primary text-primary-contrast"
|
|
85
|
+
: "border-base-200 text-base-contrast",
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{kind === "external" ? "External URL" : "Internal page"}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{mode === "external" ? (
|
|
94
|
+
<>
|
|
95
|
+
<input
|
|
96
|
+
autoFocus
|
|
97
|
+
type="url"
|
|
98
|
+
value={url}
|
|
99
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
100
|
+
placeholder="https://example.com"
|
|
101
|
+
className="rounded border border-base-contrast/20 bg-base px-2 py-1 text-sm text-base-contrast placeholder:text-base-contrast/40 focus:outline-none focus:ring-1 focus:ring-primary"
|
|
102
|
+
/>
|
|
103
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm text-base-contrast">
|
|
104
|
+
<input
|
|
105
|
+
type="checkbox"
|
|
106
|
+
checked={openInNewTab}
|
|
107
|
+
onChange={(e) => setOpenInNewTab(e.target.checked)}
|
|
108
|
+
className="accent-primary"
|
|
109
|
+
/>
|
|
110
|
+
Open in new tab
|
|
111
|
+
</label>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<select
|
|
116
|
+
autoFocus
|
|
117
|
+
aria-label="Page"
|
|
118
|
+
value={pageId}
|
|
119
|
+
onChange={(e) => {
|
|
120
|
+
setPageId(e.target.value);
|
|
121
|
+
setAnchorId("");
|
|
122
|
+
}}
|
|
123
|
+
className={selectClasses}
|
|
124
|
+
>
|
|
125
|
+
<option value="">Select a page…</option>
|
|
126
|
+
{pages.map((p) => (
|
|
127
|
+
<option key={p.id} value={p.id}>{p.title}</option>
|
|
128
|
+
))}
|
|
129
|
+
</select>
|
|
130
|
+
<select
|
|
131
|
+
aria-label="Section"
|
|
132
|
+
value={anchorId}
|
|
133
|
+
disabled={!pageId}
|
|
134
|
+
onChange={(e) => setAnchorId(e.target.value)}
|
|
135
|
+
className={cn(selectClasses, !pageId && "cursor-not-allowed opacity-50")}
|
|
136
|
+
>
|
|
137
|
+
<option value="">None (top of page)</option>
|
|
138
|
+
{headings.map((h) => (
|
|
139
|
+
<option key={h.id} value={h.id}>{h.label}</option>
|
|
140
|
+
))}
|
|
141
|
+
</select>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
|
|
73
145
|
<div className="flex gap-2">
|
|
74
146
|
<Button
|
|
75
147
|
variant="brand"
|
|
@@ -42,7 +42,9 @@ const rich: Extensions = [
|
|
|
42
42
|
}),
|
|
43
43
|
CustomParagraph,
|
|
44
44
|
Underline,
|
|
45
|
-
|
|
45
|
+
// `page` protocol: stable internal-page hrefs (page://{pageId}#{sectionId})
|
|
46
|
+
// resolved to real routes at SSR — see lib/links.ts.
|
|
47
|
+
Link.configure({ openOnClick: false, protocols: ["page"] }),
|
|
46
48
|
];
|
|
47
49
|
|
|
48
50
|
export type PresetName = "basic" | "rich";
|
|
@@ -2,16 +2,16 @@ import { cn } from "../../../lib/cn";
|
|
|
2
2
|
import { Download } from "lucide-react";
|
|
3
3
|
import { EditablePlainText } from "../../primitives/EditablePlainText";
|
|
4
4
|
import type { SectionContent } from "../../../schemas/sections";
|
|
5
|
+
import type { LinkValue } from "../../../schemas/link";
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
text: string;
|
|
8
|
-
|
|
9
|
-
target?: string;
|
|
9
|
+
link?: LinkValue;
|
|
10
10
|
download?: boolean;
|
|
11
11
|
onChange?: (content: SectionContent) => void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export default function Button({ text,
|
|
14
|
+
export default function Button({ text, link, download, onChange }: Props) {
|
|
15
15
|
const base = "inline-block rounded-md px-6 py-3 font-bold transition-colors";
|
|
16
16
|
const variant = "border-2 border-primary text-primary hover:bg-primary hover:text-primary-contrast";
|
|
17
17
|
|
|
@@ -23,10 +23,7 @@ export default function Button({ text, href, target, download, onChange }: Props
|
|
|
23
23
|
tag="span"
|
|
24
24
|
value={text}
|
|
25
25
|
onChange={(newText) =>
|
|
26
|
-
onChange({
|
|
27
|
-
type: "button",
|
|
28
|
-
content: { text: newText, href, target, download },
|
|
29
|
-
})
|
|
26
|
+
onChange({ type: "button", content: { text: newText, link, download } })
|
|
30
27
|
}
|
|
31
28
|
isEditMode={true}
|
|
32
29
|
placeholder="Button text"
|
|
@@ -35,16 +32,18 @@ export default function Button({ text, href, target, download, onChange }: Props
|
|
|
35
32
|
);
|
|
36
33
|
}
|
|
37
34
|
|
|
35
|
+
// Viewer: links are resolved to { kind: "external", href, target } upstream.
|
|
36
|
+
const href = link && link.kind === "external" ? link.href : "";
|
|
37
|
+
const target = link?.target;
|
|
38
38
|
if (href) {
|
|
39
39
|
return (
|
|
40
|
-
<a href={href} target={target} download={download ? "" : undefined}
|
|
40
|
+
<a href={href} target={target} download={download ? "" : undefined}
|
|
41
|
+
className={cn(base, variant, download && "inline-flex items-center gap-2")}>
|
|
41
42
|
{download && <Download size={16} className="shrink-0" />}
|
|
42
43
|
{text}
|
|
43
44
|
</a>
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
return (
|
|
48
|
-
<button className={cn("cursor-pointer", base, variant)}>{text}</button>
|
|
49
|
-
);
|
|
48
|
+
return <button className={cn("cursor-pointer", base, variant)}>{text}</button>;
|
|
50
49
|
}
|