@eventcatalog/core 3.29.2 → 3.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-36IA4UE4.js → chunk-7IGMIOQF.js} +1 -1
  6. package/dist/{chunk-EGQGCB2B.js → chunk-HVOLSUC2.js} +1 -1
  7. package/dist/{chunk-DB4IQ3GB.js → chunk-LWVHWR77.js} +1 -1
  8. package/dist/{chunk-VEUNSJ6Z.js → chunk-QIJOBQZ7.js} +1 -1
  9. package/dist/{chunk-MEJOYC5Z.js → chunk-UY5QDWK7.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/astro.config.mjs +11 -7
  19. package/eventcatalog/public/logo.png +0 -0
  20. package/eventcatalog/src/components/CopyAsMarkdown.tsx +29 -24
  21. package/eventcatalog/src/components/EnvironmentDropdown.tsx +33 -21
  22. package/eventcatalog/src/components/FieldsExplorer/FieldFilters.tsx +3 -53
  23. package/eventcatalog/src/components/FieldsExplorer/FieldsExplorer.tsx +144 -91
  24. package/eventcatalog/src/components/FieldsExplorer/FieldsTable.tsx +112 -109
  25. package/eventcatalog/src/components/Header.astro +9 -19
  26. package/eventcatalog/src/components/MDX/Accordion/Accordion.tsx +12 -14
  27. package/eventcatalog/src/components/MDX/Accordion/AccordionGroup.astro +11 -3
  28. package/eventcatalog/src/components/MDX/Design/Design.astro +1 -1
  29. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +15 -5
  30. package/eventcatalog/src/components/MDX/Tiles/Tile.astro +11 -8
  31. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +164 -53
  32. package/eventcatalog/src/components/SchemaExplorer/DiffViewer.tsx +1 -1
  33. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +4 -4
  34. package/eventcatalog/src/components/SchemaExplorer/Pagination.tsx +12 -10
  35. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +48 -77
  36. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +238 -169
  37. package/eventcatalog/src/components/SchemaExplorer/SchemaExplorer.tsx +189 -230
  38. package/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx +39 -36
  39. package/eventcatalog/src/components/Search/Search.astro +1 -1
  40. package/eventcatalog/src/components/Seo.astro +1 -1
  41. package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +218 -0
  42. package/eventcatalog/src/components/Settings/BillingSettingsForm.tsx +265 -0
  43. package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +371 -0
  44. package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +183 -0
  45. package/eventcatalog/src/components/Settings/LogoUpload.tsx +137 -0
  46. package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +91 -0
  47. package/eventcatalog/src/components/Settings/ReadOnlyBanner.tsx +18 -0
  48. package/eventcatalog/src/components/Settings/Row.tsx +59 -0
  49. package/eventcatalog/src/components/Settings/SettingsShared.tsx +176 -0
  50. package/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx +3 -3
  51. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +233 -261
  52. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +116 -68
  53. package/eventcatalog/src/components/Tables/Discover/FilterComponents.tsx +2 -2
  54. package/eventcatalog/src/components/Tables/Discover/columns.tsx +130 -197
  55. package/eventcatalog/src/components/Tables/Table.tsx +21 -18
  56. package/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx +79 -131
  57. package/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx +104 -175
  58. package/eventcatalog/src/content.config.ts +1 -1
  59. package/eventcatalog/src/enterprise/auth/error.astro +1 -1
  60. package/eventcatalog/src/enterprise/auth/login.astro +1 -1
  61. package/eventcatalog/src/enterprise/auth/middleware/middleware-auth.ts +11 -7
  62. package/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/index.tsx +97 -95
  63. package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +232 -181
  64. package/eventcatalog/src/enterprise/feature.ts +2 -1
  65. package/eventcatalog/src/enterprise/fields/pages/fields.astro +10 -8
  66. package/eventcatalog/src/enterprise/integrations/eventcatalog-features.ts +0 -8
  67. package/eventcatalog/src/layouts/DirectoryLayout.astro +17 -88
  68. package/eventcatalog/src/layouts/SettingsLayout.astro +116 -0
  69. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +562 -141
  70. package/eventcatalog/src/layouts/VisualiserLayout.astro +7 -2
  71. package/eventcatalog/src/pages/_index.astro +253 -256
  72. package/eventcatalog/src/pages/api/settings/ai.ts +57 -0
  73. package/eventcatalog/src/pages/api/settings/general.ts +71 -0
  74. package/eventcatalog/src/pages/api/settings/logo.ts +113 -0
  75. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +3 -3
  76. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +223 -73
  77. package/eventcatalog/src/pages/discover/[type]/index.astro +22 -141
  78. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +130 -30
  79. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +147 -53
  80. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/[filename].astro +6 -2
  81. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +2 -2
  82. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +22 -19
  83. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +71 -61
  84. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/[filename].astro +5 -1
  85. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +3 -3
  86. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +6 -32
  87. package/eventcatalog/src/pages/docs/llm/llms.txt.ts +5 -1
  88. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +11 -4
  89. package/eventcatalog/src/pages/docs/users/[id]/index.astro +12 -5
  90. package/eventcatalog/src/pages/schemas/explorer/index.astro +10 -8
  91. package/eventcatalog/src/pages/settings/assistant.astro +37 -0
  92. package/eventcatalog/src/pages/settings/billing.astro +17 -0
  93. package/eventcatalog/src/pages/settings/general.astro +32 -0
  94. package/eventcatalog/src/pages/settings/index.astro +21 -0
  95. package/eventcatalog/src/pages/settings/llm-access.astro +34 -0
  96. package/eventcatalog/src/pages/settings/mcp.astro +14 -0
  97. package/eventcatalog/src/pages/studio.astro +1 -1
  98. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +2 -7
  99. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +2 -2
  100. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +2 -7
  101. package/eventcatalog/src/styles/theme.css +95 -30
  102. package/eventcatalog/src/styles/themes/forest.css +17 -9
  103. package/eventcatalog/src/styles/themes/ocean.css +10 -2
  104. package/eventcatalog/src/styles/themes/sapphire.css +10 -2
  105. package/eventcatalog/src/styles/themes/sunset.css +25 -17
  106. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +13 -0
  107. package/eventcatalog/src/utils/eventcatalog-config/config-schema.ts +49 -0
  108. package/eventcatalog/src/utils/eventcatalog-config/config-writer.ts +149 -0
  109. package/eventcatalog/src/utils/url-builder.ts +4 -2
  110. package/package.json +7 -5
  111. package/eventcatalog/public/logo.svg +0 -14
  112. package/eventcatalog/src/enterprise/plans/index.astro +0 -319
  113. 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
+ };