@drawnagency/primitives 0.1.109 → 0.1.111
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/auth/cookies.d.ts +8 -0
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +11 -4
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-WXBHP4JY.js → chunk-3GAG6ZXB.js} +55 -46
- package/dist/{chunk-FM4M4Q2W.js → chunk-4TZJZU2T.js} +23 -3
- package/dist/{chunk-5PVRACZY.js → chunk-7MI3HCOF.js} +60 -23
- package/dist/{chunk-WBDPDGFA.js → chunk-7WQJ57XG.js} +6 -31
- package/dist/{chunk-LQHYVPBS.js → chunk-ALMEC3XN.js} +11 -12
- package/dist/{chunk-TZQPOR5A.js → chunk-DMCGN5AS.js} +10 -3
- package/dist/{chunk-ZKVKNUJC.js → chunk-ELQ4C677.js} +1 -1
- package/dist/{chunk-5BM2Y3NJ.js → chunk-FRVO75DK.js} +14 -2
- package/dist/{chunk-VKAGMEKE.js → chunk-FTPETWXE.js} +1 -0
- package/dist/{chunk-3TO4RRHY.js → chunk-GPPVYLEI.js} +7 -0
- package/dist/{chunk-NLQ5F3RQ.js → chunk-HBULBDGP.js} +1 -1
- package/dist/{chunk-67ZOQCUD.js → chunk-NXGIHSL5.js} +67 -50
- package/dist/{chunk-CPTFFLK2.js → chunk-OTDA6KWL.js} +10 -3
- package/dist/{chunk-XIDE4TDF.js → chunk-OUYB2GCW.js} +1 -1
- package/dist/{chunk-72CG6AA2.js → chunk-RA5IOFPY.js} +10 -2
- package/dist/{chunk-GEENI3OC.js → chunk-SW6PCQQD.js} +4 -0
- package/dist/{chunk-BOZUEKWL.js → chunk-UVIYI4J6.js} +1 -1
- package/dist/{chunk-AB3XVXI3.js → chunk-VCCNKTFD.js} +37 -29
- package/dist/{chunk-XBYJQ5RS.js → chunk-XQMDWXD6.js} +21 -6
- package/dist/collab/rebase.d.ts +6 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +12 -3
- package/dist/collab/recovery.d.ts +0 -20
- package/dist/collab/recovery.d.ts.map +1 -1
- package/dist/components/brandguide/ColorSwatchSettings.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsView.d.ts.map +1 -1
- package/dist/components/brandguide/colors-shared.d.ts +10 -1
- package/dist/components/brandguide/colors-shared.d.ts.map +1 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
- package/dist/components/docfile/PdfCanvasViewer.d.ts.map +1 -1
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/NavGroupRow.d.ts +7 -2
- package/dist/components/editor/NavGroupRow.d.ts.map +1 -1
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +13 -12
- package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
- package/dist/components/primitives/LinkPopover.d.ts.map +1 -1
- package/dist/components/primitives/index.js +14 -12
- package/dist/components/primitives/useEditableCollection.d.ts.map +1 -1
- package/dist/components/primitives/useEditablePlainText.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Prose/Prose.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +8 -8
- package/dist/components/sections/register-schemas.js +14 -14
- package/dist/components/sections/register.js +14 -14
- package/dist/components/shared/FontPicker.d.ts.map +1 -1
- package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -1
- package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shell/AudienceAddForm.d.ts +1 -5
- package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
- package/dist/components/shell/AudienceRow.d.ts +2 -3
- package/dist/components/shell/AudienceRow.d.ts.map +1 -1
- package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsUsers.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
- package/dist/components/shell/view-branch.d.ts.map +1 -1
- package/dist/document/components/DocRail.d.ts.map +1 -1
- package/dist/document/components/DocViewer.d.ts.map +1 -1
- package/dist/document/components/measure.d.ts.map +1 -1
- package/dist/document/components/useDocPages.d.ts.map +1 -1
- package/dist/document/engine/blocks.d.ts.map +1 -1
- package/dist/document/engine/masonry.d.ts.map +1 -1
- package/dist/document/engine/paginate.d.ts.map +1 -1
- package/dist/document/engine/settings.d.ts +3 -0
- package/dist/document/engine/settings.d.ts.map +1 -1
- package/dist/document/index.js +79 -28
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +1 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.d.ts +6 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +3 -2
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useSanitizedHtml.d.ts +12 -0
- package/dist/hooks/useSanitizedHtml.d.ts.map +1 -0
- package/dist/index.js +19 -13
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +2 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +21 -49
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +43 -23
- package/dist/lib/index.js +8 -8
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-folders.d.ts +12 -4
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +12 -2
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/lib/nav-progress.d.ts +14 -0
- package/dist/lib/nav-progress.d.ts.map +1 -0
- package/dist/lib/nav-tree.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +1 -1
- package/dist/lib/sanitize.d.ts +6 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/lib/timestamp.d.ts.map +1 -1
- package/dist/media/index.js +4 -4
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/audience.d.ts +5 -1
- package/dist/schemas/audience.d.ts.map +1 -1
- package/dist/schemas/audience.js +1 -1
- package/dist/schemas/index.js +8 -6
- package/dist/schemas/sections.d.ts +12 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +9 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +0 -3
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/auth/cookies.ts +11 -0
- package/src/auth/index.ts +1 -1
- package/src/auth/types.ts +18 -4
- package/src/collab/rebase.ts +26 -1
- package/src/collab/recovery.ts +29 -3
- package/src/components/brandguide/ColorSwatchSettings.tsx +40 -13
- package/src/components/brandguide/ColorsEditable.tsx +7 -9
- package/src/components/brandguide/ColorsView.tsx +12 -11
- package/src/components/brandguide/colors-shared.ts +17 -1
- package/src/components/chat/ChatPanel.tsx +19 -0
- package/src/components/docfile/DocFilePane.tsx +11 -0
- package/src/components/docfile/PdfCanvasViewer.tsx +46 -5
- package/src/components/docfile/doc-file-state.ts +7 -0
- package/src/components/editor/IndicatorPill.tsx +5 -1
- package/src/components/editor/NavGroupRow.tsx +8 -3
- package/src/components/editor/PagesModal.tsx +10 -2
- package/src/components/primitives/EditableList.tsx +10 -2
- package/src/components/primitives/EditableRichText.tsx +4 -3
- package/src/components/primitives/LinkPopover.tsx +23 -1
- package/src/components/primitives/useEditableCollection.ts +61 -41
- package/src/components/primitives/useEditablePlainText.ts +24 -3
- package/src/components/sections/IconList/index.tsx +8 -1
- package/src/components/sections/Prose/Prose.tsx +4 -2
- package/src/components/shared/FontPicker.tsx +16 -4
- package/src/components/shared/HistoryChangesModal.tsx +12 -1
- package/src/components/shared/HistoryPopover.tsx +18 -4
- package/src/components/shared/Navigation.tsx +20 -2
- package/src/components/shared/Popover.tsx +9 -5
- package/src/components/shell/AudienceAddForm.tsx +6 -26
- package/src/components/shell/AudienceRow.tsx +29 -39
- package/src/components/shell/BugReportFAB.tsx +20 -2
- package/src/components/shell/EditorShell.tsx +200 -50
- package/src/components/shell/MediaLibraryModal.tsx +52 -10
- package/src/components/shell/SiteSettingsUsers.tsx +3 -0
- package/src/components/shell/SiteSettingsViewerAccess.tsx +21 -16
- package/src/components/shell/view-branch.ts +4 -1
- package/src/document/components/DocRail.tsx +27 -2
- package/src/document/components/DocViewer.tsx +5 -1
- package/src/document/components/measure.tsx +22 -6
- package/src/document/components/useDocPages.ts +13 -3
- package/src/document/engine/blocks.ts +33 -4
- package/src/document/engine/masonry.ts +8 -2
- package/src/document/engine/paginate.ts +4 -1
- package/src/document/engine/settings.ts +40 -3
- package/src/hooks/useBuildStatus.ts +6 -0
- package/src/hooks/useDocumentUploads.ts +46 -3
- package/src/hooks/useEditorPersistence.ts +33 -19
- package/src/hooks/useEditorPublish.ts +83 -34
- package/src/hooks/useFocusTrap.ts +18 -0
- package/src/hooks/useMediaPipeline.ts +111 -38
- package/src/hooks/useSanitizedHtml.ts +29 -0
- package/src/lib/audience-secret.ts +1 -0
- package/src/lib/block-move.ts +13 -2
- package/src/lib/contrast.ts +14 -14
- package/src/lib/dexie.ts +46 -49
- package/src/lib/index-ops.ts +63 -20
- package/src/lib/loader.ts +41 -12
- package/src/lib/media-folders.ts +31 -5
- package/src/lib/migrate-sections-transform.ts +22 -2
- package/src/lib/nav-progress.ts +108 -0
- package/src/lib/nav-tree.ts +16 -2
- package/src/lib/nav.ts +10 -1
- package/src/lib/pages.ts +24 -6
- package/src/lib/registry.ts +3 -0
- package/src/lib/sanitize.ts +9 -0
- package/src/lib/timestamp.ts +5 -0
- package/src/media/queue.ts +4 -0
- package/src/media/resolve.ts +14 -1
- package/src/media/utils.ts +3 -1
- package/src/media/videoPoster.ts +14 -3
- package/src/schemas/audience.ts +10 -2
- package/src/schemas/sections.ts +23 -1
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +13 -0
- package/src/types/database.ts +0 -3
|
@@ -40,79 +40,99 @@ export function useEditableCollection<T>({
|
|
|
40
40
|
wrapItems(items)
|
|
41
41
|
);
|
|
42
42
|
const prevItemsRef = useRef(items);
|
|
43
|
+
// State updaters must be pure. Every mutator below used to call the parent's
|
|
44
|
+
// onChange from inside setWrappedItems, so React 18/19 StrictMode's dev
|
|
45
|
+
// double-invoke fired it twice (deleting one icon-list row emitted two
|
|
46
|
+
// changes), and React is free to re-run or discard an updater at any time.
|
|
47
|
+
// Mirroring the list here lets each mutator compute `next` outside the
|
|
48
|
+
// updater; the eager write keeps two mutations in one tick consistent.
|
|
49
|
+
const wrappedRef = useRef(wrappedItems);
|
|
50
|
+
wrappedRef.current = wrappedItems;
|
|
51
|
+
|
|
52
|
+
const commit = useCallback(
|
|
53
|
+
(next: WrappedItem<T>[]) => {
|
|
54
|
+
wrappedRef.current = next;
|
|
55
|
+
setWrappedItems(next);
|
|
56
|
+
onChange(next.map((w) => w.data));
|
|
57
|
+
},
|
|
58
|
+
[onChange],
|
|
59
|
+
);
|
|
43
60
|
|
|
44
61
|
useEffect(() => {
|
|
45
62
|
if (items === prevItemsRef.current) return;
|
|
46
63
|
prevItemsRef.current = items;
|
|
47
64
|
|
|
48
65
|
setWrappedItems((prev) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
// Identity first, position only for the remainder. Keyed purely by index,
|
|
67
|
+
// a parent-side removal or reorder re-pointed every id one slot over:
|
|
68
|
+
// React reused the wrong row's DOM, so the focused input, the in-progress
|
|
69
|
+
// edit and the drag registration all belonged to a different item than
|
|
70
|
+
// the one now rendered there. Pure identity would be worse in the other
|
|
71
|
+
// direction — an in-place edit hands back a fresh object for that row, and
|
|
72
|
+
// minting a new id there remounts the field the user is typing into.
|
|
73
|
+
const taken = new Array<boolean>(prev.length).fill(false);
|
|
74
|
+
const slotsByData = new Map<unknown, number[]>();
|
|
75
|
+
prev.forEach((w, i) => {
|
|
76
|
+
const bucket = slotsByData.get(w.data);
|
|
77
|
+
if (bucket) bucket.push(i);
|
|
78
|
+
else slotsByData.set(w.data, [i]);
|
|
54
79
|
});
|
|
80
|
+
|
|
81
|
+
const matched = items.map((data) => {
|
|
82
|
+
const slot = slotsByData.get(data)?.shift();
|
|
83
|
+
if (slot === undefined) return null;
|
|
84
|
+
taken[slot] = true;
|
|
85
|
+
return prev[slot].id;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Unmatched rows (edited in place, or genuinely new) take the surviving
|
|
89
|
+
// ids in their original order, which for a pure edit is the same id the
|
|
90
|
+
// row already had.
|
|
91
|
+
const spare = prev.filter((_, i) => !taken[i]).map((w) => w.id);
|
|
92
|
+
let next = 0;
|
|
93
|
+
return items.map((data, i) => ({ id: matched[i] ?? spare[next++] ?? uid(), data }));
|
|
55
94
|
});
|
|
56
95
|
}, [items]);
|
|
57
96
|
|
|
58
97
|
const onReorder = useCallback(
|
|
59
98
|
(fromIndex: number, toIndex: number) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
onChange(next.map((w) => w.data));
|
|
65
|
-
return next;
|
|
66
|
-
});
|
|
99
|
+
const next = [...wrappedRef.current];
|
|
100
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
101
|
+
next.splice(toIndex, 0, moved);
|
|
102
|
+
commit(next);
|
|
67
103
|
},
|
|
68
|
-
[
|
|
104
|
+
[commit]
|
|
69
105
|
);
|
|
70
106
|
|
|
71
107
|
const onAdd = useCallback(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const next = [...prev, { id: uid(), data: newItem }];
|
|
75
|
-
onChange(next.map((w) => w.data));
|
|
76
|
-
return next;
|
|
77
|
-
});
|
|
78
|
-
}, [createItem, onChange]);
|
|
108
|
+
commit([...wrappedRef.current, { id: uid(), data: createItem() }]);
|
|
109
|
+
}, [createItem, commit]);
|
|
79
110
|
|
|
80
111
|
const onInsert = useCallback(
|
|
81
112
|
(index: number) => {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
next.splice(index, 0, { id: uid(), data: newItem });
|
|
86
|
-
onChange(next.map((w) => w.data));
|
|
87
|
-
return next;
|
|
88
|
-
});
|
|
113
|
+
const next = [...wrappedRef.current];
|
|
114
|
+
next.splice(index, 0, { id: uid(), data: createItem() });
|
|
115
|
+
commit(next);
|
|
89
116
|
},
|
|
90
|
-
[createItem,
|
|
117
|
+
[createItem, commit],
|
|
91
118
|
);
|
|
92
119
|
|
|
93
120
|
// Insert an externally-provided item (e.g. one dragged in from another grid)
|
|
94
121
|
// at a specific index. Distinct from onInsert, which creates a blank item.
|
|
95
122
|
const onInsertItem = useCallback(
|
|
96
123
|
(index: number, data: T) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
onChange(next.map((w) => w.data));
|
|
101
|
-
return next;
|
|
102
|
-
});
|
|
124
|
+
const next = [...wrappedRef.current];
|
|
125
|
+
next.splice(index, 0, { id: uid(), data });
|
|
126
|
+
commit(next);
|
|
103
127
|
},
|
|
104
|
-
[
|
|
128
|
+
[commit]
|
|
105
129
|
);
|
|
106
130
|
|
|
107
131
|
const onRemove = useCallback(
|
|
108
132
|
(id: string) => {
|
|
109
|
-
|
|
110
|
-
const next = prev.filter((w) => w.id !== id);
|
|
111
|
-
onChange(next.map((w) => w.data));
|
|
112
|
-
return next;
|
|
113
|
-
});
|
|
133
|
+
commit(wrappedRef.current.filter((w) => w.id !== id));
|
|
114
134
|
},
|
|
115
|
-
[
|
|
135
|
+
[commit]
|
|
116
136
|
);
|
|
117
137
|
|
|
118
138
|
return { wrappedItems, onReorder, onAdd, onInsert, onInsertItem, onRemove };
|
|
@@ -29,16 +29,37 @@ export function useEditablePlainText({
|
|
|
29
29
|
}
|
|
30
30
|
}, [propValue]);
|
|
31
31
|
|
|
32
|
+
// Latest onChange, so committing on every keystroke doesn't need it in a dep
|
|
33
|
+
// array (the parent re-creates it inline every render).
|
|
34
|
+
const onChangeRef = useRef(onChange);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
onChangeRef.current = onChange;
|
|
37
|
+
}, [onChange]);
|
|
38
|
+
|
|
39
|
+
// Commit on every input, not only on blur — the same contract
|
|
40
|
+
// useEditableRichText adopted, and for the same reason: pressing ⌘S (or any
|
|
41
|
+
// keyboard save/publish, or an autosave flush) without leaving the field
|
|
42
|
+
// flushes persistence before blur ever fires, so a blur-only commit silently
|
|
43
|
+
// drops the in-progress edit. Deliberately NOT debounced: any debounce window
|
|
44
|
+
// is the same bug, just narrower.
|
|
32
45
|
const handleInput = useCallback((text: string) => {
|
|
33
46
|
setValue(text);
|
|
47
|
+
if (text !== lastPropValue.current) {
|
|
48
|
+
lastPropValue.current = text;
|
|
49
|
+
onChangeRef.current(text);
|
|
50
|
+
}
|
|
34
51
|
}, []);
|
|
35
52
|
|
|
53
|
+
// Now a flush rather than the only commit: a no-op unless something changed
|
|
54
|
+
// `value` without routing through handleInput. Compared against the last
|
|
55
|
+
// COMMITTED text rather than `propValue`, so a parent that doesn't echo the
|
|
56
|
+
// value straight back doesn't get the same edit twice.
|
|
36
57
|
const handleBlur = useCallback(() => {
|
|
37
|
-
if (value !==
|
|
38
|
-
onChange(value);
|
|
58
|
+
if (value !== lastPropValue.current) {
|
|
39
59
|
lastPropValue.current = value;
|
|
60
|
+
onChangeRef.current(value);
|
|
40
61
|
}
|
|
41
|
-
}, [value
|
|
62
|
+
}, [value]);
|
|
42
63
|
|
|
43
64
|
return {
|
|
44
65
|
value,
|
|
@@ -80,7 +80,14 @@ export default defineSection({
|
|
|
80
80
|
showLabel={options?.showLabel as boolean}
|
|
81
81
|
stackText={options?.stackText as boolean}
|
|
82
82
|
dodont={options?.dodont as "do" | "dont" | undefined}
|
|
83
|
-
|
|
83
|
+
// IconList rebuilds its options from the four props above, which inside a
|
|
84
|
+
// container are childDefaults merged with the child's own options — writing
|
|
85
|
+
// that back would materialise the container's defaults onto the child (it
|
|
86
|
+
// then stops following later default changes) and drop any option key
|
|
87
|
+
// outside those four. Only items change here, so keep the stored options.
|
|
88
|
+
onChange={
|
|
89
|
+
onChange ? (c) => onChange({ ...(c as typeof content), options: content.options }) : undefined
|
|
90
|
+
}
|
|
84
91
|
/>
|
|
85
92
|
),
|
|
86
93
|
defaults: () => ({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EditableRichText } from "../../primitives/EditableRichText";
|
|
2
|
-
import {
|
|
2
|
+
import { useSanitizedHtml } from "../../../hooks/useSanitizedHtml";
|
|
3
3
|
import type { SectionContent } from "../../../schemas/sections";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
@@ -8,6 +8,8 @@ interface Props {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export default function Prose({ body, onChange }: Props) {
|
|
11
|
+
const safeHtml = useSanitizedHtml(body);
|
|
12
|
+
|
|
11
13
|
if (onChange) {
|
|
12
14
|
return (
|
|
13
15
|
<div className="prose-content">
|
|
@@ -29,7 +31,7 @@ export default function Prose({ body, onChange }: Props) {
|
|
|
29
31
|
return (
|
|
30
32
|
<div
|
|
31
33
|
className="prose-content"
|
|
32
|
-
dangerouslySetInnerHTML={{ __html:
|
|
34
|
+
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
33
35
|
/>
|
|
34
36
|
);
|
|
35
37
|
}
|
|
@@ -22,17 +22,21 @@ function displayValue(value: string): string {
|
|
|
22
22
|
return value === "system-ui" ? "System Default" : value;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Returns the element it added, so the picker can take it back down. These
|
|
26
|
+
// stylesheets exist only for the dropdown's own preview list — the site's
|
|
27
|
+
// selected fonts load from site-config's `googleFontsUrl`, not from here.
|
|
28
|
+
function loadFontPreview(families: string[]): HTMLLinkElement | null {
|
|
29
|
+
if (families.length === 0) return null;
|
|
27
30
|
const params = families.map((f) => `family=${f.replace(/ /g, "+")}:wght@400;700`).join("&");
|
|
28
31
|
const href = `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
|
29
32
|
|
|
30
|
-
if (document.querySelector(`link[href="${href}"]`)) return;
|
|
33
|
+
if (document.querySelector(`link[href="${href}"]`)) return null;
|
|
31
34
|
|
|
32
35
|
const link = document.createElement("link");
|
|
33
36
|
link.rel = "stylesheet";
|
|
34
37
|
link.href = href;
|
|
35
38
|
document.head.appendChild(link);
|
|
39
|
+
return link;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function FontPicker({ label, value, onChange }: FontPickerProps) {
|
|
@@ -47,6 +51,7 @@ export function FontPicker({ label, value, onChange }: FontPickerProps) {
|
|
|
47
51
|
const loadedRef = useRef(new Set<string>());
|
|
48
52
|
const batchRef = useRef<string[]>([]);
|
|
49
53
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
54
|
+
const previewLinksRef = useRef<HTMLLinkElement[]>([]);
|
|
50
55
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
51
56
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number }>({
|
|
52
57
|
top: 0,
|
|
@@ -84,16 +89,23 @@ export function FontPicker({ label, value, onChange }: FontPickerProps) {
|
|
|
84
89
|
clearTimeout(timerRef.current);
|
|
85
90
|
timerRef.current = setTimeout(() => {
|
|
86
91
|
const batch = batchRef.current.splice(0);
|
|
87
|
-
if (batch.length
|
|
92
|
+
if (batch.length === 0) return;
|
|
93
|
+
const link = loadFontPreview(batch);
|
|
94
|
+
if (link) previewLinksRef.current.push(link);
|
|
88
95
|
}, 100);
|
|
89
96
|
}, []);
|
|
90
97
|
|
|
91
98
|
const observerRef = useRef<IntersectionObserver | undefined>(undefined);
|
|
92
99
|
|
|
93
100
|
useEffect(() => {
|
|
101
|
+
const links = previewLinksRef.current;
|
|
94
102
|
return () => {
|
|
95
103
|
clearTimeout(timerRef.current);
|
|
96
104
|
clearTimeout(debounceRef.current);
|
|
105
|
+
// Scrolling the ~1,500-font list loads a batch every 100ms, and every one
|
|
106
|
+
// of those left a <link> (and its Google Fonts request) behind for the
|
|
107
|
+
// rest of the session.
|
|
108
|
+
for (const link of links.splice(0)) link.remove();
|
|
97
109
|
};
|
|
98
110
|
}, []);
|
|
99
111
|
|
|
@@ -58,7 +58,18 @@ interface HistoryChangesModalProps {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// Commit ranges are immutable, so a fetched summary stays valid for the session.
|
|
61
|
+
// Bounded with FIFO eviction: each entry holds every changed section's title, and
|
|
62
|
+
// browsing a long history otherwise accumulated them all until reload.
|
|
61
63
|
const cache = new Map<string, ChangeSummary>();
|
|
64
|
+
const CACHE_LIMIT = 50;
|
|
65
|
+
|
|
66
|
+
function cacheSummary(key: string, summary: ChangeSummary): void {
|
|
67
|
+
if (cache.size >= CACHE_LIMIT) {
|
|
68
|
+
const oldest = cache.keys().next().value;
|
|
69
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
70
|
+
}
|
|
71
|
+
cache.set(key, summary);
|
|
72
|
+
}
|
|
62
73
|
|
|
63
74
|
/**
|
|
64
75
|
* Bump whenever the response shape or the meaning of a field changes.
|
|
@@ -133,7 +144,7 @@ export function HistoryChangesModal({
|
|
|
133
144
|
const res = await fetch(url);
|
|
134
145
|
if (!res.ok) throw new Error(String(res.status));
|
|
135
146
|
const data = (await res.json()) as ChangeSummary;
|
|
136
|
-
|
|
147
|
+
cacheSummary(url, data);
|
|
137
148
|
if (live) setSummary(data);
|
|
138
149
|
} catch {
|
|
139
150
|
if (live) setError(true);
|
|
@@ -80,25 +80,39 @@ export function HistoryPopover({ onSelectCommit, hasUnsavedChanges = false }: Hi
|
|
|
80
80
|
}), []);
|
|
81
81
|
|
|
82
82
|
useEffect(() => {
|
|
83
|
+
const controller = new AbortController();
|
|
83
84
|
let live = true;
|
|
84
85
|
(async () => {
|
|
85
86
|
const all: HistoryCommit[] = [];
|
|
86
87
|
try {
|
|
87
88
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
88
|
-
const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}
|
|
89
|
+
const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}`, {
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
89
92
|
if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
|
|
90
93
|
const data = (await res.json()) as { items: HistoryCommit[] };
|
|
91
94
|
all.push(...data.items);
|
|
95
|
+
// Render each page as it lands. These requests are sequential, so waiting
|
|
96
|
+
// for all ten left the popover on "Loading history…" for the whole run —
|
|
97
|
+
// and the first page is already more than fills the visible tree.
|
|
98
|
+
if (live) {
|
|
99
|
+
setCommits([...all]);
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
92
102
|
if (data.items.length < PER_PAGE) break;
|
|
93
103
|
}
|
|
94
|
-
if (live) setCommits(all);
|
|
95
104
|
} catch (err) {
|
|
96
|
-
if (live
|
|
105
|
+
if (!live || controller.signal.aborted) return;
|
|
106
|
+
// A later page failing is not worth throwing away the pages that landed:
|
|
107
|
+
// the error view replaces the tree entirely. Keep what we have instead.
|
|
108
|
+
if (all.length === 0) setError(err instanceof Error ? err.message : "Failed to load history");
|
|
97
109
|
} finally {
|
|
98
110
|
if (live) setLoading(false);
|
|
99
111
|
}
|
|
100
112
|
})();
|
|
101
|
-
|
|
113
|
+
// Closing the popover unmounts this; without the abort the remaining pages
|
|
114
|
+
// kept fetching against GitHub's rate limit for nothing.
|
|
115
|
+
return () => { live = false; controller.abort(); };
|
|
102
116
|
}, []);
|
|
103
117
|
|
|
104
118
|
const groups = useMemo(() => buildHistoryTree(commits, new Date()), [commits]);
|
|
@@ -110,12 +110,27 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
110
110
|
}
|
|
111
111
|
}, [identity?.kind]);
|
|
112
112
|
|
|
113
|
+
// The transition-suppression class is removed on a timer; unmounting mid-flight
|
|
114
|
+
// (a navigation right after a toggle) would otherwise leave `theme-changing`
|
|
115
|
+
// pinned on <html>, killing every transition on the page.
|
|
116
|
+
const themeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
117
|
+
useEffect(() => () => {
|
|
118
|
+
if (themeTimerRef.current) {
|
|
119
|
+
clearTimeout(themeTimerRef.current);
|
|
120
|
+
document.documentElement.classList.remove("theme-changing");
|
|
121
|
+
}
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
113
124
|
const handleThemeToggle = useCallback((next: boolean) => {
|
|
114
125
|
const root = document.documentElement;
|
|
115
126
|
root.classList.add("theme-changing");
|
|
116
127
|
root.classList.toggle("dark", next);
|
|
117
128
|
localStorage.setItem("theme", next ? "dark" : "light");
|
|
118
|
-
|
|
129
|
+
if (themeTimerRef.current) clearTimeout(themeTimerRef.current);
|
|
130
|
+
themeTimerRef.current = setTimeout(() => {
|
|
131
|
+
themeTimerRef.current = null;
|
|
132
|
+
root.classList.remove("theme-changing");
|
|
133
|
+
}, 200);
|
|
119
134
|
}, []);
|
|
120
135
|
|
|
121
136
|
// The active page can live in any tier (editors navigate to hidden and
|
|
@@ -299,7 +314,10 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
299
314
|
</button>
|
|
300
315
|
) : (
|
|
301
316
|
<a
|
|
302
|
-
|
|
317
|
+
// Encoded, like openDocFile's own hash write — readHash decodes, so an
|
|
318
|
+
// unencoded writer and a decoding reader disagree on any id that needs
|
|
319
|
+
// escaping.
|
|
320
|
+
href={`#doc=${encodeURIComponent(doc.sectionId)}`}
|
|
303
321
|
onClick={() => setIsOpen(false)}
|
|
304
322
|
className="block w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
|
|
305
323
|
>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import {
|
|
3
|
+
import { useInitialFocus } from "../../hooks/useFocusTrap";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
|
|
6
6
|
interface PopoverProps {
|
|
@@ -35,10 +35,14 @@ export function Popover({
|
|
|
35
35
|
const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
|
|
36
36
|
const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// Focus the first control on open and restore it on close, but do NOT trap
|
|
39
|
+
// Tab: this surface declares aria-modal="false", and trapping left a keyboard
|
|
40
|
+
// user with no way out of a popover that isn't modal. Dismissal stays with
|
|
41
|
+
// Escape and outside-mousedown, both below.
|
|
42
|
+
// In portal mode the panel isn't rendered until it's been measured, so this
|
|
43
|
+
// activates on that second commit — the hook bails on a null ref and only
|
|
44
|
+
// re-runs when `active` changes.
|
|
45
|
+
useInitialFocus(panelRef, isOpen && (!portal || fixedPos !== null));
|
|
42
46
|
|
|
43
47
|
// Track the anchor while portalled: the panel is out of normal flow, so it
|
|
44
48
|
// has to be repositioned on resize and on any ancestor's scroll.
|
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
import { useState, type FormEvent } from "react";
|
|
2
2
|
import { AudienceColorSchema } from "../../schemas/audience";
|
|
3
3
|
import { Input } from "../shared/Input";
|
|
4
|
-
import { PasswordInput } from "../shared/PasswordInput";
|
|
5
4
|
import { Button } from "../shared/Button";
|
|
6
5
|
import { ColorPicker } from "../shared/ColorPicker";
|
|
7
6
|
|
|
8
7
|
interface Props {
|
|
9
|
-
onSubmit: (data: { displayName: string; color: string | null
|
|
8
|
+
onSubmit: (data: { displayName: string; color: string | null }) => Promise<void>;
|
|
10
9
|
onCancel: () => void;
|
|
11
|
-
/** Credential mode: an audience has no password of its own — access comes from
|
|
12
|
-
* the named sign-ins added to it afterwards, so the field is hidden. */
|
|
13
|
-
requirePassword?: boolean;
|
|
14
10
|
}
|
|
15
11
|
|
|
16
12
|
const DEFAULT_COLOR = "#3b82f6";
|
|
17
13
|
|
|
18
|
-
export function AudienceAddForm({ onSubmit, onCancel
|
|
14
|
+
export function AudienceAddForm({ onSubmit, onCancel }: Props) {
|
|
19
15
|
const [name, setName] = useState("");
|
|
20
16
|
const [color, setColor] = useState(DEFAULT_COLOR);
|
|
21
|
-
const [password, setPassword] = useState("");
|
|
22
17
|
const [error, setError] = useState<string | null>(null);
|
|
23
18
|
const [submitting, setSubmitting] = useState(false);
|
|
24
19
|
|
|
@@ -35,14 +30,9 @@ export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }:
|
|
|
35
30
|
setError("Invalid color");
|
|
36
31
|
return;
|
|
37
32
|
}
|
|
38
|
-
if (requirePassword && password.length === 0) {
|
|
39
|
-
setError("Password is required");
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
setSubmitting(true);
|
|
44
34
|
try {
|
|
45
|
-
await onSubmit({ displayName: name, color
|
|
35
|
+
await onSubmit({ displayName: name, color });
|
|
46
36
|
} catch (e) {
|
|
47
37
|
setError(e instanceof Error ? e.message : "Add failed");
|
|
48
38
|
} finally {
|
|
@@ -64,19 +54,9 @@ export function AudienceAddForm({ onSubmit, onCancel, requirePassword = true }:
|
|
|
64
54
|
value={name}
|
|
65
55
|
onChange={setName}
|
|
66
56
|
/>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
value={password}
|
|
71
|
-
onChange={setPassword}
|
|
72
|
-
autoComplete="new-password"
|
|
73
|
-
showToggle
|
|
74
|
-
/>
|
|
75
|
-
) : (
|
|
76
|
-
<p className="text-[11px] text-base-contrast-light">
|
|
77
|
-
Add sign-ins to this audience once it exists — that is what grants access.
|
|
78
|
-
</p>
|
|
79
|
-
)}
|
|
57
|
+
<p className="text-[11px] text-base-contrast-light">
|
|
58
|
+
Add sign-ins to this audience once it exists — that is what grants access.
|
|
59
|
+
</p>
|
|
80
60
|
</div>
|
|
81
61
|
</div>
|
|
82
62
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { Audience } from "../../auth/types";
|
|
3
3
|
import { Input } from "../shared/Input";
|
|
4
|
-
import { PasswordInput } from "../shared/PasswordInput";
|
|
5
4
|
import { Button } from "../shared/Button";
|
|
6
5
|
import { ColorPicker } from "../shared/ColorPicker";
|
|
7
6
|
import { CredentialList } from "./CredentialList";
|
|
@@ -11,10 +10,10 @@ interface Props {
|
|
|
11
10
|
audience: Audience;
|
|
12
11
|
isDefault: boolean;
|
|
13
12
|
canMutate: boolean;
|
|
14
|
-
onSave: (patch: { displayName?: string; color?: string | null
|
|
13
|
+
onSave: (patch: { displayName?: string; color?: string | null }) => Promise<void>;
|
|
15
14
|
onDelete: () => Promise<void>;
|
|
16
|
-
/** Named sign-ins for this audience. Undefined in `.env
|
|
17
|
-
*
|
|
15
|
+
/** Named sign-ins for this audience. Undefined in `.env` mode, whose audiences
|
|
16
|
+
* come from environment variables and cannot be mutated at all. */
|
|
18
17
|
credentials?: ViewerCredential[];
|
|
19
18
|
credentialActions?: {
|
|
20
19
|
create: (data: { username: string; label: string | null; password: string }) => Promise<void>;
|
|
@@ -35,21 +34,18 @@ export function AudienceRow({
|
|
|
35
34
|
credentials,
|
|
36
35
|
credentialActions,
|
|
37
36
|
}: Props) {
|
|
38
|
-
// Credential mode: access comes from named sign-ins, so the row's own password
|
|
39
|
-
// field is gone. The two are never shown together.
|
|
40
37
|
const credentialMode = Boolean(credentials && credentialActions);
|
|
41
38
|
const [isEditing, setIsEditing] = useState(false);
|
|
42
39
|
const [draftDisplayName, setDraftDisplayName] = useState(audience.displayName);
|
|
43
40
|
const [draftColor, setDraftColor] = useState(audience.color ?? PLACEHOLDER_COLOR);
|
|
44
|
-
const [draftPassword, setDraftPassword] = useState("");
|
|
45
41
|
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [confirmingRemove, setConfirmingRemove] = useState(false);
|
|
46
43
|
const [saving, setSaving] = useState(false);
|
|
47
44
|
|
|
48
45
|
function startEdit() {
|
|
49
46
|
setIsEditing(true);
|
|
50
47
|
setDraftColor(audience.color ?? PLACEHOLDER_COLOR);
|
|
51
48
|
setDraftDisplayName(audience.displayName);
|
|
52
|
-
setDraftPassword("");
|
|
53
49
|
setError(null);
|
|
54
50
|
}
|
|
55
51
|
|
|
@@ -61,7 +57,7 @@ export function AudienceRow({
|
|
|
61
57
|
async function save() {
|
|
62
58
|
setError(null);
|
|
63
59
|
setSaving(true);
|
|
64
|
-
const patch: { displayName?: string; color?: string | null
|
|
60
|
+
const patch: { displayName?: string; color?: string | null } = {};
|
|
65
61
|
if (draftDisplayName.trim().length === 0) {
|
|
66
62
|
setError("Name is required");
|
|
67
63
|
setSaving(false);
|
|
@@ -73,9 +69,6 @@ export function AudienceRow({
|
|
|
73
69
|
if (draftColor !== (audience.color ?? PLACEHOLDER_COLOR)) {
|
|
74
70
|
patch.color = draftColor;
|
|
75
71
|
}
|
|
76
|
-
if (draftPassword.length > 0) {
|
|
77
|
-
patch.password = draftPassword;
|
|
78
|
-
}
|
|
79
72
|
if (Object.keys(patch).length === 0) {
|
|
80
73
|
setIsEditing(false);
|
|
81
74
|
setSaving(false);
|
|
@@ -91,14 +84,11 @@ export function AudienceRow({
|
|
|
91
84
|
}
|
|
92
85
|
}
|
|
93
86
|
|
|
87
|
+
// In-app confirmation rather than window.confirm: the native dialog is
|
|
88
|
+
// unstyled, blocks the whole tab, and is suppressible by the browser — the
|
|
89
|
+
// same reason SiteSettingsUsers confirms revocation inline.
|
|
94
90
|
async function remove() {
|
|
95
|
-
|
|
96
|
-
if (!confirm(
|
|
97
|
-
`Remove audience "${audience.displayName}"?` +
|
|
98
|
-
(signIns > 0
|
|
99
|
-
? `\n\nIts ${signIns === 1 ? "sign-in" : `${signIns} sign-ins`} will be removed too, locking out anyone using them.`
|
|
100
|
-
: ""),
|
|
101
|
-
)) return;
|
|
91
|
+
setConfirmingRemove(false);
|
|
102
92
|
try {
|
|
103
93
|
await onDelete();
|
|
104
94
|
} catch (e) {
|
|
@@ -117,21 +107,6 @@ export function AudienceRow({
|
|
|
117
107
|
value={draftDisplayName}
|
|
118
108
|
onChange={setDraftDisplayName}
|
|
119
109
|
/>
|
|
120
|
-
{!credentialMode && (
|
|
121
|
-
<>
|
|
122
|
-
<PasswordInput
|
|
123
|
-
label="New password"
|
|
124
|
-
value={draftPassword}
|
|
125
|
-
onChange={setDraftPassword}
|
|
126
|
-
autoComplete="new-password"
|
|
127
|
-
placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
|
|
128
|
-
showToggle
|
|
129
|
-
/>
|
|
130
|
-
<span className="block text-[11px] text-base-contrast-light">
|
|
131
|
-
Passwords are hashed — the existing one can't be recovered, only replaced.
|
|
132
|
-
</span>
|
|
133
|
-
</>
|
|
134
|
-
)}
|
|
135
110
|
</div>
|
|
136
111
|
</div>
|
|
137
112
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
@@ -179,24 +154,39 @@ export function AudienceRow({
|
|
|
179
154
|
{audience.credentialCount === 1 ? "1 sign-in" : `${audience.credentialCount} sign-ins`}
|
|
180
155
|
</span>
|
|
181
156
|
) : audience.hasPassword ? (
|
|
157
|
+
// `.env` mode only. Its audiences are read-only here — the password
|
|
158
|
+
// lives in a VIEWER_<NAME>_PASSWORD environment variable.
|
|
182
159
|
<span
|
|
183
160
|
aria-label="Password is set"
|
|
184
|
-
title="
|
|
161
|
+
title="Set by an environment variable; it can't be read or changed from here."
|
|
185
162
|
className="text-xs tracking-widest text-base-contrast-light"
|
|
186
163
|
>
|
|
187
164
|
••••
|
|
188
165
|
</span>
|
|
189
|
-
) : canMutate ? (
|
|
190
|
-
<Button variant="ghost" onClick={startEdit}>Set password</Button>
|
|
191
166
|
) : null}
|
|
192
167
|
{canMutate && (
|
|
193
168
|
<Button variant="ghost" onClick={startEdit}>Edit</Button>
|
|
194
169
|
)}
|
|
195
|
-
{canMutate && !isDefault && (
|
|
196
|
-
<Button variant="ghost" tone="destructive" onClick={
|
|
170
|
+
{canMutate && !isDefault && !confirmingRemove && (
|
|
171
|
+
<Button variant="ghost" tone="destructive" onClick={() => setConfirmingRemove(true)}>Remove</Button>
|
|
197
172
|
)}
|
|
198
173
|
</div>
|
|
199
174
|
|
|
175
|
+
{confirmingRemove && (
|
|
176
|
+
<div role="alertdialog" aria-label={`Remove ${audience.displayName}`} className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-900/50 dark:bg-red-950/30">
|
|
177
|
+
<p className="text-sm text-red-800 dark:text-red-200">
|
|
178
|
+
Remove “{audience.displayName}”?
|
|
179
|
+
{audience.credentialCount > 0 && (
|
|
180
|
+
<> Its {audience.credentialCount === 1 ? "sign-in" : `${audience.credentialCount} sign-ins`} go with it, locking out anyone using them.</>
|
|
181
|
+
)}
|
|
182
|
+
</p>
|
|
183
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
184
|
+
<Button variant="secondary" onClick={() => setConfirmingRemove(false)}>Cancel</Button>
|
|
185
|
+
<Button variant="destructive" onClick={remove}>Remove</Button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
200
190
|
{credentials && credentialActions && (
|
|
201
191
|
<CredentialList
|
|
202
192
|
audienceName={audience.displayName}
|