@eventcatalog/core 3.30.0 → 3.31.2
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/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-Z26P4PCB.js → chunk-5VANHNV3.js} +1 -1
- package/dist/{chunk-RRBDF4MM.js → chunk-7FECQ5B3.js} +1 -1
- package/dist/{chunk-MVZKHUX2.js → chunk-DL3PF5MS.js} +1 -1
- package/dist/{chunk-6UG4JMUV.js → chunk-IPGFRHRL.js} +1 -1
- package/dist/{chunk-ATRBVTJ6.js → chunk-UOKUSIKW.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/astro.config.mjs +10 -6
- package/eventcatalog/public/logo.png +0 -0
- package/eventcatalog/src/components/CopyAsMarkdown.tsx +29 -24
- package/eventcatalog/src/components/MDX/Design/Design.astro +1 -1
- package/eventcatalog/src/components/MDX/Tiles/Tile.astro +11 -8
- package/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx +25 -18
- package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +218 -0
- package/eventcatalog/src/components/Settings/BillingSettingsForm.tsx +265 -0
- package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +371 -0
- package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +183 -0
- package/eventcatalog/src/components/Settings/LogoUpload.tsx +137 -0
- package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +91 -0
- package/eventcatalog/src/components/Settings/ReadOnlyBanner.tsx +18 -0
- package/eventcatalog/src/components/Settings/Row.tsx +59 -0
- package/eventcatalog/src/components/Settings/SettingsShared.tsx +176 -0
- package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +17 -18
- package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +45 -16
- package/eventcatalog/src/components/Tables/Discover/FilterComponents.tsx +2 -2
- package/eventcatalog/src/content.config.ts +1 -1
- package/eventcatalog/src/enterprise/auth/middleware/middleware-auth.ts +11 -7
- package/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/index.tsx +4 -4
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +70 -57
- package/eventcatalog/src/enterprise/feature.ts +2 -1
- package/eventcatalog/src/layouts/SettingsLayout.astro +116 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +62 -23
- package/eventcatalog/src/pages/_index.astro +250 -255
- package/eventcatalog/src/pages/api/settings/ai.ts +57 -0
- package/eventcatalog/src/pages/api/settings/general.ts +71 -0
- package/eventcatalog/src/pages/api/settings/logo.ts +113 -0
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +26 -32
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +40 -31
- package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +1 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +2 -26
- package/eventcatalog/src/pages/docs/llm/llms.txt.ts +5 -1
- package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -1
- package/eventcatalog/src/pages/settings/assistant.astro +37 -0
- package/eventcatalog/src/pages/settings/billing.astro +17 -0
- package/eventcatalog/src/pages/settings/general.astro +32 -0
- package/eventcatalog/src/pages/settings/index.astro +21 -0
- package/eventcatalog/src/pages/settings/llm-access.astro +34 -0
- package/eventcatalog/src/pages/settings/mcp.astro +14 -0
- package/eventcatalog/src/styles/theme.css +38 -29
- package/eventcatalog/src/styles/themes/forest.css +17 -9
- package/eventcatalog/src/styles/themes/ocean.css +10 -2
- package/eventcatalog/src/styles/themes/sapphire.css +10 -2
- package/eventcatalog/src/styles/themes/sunset.css +25 -17
- package/eventcatalog/src/utils/eventcatalog-config/config-schema.ts +49 -0
- package/eventcatalog/src/utils/eventcatalog-config/config-writer.ts +149 -0
- package/eventcatalog/src/utils/url-builder.ts +4 -2
- package/package.json +7 -5
- package/eventcatalog/src/pages/docs/llm/llms-services.txt.ts +0 -81
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Toaster, toast } from 'sonner';
|
|
3
|
+
import { Check } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
generalSettingsSchema,
|
|
6
|
+
KNOWN_THEMES,
|
|
7
|
+
isKnownTheme,
|
|
8
|
+
type GeneralSettings,
|
|
9
|
+
} from '@utils/eventcatalog-config/config-schema';
|
|
10
|
+
import { ReadOnlyBanner } from './ReadOnlyBanner';
|
|
11
|
+
import { LogoUpload } from './LogoUpload';
|
|
12
|
+
import { Row, cn, inputBase, inputError, monoInput } from './Row';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
canEdit: boolean;
|
|
16
|
+
initial: GeneralSettings & { logo?: { src?: string } };
|
|
17
|
+
apiBase: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FormState {
|
|
21
|
+
organization: string;
|
|
22
|
+
tagline: string;
|
|
23
|
+
homepageLink: string;
|
|
24
|
+
editUrl: string;
|
|
25
|
+
repositoryUrl: string;
|
|
26
|
+
theme: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type FieldKey = keyof FormState;
|
|
30
|
+
|
|
31
|
+
const toFormState = (s: GeneralSettings): FormState => ({
|
|
32
|
+
organization: s.organizationName ?? s.title ?? s.logo?.text ?? '',
|
|
33
|
+
tagline: s.tagline ?? '',
|
|
34
|
+
homepageLink: s.homepageLink ?? '',
|
|
35
|
+
editUrl: s.editUrl ?? '',
|
|
36
|
+
repositoryUrl: s.repositoryUrl ?? '',
|
|
37
|
+
theme: s.theme || 'default',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const themeOptions = (currentTheme: string): string[] => {
|
|
41
|
+
const known = [...KNOWN_THEMES];
|
|
42
|
+
if (currentTheme && !isKnownTheme(currentTheme)) {
|
|
43
|
+
return [...known, currentTheme];
|
|
44
|
+
}
|
|
45
|
+
return known;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const THEME_PREVIEWS: Record<string, { accent: string; bg: string; label: string }> = {
|
|
49
|
+
default: { accent: '#6366f1', bg: '#0b0b0f', label: 'Default' },
|
|
50
|
+
ocean: { accent: '#06b6d4', bg: '#082431', label: 'Ocean' },
|
|
51
|
+
sapphire: { accent: '#3b82f6', bg: '#0b1430', label: 'Sapphire' },
|
|
52
|
+
sunset: { accent: '#f97316', bg: '#2a0f0a', label: 'Sunset' },
|
|
53
|
+
forest: { accent: '#22c55e', bg: '#0c1f12', label: 'Forest' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const ensureUrlScheme = (value: string): string => {
|
|
57
|
+
const v = value.trim();
|
|
58
|
+
if (!v) return '';
|
|
59
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
60
|
+
return `https://${v}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const GeneralSettingsForm = ({ canEdit, initial, apiBase }: Props) => {
|
|
64
|
+
const [form, setForm] = useState<FormState>(() => toFormState(initial));
|
|
65
|
+
const [pristine, setPristine] = useState<FormState>(() => toFormState(initial));
|
|
66
|
+
const [errors, setErrors] = useState<Partial<Record<FieldKey, string>>>({});
|
|
67
|
+
const [savingKey, setSavingKey] = useState<FieldKey | null>(null);
|
|
68
|
+
const originalThemeRef = useRef<string>(initial.theme || 'default');
|
|
69
|
+
|
|
70
|
+
const dirtyKeys = useMemo<Set<FieldKey>>(() => {
|
|
71
|
+
const s = new Set<FieldKey>();
|
|
72
|
+
(Object.keys(form) as FieldKey[]).forEach((k) => {
|
|
73
|
+
if (form[k] !== pristine[k]) s.add(k);
|
|
74
|
+
});
|
|
75
|
+
return s;
|
|
76
|
+
}, [form, pristine]);
|
|
77
|
+
|
|
78
|
+
const isFormDirty = dirtyKeys.size > 0;
|
|
79
|
+
|
|
80
|
+
// Live theme preview; revert on unmount if not saved.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!canEdit) return;
|
|
83
|
+
document.documentElement.setAttribute('data-catalog-theme', form.theme || 'default');
|
|
84
|
+
return () => {
|
|
85
|
+
document.documentElement.setAttribute('data-catalog-theme', originalThemeRef.current || 'default');
|
|
86
|
+
};
|
|
87
|
+
}, [form.theme, canEdit]);
|
|
88
|
+
|
|
89
|
+
// beforeunload guard while any field is dirty.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!isFormDirty) return;
|
|
92
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
e.returnValue = '';
|
|
95
|
+
};
|
|
96
|
+
window.addEventListener('beforeunload', handler);
|
|
97
|
+
return () => window.removeEventListener('beforeunload', handler);
|
|
98
|
+
}, [isFormDirty]);
|
|
99
|
+
|
|
100
|
+
const setField = (key: FieldKey, value: string) => {
|
|
101
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
102
|
+
setErrors((prev) => ({ ...prev, [key]: undefined }));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleChange = (key: FieldKey) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
|
106
|
+
setField(key, e.target.value);
|
|
107
|
+
|
|
108
|
+
const handleUrlBlur = (key: 'homepageLink' | 'editUrl' | 'repositoryUrl') => () => {
|
|
109
|
+
setForm((prev) => ({ ...prev, [key]: ensureUrlScheme(prev[key]) }));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const validateAll = (): { ok: true } | { ok: false; errors: Partial<Record<FieldKey, string>> } => {
|
|
113
|
+
const candidate = {
|
|
114
|
+
title: form.organization,
|
|
115
|
+
tagline: form.tagline,
|
|
116
|
+
organizationName: form.organization,
|
|
117
|
+
homepageLink: form.homepageLink,
|
|
118
|
+
editUrl: form.editUrl,
|
|
119
|
+
repositoryUrl: form.repositoryUrl,
|
|
120
|
+
logo: { text: form.organization },
|
|
121
|
+
theme: form.theme,
|
|
122
|
+
};
|
|
123
|
+
const parsed = generalSettingsSchema.safeParse(candidate);
|
|
124
|
+
if (parsed.success) return { ok: true };
|
|
125
|
+
const next: Partial<Record<FieldKey, string>> = {};
|
|
126
|
+
for (const issue of parsed.error.issues) {
|
|
127
|
+
const [key] = issue.path as string[];
|
|
128
|
+
if (key === 'title' || key === 'organizationName' || key === 'logo') next.organization = issue.message;
|
|
129
|
+
else if (key === 'tagline') next.tagline = issue.message;
|
|
130
|
+
else if (key === 'homepageLink') next.homepageLink = issue.message;
|
|
131
|
+
else if (key === 'editUrl') next.editUrl = issue.message;
|
|
132
|
+
else if (key === 'repositoryUrl') next.repositoryUrl = issue.message;
|
|
133
|
+
else if (key === 'theme') next.theme = issue.message;
|
|
134
|
+
}
|
|
135
|
+
return { ok: false, errors: next };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Per-row save: validate the whole form, but only block on errors that
|
|
139
|
+
// affect the row being saved. The API still receives the full payload.
|
|
140
|
+
const saveRow = async (key: FieldKey) => {
|
|
141
|
+
if (!canEdit) return;
|
|
142
|
+
const result = validateAll();
|
|
143
|
+
if (!result.ok && result.errors[key]) {
|
|
144
|
+
setErrors((prev) => ({ ...prev, [key]: result.errors[key] }));
|
|
145
|
+
toast.error(result.errors[key] || 'Please fix the highlighted field');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setSavingKey(key);
|
|
150
|
+
try {
|
|
151
|
+
const payload = {
|
|
152
|
+
title: form.organization,
|
|
153
|
+
organizationName: form.organization,
|
|
154
|
+
logo: { text: form.organization },
|
|
155
|
+
tagline: form.tagline || undefined,
|
|
156
|
+
homepageLink: form.homepageLink || undefined,
|
|
157
|
+
editUrl: form.editUrl || undefined,
|
|
158
|
+
repositoryUrl: form.repositoryUrl || undefined,
|
|
159
|
+
theme: form.theme,
|
|
160
|
+
};
|
|
161
|
+
const res = await fetch(`${apiBase}/general`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify(payload),
|
|
165
|
+
});
|
|
166
|
+
const body = await res.json().catch(() => ({}));
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
toast.error(body.error || 'Could not save');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
setPristine(form);
|
|
172
|
+
originalThemeRef.current = form.theme;
|
|
173
|
+
toast.success('Saved to eventcatalog.config.js');
|
|
174
|
+
} catch (err) {
|
|
175
|
+
toast.error(`Could not save: ${(err as Error).message}`);
|
|
176
|
+
} finally {
|
|
177
|
+
setSavingKey(null);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<form onSubmit={(e) => e.preventDefault()} className="divide-y divide-[rgb(var(--ec-page-border))]">
|
|
183
|
+
<Toaster richColors closeButton position="bottom-right" theme="system" />
|
|
184
|
+
{!canEdit && (
|
|
185
|
+
<div className="pb-6">
|
|
186
|
+
<ReadOnlyBanner />
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<Row
|
|
191
|
+
title="Theme"
|
|
192
|
+
description="Pick a built-in colour theme. Selection updates the preview live; click Save to persist."
|
|
193
|
+
canEdit={canEdit}
|
|
194
|
+
dirty={dirtyKeys.has('theme')}
|
|
195
|
+
saving={savingKey === 'theme'}
|
|
196
|
+
onSave={() => saveRow('theme')}
|
|
197
|
+
error={errors.theme}
|
|
198
|
+
>
|
|
199
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
200
|
+
{themeOptions(form.theme).map((t) => {
|
|
201
|
+
const preview = THEME_PREVIEWS[t] ?? { accent: '#888', bg: '#1a1a1a', label: t };
|
|
202
|
+
const isActive = form.theme === t;
|
|
203
|
+
const isCustom = !isKnownTheme(t);
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
key={t}
|
|
208
|
+
onClick={() => setField('theme', t)}
|
|
209
|
+
disabled={!canEdit}
|
|
210
|
+
aria-pressed={isActive}
|
|
211
|
+
className={cn(
|
|
212
|
+
'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-all',
|
|
213
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
214
|
+
isActive
|
|
215
|
+
? 'border-[rgb(var(--ec-accent))] ring-2 ring-[rgb(var(--ec-accent)/0.25)]'
|
|
216
|
+
: 'border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-page-text-muted)/0.6)]'
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
<div className="relative h-14 w-full overflow-hidden" style={{ background: preview.bg }}>
|
|
220
|
+
<div
|
|
221
|
+
className="absolute -bottom-2 -right-2 h-12 w-12 rounded-full opacity-90 blur-md"
|
|
222
|
+
style={{ background: preview.accent }}
|
|
223
|
+
aria-hidden
|
|
224
|
+
/>
|
|
225
|
+
<div
|
|
226
|
+
className="absolute left-3 top-3 h-1.5 w-10 rounded-full opacity-90"
|
|
227
|
+
style={{ background: preview.accent }}
|
|
228
|
+
aria-hidden
|
|
229
|
+
/>
|
|
230
|
+
<div
|
|
231
|
+
className="absolute left-3 top-6 h-1 w-7 rounded-full opacity-60"
|
|
232
|
+
style={{ background: 'rgba(255,255,255,0.4)' }}
|
|
233
|
+
aria-hidden
|
|
234
|
+
/>
|
|
235
|
+
{isActive && (
|
|
236
|
+
<span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-white text-black shadow-sm">
|
|
237
|
+
<Check className="h-2.5 w-2.5" aria-hidden />
|
|
238
|
+
</span>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
|
242
|
+
<span className="text-[12px] font-medium capitalize text-[rgb(var(--ec-page-text))]">{preview.label}</span>
|
|
243
|
+
{isCustom && (
|
|
244
|
+
<span className="rounded-full border border-[rgb(var(--ec-page-border))] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
|
|
245
|
+
custom
|
|
246
|
+
</span>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
</button>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
</Row>
|
|
254
|
+
|
|
255
|
+
<Row
|
|
256
|
+
title="Organization"
|
|
257
|
+
description="Used as the catalog name, the brand line next to the logo, and OpenGraph metadata."
|
|
258
|
+
canEdit={canEdit}
|
|
259
|
+
dirty={dirtyKeys.has('organization')}
|
|
260
|
+
saving={savingKey === 'organization'}
|
|
261
|
+
onSave={() => saveRow('organization')}
|
|
262
|
+
error={errors.organization}
|
|
263
|
+
>
|
|
264
|
+
<input
|
|
265
|
+
type="text"
|
|
266
|
+
value={form.organization}
|
|
267
|
+
onChange={handleChange('organization')}
|
|
268
|
+
disabled={!canEdit}
|
|
269
|
+
maxLength={100}
|
|
270
|
+
placeholder="Acme Inc."
|
|
271
|
+
className={cn(inputBase, errors.organization && inputError)}
|
|
272
|
+
required
|
|
273
|
+
/>
|
|
274
|
+
</Row>
|
|
275
|
+
|
|
276
|
+
<Row
|
|
277
|
+
title="Tagline"
|
|
278
|
+
description="A short subtitle shown beneath the organization name on the homepage."
|
|
279
|
+
canEdit={canEdit}
|
|
280
|
+
dirty={dirtyKeys.has('tagline')}
|
|
281
|
+
saving={savingKey === 'tagline'}
|
|
282
|
+
onSave={() => saveRow('tagline')}
|
|
283
|
+
error={errors.tagline}
|
|
284
|
+
>
|
|
285
|
+
<textarea
|
|
286
|
+
value={form.tagline}
|
|
287
|
+
onChange={handleChange('tagline')}
|
|
288
|
+
disabled={!canEdit}
|
|
289
|
+
maxLength={500}
|
|
290
|
+
rows={2}
|
|
291
|
+
placeholder="Discover and explore our events, services, and architecture."
|
|
292
|
+
className={cn(inputBase, 'resize-y', errors.tagline && inputError)}
|
|
293
|
+
/>
|
|
294
|
+
</Row>
|
|
295
|
+
|
|
296
|
+
<Row
|
|
297
|
+
title="Homepage link"
|
|
298
|
+
description="Where the logo links to. Useful for pointing back to your main site."
|
|
299
|
+
canEdit={canEdit}
|
|
300
|
+
dirty={dirtyKeys.has('homepageLink')}
|
|
301
|
+
saving={savingKey === 'homepageLink'}
|
|
302
|
+
onSave={() => saveRow('homepageLink')}
|
|
303
|
+
error={errors.homepageLink}
|
|
304
|
+
>
|
|
305
|
+
<input
|
|
306
|
+
type="url"
|
|
307
|
+
value={form.homepageLink}
|
|
308
|
+
onChange={handleChange('homepageLink')}
|
|
309
|
+
onBlur={handleUrlBlur('homepageLink')}
|
|
310
|
+
disabled={!canEdit}
|
|
311
|
+
placeholder="https://example.com"
|
|
312
|
+
className={cn(inputBase, monoInput, errors.homepageLink && inputError)}
|
|
313
|
+
/>
|
|
314
|
+
</Row>
|
|
315
|
+
|
|
316
|
+
<Row
|
|
317
|
+
title="Edit URL"
|
|
318
|
+
description="Base URL for the “Edit on GitHub” links shown on resource pages."
|
|
319
|
+
canEdit={canEdit}
|
|
320
|
+
dirty={dirtyKeys.has('editUrl')}
|
|
321
|
+
saving={savingKey === 'editUrl'}
|
|
322
|
+
onSave={() => saveRow('editUrl')}
|
|
323
|
+
error={errors.editUrl}
|
|
324
|
+
>
|
|
325
|
+
<input
|
|
326
|
+
type="url"
|
|
327
|
+
value={form.editUrl}
|
|
328
|
+
onChange={handleChange('editUrl')}
|
|
329
|
+
onBlur={handleUrlBlur('editUrl')}
|
|
330
|
+
disabled={!canEdit}
|
|
331
|
+
placeholder="https://github.com/org/repo/edit/main"
|
|
332
|
+
className={cn(inputBase, monoInput, errors.editUrl && inputError)}
|
|
333
|
+
/>
|
|
334
|
+
</Row>
|
|
335
|
+
|
|
336
|
+
<Row
|
|
337
|
+
title="Repository URL"
|
|
338
|
+
description="Link to your catalog's source repository. Shown in the header for quick navigation back to the code."
|
|
339
|
+
canEdit={canEdit}
|
|
340
|
+
dirty={dirtyKeys.has('repositoryUrl')}
|
|
341
|
+
saving={savingKey === 'repositoryUrl'}
|
|
342
|
+
onSave={() => saveRow('repositoryUrl')}
|
|
343
|
+
error={errors.repositoryUrl}
|
|
344
|
+
>
|
|
345
|
+
<input
|
|
346
|
+
type="url"
|
|
347
|
+
value={form.repositoryUrl}
|
|
348
|
+
onChange={handleChange('repositoryUrl')}
|
|
349
|
+
onBlur={handleUrlBlur('repositoryUrl')}
|
|
350
|
+
disabled={!canEdit}
|
|
351
|
+
placeholder="https://github.com/org/repo"
|
|
352
|
+
className={cn(inputBase, monoInput, errors.repositoryUrl && inputError)}
|
|
353
|
+
/>
|
|
354
|
+
</Row>
|
|
355
|
+
|
|
356
|
+
<Row
|
|
357
|
+
title="Logo"
|
|
358
|
+
description="Image shown in the top-left corner. PNG, JPG, SVG, or WebP up to 2MB."
|
|
359
|
+
canEdit={canEdit}
|
|
360
|
+
// Logo upload saves itself directly; no inline Save button needed.
|
|
361
|
+
dirty={false}
|
|
362
|
+
>
|
|
363
|
+
<LogoUpload
|
|
364
|
+
canEdit={canEdit}
|
|
365
|
+
initialSrc={initial.logo?.src ? `${initial.logo.src}?t=${Date.now()}` : undefined}
|
|
366
|
+
apiUrl={`${apiBase}/logo`}
|
|
367
|
+
/>
|
|
368
|
+
</Row>
|
|
369
|
+
</form>
|
|
370
|
+
);
|
|
371
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Toaster, toast } from 'sonner';
|
|
3
|
+
import { Bot, FileText } from 'lucide-react';
|
|
4
|
+
import { aiSettingsSchema } from '@utils/eventcatalog-config/config-schema';
|
|
5
|
+
import { ReadOnlyBanner } from './ReadOnlyBanner';
|
|
6
|
+
import { Row } from './Row';
|
|
7
|
+
import { ToggleRow, UpgradeRequired, UrlPanel } from './SettingsShared';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
canEdit: boolean;
|
|
11
|
+
initial: { llmsTxtEnabled: boolean; chatEnabled: boolean };
|
|
12
|
+
hasScalePlan: boolean;
|
|
13
|
+
apiBase: string;
|
|
14
|
+
llmsTxtUrl: string;
|
|
15
|
+
llmsFullTxtUrl: string;
|
|
16
|
+
schemasTxtUrl: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const LlmAccessSettingsForm = ({
|
|
20
|
+
canEdit,
|
|
21
|
+
initial,
|
|
22
|
+
hasScalePlan,
|
|
23
|
+
apiBase,
|
|
24
|
+
llmsTxtUrl,
|
|
25
|
+
llmsFullTxtUrl,
|
|
26
|
+
schemasTxtUrl,
|
|
27
|
+
}: Props) => {
|
|
28
|
+
const [llmsTxtEnabled, setLlmsTxtEnabled] = useState(initial.llmsTxtEnabled);
|
|
29
|
+
const [pristine, setPristine] = useState(initial.llmsTxtEnabled);
|
|
30
|
+
const [saving, setSaving] = useState(false);
|
|
31
|
+
|
|
32
|
+
const dirty = llmsTxtEnabled !== pristine;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!dirty) return;
|
|
36
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
e.returnValue = '';
|
|
39
|
+
};
|
|
40
|
+
window.addEventListener('beforeunload', handler);
|
|
41
|
+
return () => window.removeEventListener('beforeunload', handler);
|
|
42
|
+
}, [dirty]);
|
|
43
|
+
|
|
44
|
+
const save = async () => {
|
|
45
|
+
if (!canEdit) return;
|
|
46
|
+
const candidate = aiSettingsSchema.safeParse({
|
|
47
|
+
llmsTxtEnabled,
|
|
48
|
+
chatEnabled: initial.chatEnabled,
|
|
49
|
+
});
|
|
50
|
+
if (!candidate.success) {
|
|
51
|
+
toast.error('Invalid settings');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setSaving(true);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${apiBase}/ai`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(candidate.data),
|
|
60
|
+
});
|
|
61
|
+
const body = await res.json().catch(() => ({}));
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
toast.error(body.error || 'Could not save');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setPristine(llmsTxtEnabled);
|
|
67
|
+
toast.success('Saved to eventcatalog.config.js');
|
|
68
|
+
} catch (err) {
|
|
69
|
+
toast.error(`Could not save: ${(err as Error).message}`);
|
|
70
|
+
} finally {
|
|
71
|
+
setSaving(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<form onSubmit={(e) => e.preventDefault()} className="divide-y divide-[rgb(var(--ec-page-border))]">
|
|
77
|
+
<Toaster richColors closeButton position="bottom-right" theme="system" />
|
|
78
|
+
{!canEdit && (
|
|
79
|
+
<div className="pb-6">
|
|
80
|
+
<ReadOnlyBanner />
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<Row
|
|
85
|
+
title="llms.txt"
|
|
86
|
+
description="Generate llms.txt files so tools like Claude, ChatGPT, and Cursor can read your catalog at /docs/llm/llms.txt."
|
|
87
|
+
canEdit={canEdit}
|
|
88
|
+
dirty={dirty}
|
|
89
|
+
saving={saving}
|
|
90
|
+
onSave={save}
|
|
91
|
+
>
|
|
92
|
+
<ToggleRow
|
|
93
|
+
icon={<Bot className="h-4 w-4" aria-hidden />}
|
|
94
|
+
label={llmsTxtEnabled ? 'Enabled' : 'Disabled'}
|
|
95
|
+
hint={
|
|
96
|
+
llmsTxtEnabled
|
|
97
|
+
? 'AI tools can read your catalog at /docs/llm/llms.txt.'
|
|
98
|
+
: 'AI tools will not be able to read your catalog.'
|
|
99
|
+
}
|
|
100
|
+
checked={llmsTxtEnabled}
|
|
101
|
+
disabled={!canEdit}
|
|
102
|
+
onChange={setLlmsTxtEnabled}
|
|
103
|
+
/>
|
|
104
|
+
{llmsTxtEnabled && <LlmsTxtPreview url={llmsTxtUrl} fullUrl={llmsFullTxtUrl} />}
|
|
105
|
+
</Row>
|
|
106
|
+
|
|
107
|
+
<Row
|
|
108
|
+
title="schemas.txt"
|
|
109
|
+
description="Give LLMs access to every schema across your organization in one place — events, commands, queries, and services."
|
|
110
|
+
canEdit={false}
|
|
111
|
+
dirty={false}
|
|
112
|
+
>
|
|
113
|
+
{hasScalePlan ? (
|
|
114
|
+
<SchemasTxtAvailable url={schemasTxtUrl} />
|
|
115
|
+
) : (
|
|
116
|
+
<UpgradeRequired
|
|
117
|
+
tier="Scale"
|
|
118
|
+
blurb="The schema index is a Scale-plan feature. Upgrade to publish a machine-readable catalogue of your schemas alongside llms.txt."
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</Row>
|
|
122
|
+
</form>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const LlmsTxtPreview = ({ url, fullUrl }: { url: string; fullUrl: string }) => {
|
|
127
|
+
const variants = [
|
|
128
|
+
{ label: 'Index', path: url },
|
|
129
|
+
{ label: 'Full', path: fullUrl },
|
|
130
|
+
];
|
|
131
|
+
return (
|
|
132
|
+
<div className="rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg)/0.4)] px-4 py-3">
|
|
133
|
+
<p className="text-[12px] font-medium text-[rgb(var(--ec-page-text))]">Point your LLM here</p>
|
|
134
|
+
<p className="mt-0.5 text-[12px] leading-snug text-[rgb(var(--ec-page-text-muted))]">
|
|
135
|
+
Drop this URL into Claude, ChatGPT, Cursor, or any AI tool to give it the full context of your catalog.
|
|
136
|
+
</p>
|
|
137
|
+
<p className="mt-1 text-[12px] leading-snug text-[rgb(var(--ec-page-text-muted))]">
|
|
138
|
+
From there you can ask questions about your architecture, services, and events.
|
|
139
|
+
</p>
|
|
140
|
+
<div className="mt-2.5">
|
|
141
|
+
<UrlPanel url={url} />
|
|
142
|
+
</div>
|
|
143
|
+
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
144
|
+
{variants.map((v) => (
|
|
145
|
+
<a
|
|
146
|
+
key={v.path}
|
|
147
|
+
href={v.path}
|
|
148
|
+
target="_blank"
|
|
149
|
+
rel="noreferrer"
|
|
150
|
+
className="inline-flex items-center gap-1 rounded-full border border-[rgb(var(--ec-page-border))] px-2 py-0.5 text-[11px] text-[rgb(var(--ec-page-text-muted))] transition-colors hover:border-[rgb(var(--ec-accent)/0.5)] hover:text-[rgb(var(--ec-accent))]"
|
|
151
|
+
>
|
|
152
|
+
{v.label}
|
|
153
|
+
</a>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const SchemasTxtAvailable = ({ url }: { url: string }) => (
|
|
161
|
+
<div className="space-y-3">
|
|
162
|
+
<ToggleRow
|
|
163
|
+
icon={<FileText className="h-4 w-4" aria-hidden />}
|
|
164
|
+
label="Enabled"
|
|
165
|
+
hint="A markdown index of every event, command, query, and service schema in your catalog."
|
|
166
|
+
checked={true}
|
|
167
|
+
disabled={true}
|
|
168
|
+
onChange={() => {}}
|
|
169
|
+
/>
|
|
170
|
+
<div className="rounded-lg border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg)/0.4)] px-4 py-3">
|
|
171
|
+
<p className="text-[12px] font-medium text-[rgb(var(--ec-page-text))]">Point your LLM here</p>
|
|
172
|
+
<p className="mt-0.5 text-[12px] leading-snug text-[rgb(var(--ec-page-text-muted))]">
|
|
173
|
+
Share this URL with Claude, ChatGPT, Cursor, or any AI tool to give it every schema in your catalog.
|
|
174
|
+
</p>
|
|
175
|
+
<p className="mt-1 text-[12px] leading-snug text-[rgb(var(--ec-page-text-muted))]">
|
|
176
|
+
Perfect for asking about message shapes, fields, and contracts.
|
|
177
|
+
</p>
|
|
178
|
+
<div className="mt-2.5">
|
|
179
|
+
<UrlPanel url={url} />
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
import { ImageIcon, Trash2, Upload, Loader2 } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface LogoUploadProps {
|
|
6
|
+
canEdit: boolean;
|
|
7
|
+
initialSrc?: string;
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
onSrcChange?: (src: string | null) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const LogoUpload = ({ canEdit, initialSrc, apiUrl, onSrcChange }: LogoUploadProps) => {
|
|
13
|
+
const [src, setSrc] = useState<string | null>(initialSrc ?? null);
|
|
14
|
+
const [busy, setBusy] = useState(false);
|
|
15
|
+
const [dragOver, setDragOver] = useState(false);
|
|
16
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
17
|
+
|
|
18
|
+
const upload = async (file: File) => {
|
|
19
|
+
setBusy(true);
|
|
20
|
+
const fd = new FormData();
|
|
21
|
+
fd.append('logo', file);
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(apiUrl, { method: 'POST', body: fd });
|
|
24
|
+
const body = await res.json().catch(() => ({}));
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
toast.error(body.error || 'Logo upload failed');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const next = `${body.src}?t=${Date.now()}`;
|
|
30
|
+
setSrc(next);
|
|
31
|
+
onSrcChange?.(body.src);
|
|
32
|
+
toast.success('Logo uploaded');
|
|
33
|
+
} catch (err) {
|
|
34
|
+
toast.error(`Logo upload failed: ${(err as Error).message}`);
|
|
35
|
+
} finally {
|
|
36
|
+
setBusy(false);
|
|
37
|
+
if (inputRef.current) inputRef.current.value = '';
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const remove = async () => {
|
|
42
|
+
setBusy(true);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(apiUrl, { method: 'DELETE' });
|
|
45
|
+
const body = await res.json().catch(() => ({}));
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
toast.error(body.error || 'Could not remove logo');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setSrc(null);
|
|
51
|
+
onSrcChange?.(null);
|
|
52
|
+
toast.success('Logo removed');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
toast.error(`Could not remove logo: ${(err as Error).message}`);
|
|
55
|
+
} finally {
|
|
56
|
+
setBusy(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onDrop = (e: React.DragEvent) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setDragOver(false);
|
|
63
|
+
if (!canEdit || busy) return;
|
|
64
|
+
const file = e.dataTransfer.files?.[0];
|
|
65
|
+
if (file) void upload(file);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
onDragOver={(e) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
if (canEdit && !busy) setDragOver(true);
|
|
73
|
+
}}
|
|
74
|
+
onDragLeave={() => setDragOver(false)}
|
|
75
|
+
onDrop={onDrop}
|
|
76
|
+
className={`relative flex items-center gap-5 rounded-lg border border-dashed px-4 py-4 transition-colors ${
|
|
77
|
+
dragOver
|
|
78
|
+
? 'border-[rgb(var(--ec-accent)/0.6)] bg-[rgb(var(--ec-accent-subtle)/0.4)]'
|
|
79
|
+
: 'border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg)/0.4)]'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
<div className="relative flex h-20 w-20 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[rgb(var(--ec-page-bg))] ring-1 ring-[rgb(var(--ec-page-border))]">
|
|
83
|
+
{src ? (
|
|
84
|
+
<img src={src} alt="Catalog logo preview" className="h-16 w-16 object-contain" />
|
|
85
|
+
) : (
|
|
86
|
+
<ImageIcon className="h-7 w-7 text-[rgb(var(--ec-page-text-muted)/0.7)]" aria-hidden />
|
|
87
|
+
)}
|
|
88
|
+
{busy && (
|
|
89
|
+
<div className="absolute inset-0 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.7)] backdrop-blur-sm">
|
|
90
|
+
<Loader2 className="h-4 w-4 animate-spin text-[rgb(var(--ec-page-text))]" aria-hidden />
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="flex-1">
|
|
96
|
+
<p className="text-[13px] font-medium text-[rgb(var(--ec-page-text))]">{src ? 'Replace logo' : 'Upload a logo'}</p>
|
|
97
|
+
<p className="text-[12px] text-[rgb(var(--ec-page-text-muted))]">
|
|
98
|
+
{dragOver ? 'Release to upload' : 'Drag a file here, or click to browse.'}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<input
|
|
104
|
+
ref={inputRef}
|
|
105
|
+
type="file"
|
|
106
|
+
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
|
107
|
+
className="hidden"
|
|
108
|
+
disabled={!canEdit || busy}
|
|
109
|
+
onChange={(e) => {
|
|
110
|
+
const file = e.target.files?.[0];
|
|
111
|
+
if (file) void upload(file);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
disabled={!canEdit || busy}
|
|
117
|
+
onClick={() => inputRef.current?.click()}
|
|
118
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-3 py-1.5 text-[12px] font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-page-bg)/0.78)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
119
|
+
>
|
|
120
|
+
<Upload className="h-3 w-3" aria-hidden />
|
|
121
|
+
{src ? 'Replace' : 'Upload'}
|
|
122
|
+
</button>
|
|
123
|
+
{src && (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
disabled={!canEdit || busy}
|
|
127
|
+
onClick={remove}
|
|
128
|
+
aria-label="Remove logo"
|
|
129
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-transparent text-[rgb(var(--ec-page-text-muted))] transition-colors hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
130
|
+
>
|
|
131
|
+
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|