@prenta/admin 2.5.0 → 2.6.0

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 (90) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.js +3 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  4. package/dist/__tests__/views/mcp-connect.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/mcp-connect.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/mcp-connect.render.test.js +55 -0
  7. package/dist/__tests__/views/mcp-connect.render.test.js.map +1 -0
  8. package/dist/__tests__/views/mcp-connect.test.d.ts +2 -0
  9. package/dist/__tests__/views/mcp-connect.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/mcp-connect.test.js +37 -0
  11. package/dist/__tests__/views/mcp-connect.test.js.map +1 -0
  12. package/dist/lib/appearance-api.d.ts +8 -0
  13. package/dist/lib/appearance-api.d.ts.map +1 -1
  14. package/dist/lib/appearance-api.js.map +1 -1
  15. package/dist/prenta-admin.css +1 -1
  16. package/dist/views/ApiKeys.d.ts +1 -1
  17. package/dist/views/ApiKeys.d.ts.map +1 -1
  18. package/dist/views/ApiKeys.js +2 -3
  19. package/dist/views/ApiKeys.js.map +1 -1
  20. package/dist/views/Settings.d.ts.map +1 -1
  21. package/dist/views/Settings.js +5 -3
  22. package/dist/views/Settings.js.map +1 -1
  23. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  24. package/dist/views/page-editor/EditorCanvas.js +2 -1
  25. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  26. package/dist/views/page-editor/SectionThemeRoot.d.ts +10 -0
  27. package/dist/views/page-editor/SectionThemeRoot.d.ts.map +1 -0
  28. package/dist/views/page-editor/SectionThemeRoot.js +27 -0
  29. package/dist/views/page-editor/SectionThemeRoot.js.map +1 -0
  30. package/dist/views/page-editor/sections/AuthorBioSection.js +1 -1
  31. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -1
  32. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  33. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  34. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  35. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  36. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  37. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  38. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  39. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  40. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  41. package/dist/views/page-editor/sections/parts.js +11 -15
  42. package/dist/views/page-editor/sections/parts.js.map +1 -1
  43. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostEditorCanvas.js +3 -2
  45. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  46. package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
  47. package/dist/views/settings/AppearanceTab.js +2 -1
  48. package/dist/views/settings/AppearanceTab.js.map +1 -1
  49. package/dist/views/settings/McpConnectTab.d.ts +9 -0
  50. package/dist/views/settings/McpConnectTab.d.ts.map +1 -0
  51. package/dist/views/settings/McpConnectTab.js +71 -0
  52. package/dist/views/settings/McpConnectTab.js.map +1 -0
  53. package/dist/views/settings/SiteThemeCard.d.ts +7 -0
  54. package/dist/views/settings/SiteThemeCard.d.ts.map +1 -0
  55. package/dist/views/settings/SiteThemeCard.js +23 -0
  56. package/dist/views/settings/SiteThemeCard.js.map +1 -0
  57. package/dist/views/settings/mcp-connect.d.ts +25 -0
  58. package/dist/views/settings/mcp-connect.d.ts.map +1 -0
  59. package/dist/views/settings/mcp-connect.js +166 -0
  60. package/dist/views/settings/mcp-connect.js.map +1 -0
  61. package/dist/views/settings/useAppearanceSettings.d.ts +3 -1
  62. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  63. package/dist/views/settings/useAppearanceSettings.js +27 -1
  64. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  65. package/package.json +4 -4
  66. package/src/__tests__/views/appearance-settings.render.test.tsx +8 -0
  67. package/src/__tests__/views/mcp-connect.render.test.tsx +64 -0
  68. package/src/__tests__/views/mcp-connect.test.ts +42 -0
  69. package/src/lib/appearance-api.ts +8 -0
  70. package/src/views/ApiKeys.tsx +4 -5
  71. package/src/views/Settings.tsx +13 -0
  72. package/src/views/page-editor/EditorCanvas.tsx +2 -1
  73. package/src/views/page-editor/SectionThemeRoot.tsx +33 -0
  74. package/src/views/page-editor/sections/AuthorBioSection.tsx +2 -2
  75. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +1 -1
  76. package/src/views/page-editor/sections/CtaBannerSection.tsx +1 -1
  77. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -3
  78. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  79. package/src/views/page-editor/sections/parts.tsx +12 -15
  80. package/src/views/post-editor/PostEditorCanvas.tsx +21 -18
  81. package/src/views/settings/AppearanceTab.tsx +2 -0
  82. package/src/views/settings/McpConnectTab.tsx +212 -0
  83. package/src/views/settings/SiteThemeCard.tsx +71 -0
  84. package/src/views/settings/mcp-connect.ts +209 -0
  85. package/src/views/settings/useAppearanceSettings.ts +39 -0
  86. package/dist/views/settings/McpSetupCard.d.ts +0 -2
  87. package/dist/views/settings/McpSetupCard.d.ts.map +0 -1
  88. package/dist/views/settings/McpSetupCard.js +0 -48
  89. package/dist/views/settings/McpSetupCard.js.map +0 -1
  90. package/src/views/settings/McpSetupCard.tsx +0 -95
@@ -0,0 +1,212 @@
1
+ 'use client'
2
+
3
+ import * as Tabs from '@radix-ui/react-tabs'
4
+ import { Copy, Loader2 } from 'lucide-react'
5
+ import { useEffect, useState } from 'react'
6
+ import { toast } from 'sonner'
7
+
8
+ import { cmsApi } from '../../lib/api.js'
9
+ import { SettingsCard } from './components.js'
10
+ import { mcpClientGuides, type McpClientGuide, type McpClientId } from './mcp-connect.js'
11
+
12
+ type Grant = {
13
+ id: string
14
+ clientId: string
15
+ scope: string
16
+ revokedAt: string | null
17
+ }
18
+
19
+ async function copyText(value: string, label: string) {
20
+ try {
21
+ await navigator.clipboard.writeText(value)
22
+ toast.success(`${label} copied`)
23
+ } catch {
24
+ toast.error('Could not copy. Select the text and copy it manually.')
25
+ }
26
+ }
27
+
28
+ function ClientPanel({ guide, onCreateKey }: { guide: McpClientGuide; onCreateKey?: () => void }) {
29
+ const [manual, setManual] = useState(false)
30
+
31
+ return (
32
+ <div className="grid gap-6 lg:grid-cols-2">
33
+ <div className="space-y-4">
34
+ <div className="space-y-1">
35
+ <h3 className="text-lg font-medium">{guide.title}</h3>
36
+ <p className="text-muted-foreground text-sm">{guide.summary}</p>
37
+ </div>
38
+ <button
39
+ type="button"
40
+ className="bg-primary text-primary-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none"
41
+ onClick={() => void copyText(guide.copyText, guide.action.replace(/^Copy /, ''))}
42
+ >
43
+ <Copy size={16} aria-hidden="true" />
44
+ {guide.action}
45
+ </button>
46
+ <div>
47
+ <button
48
+ type="button"
49
+ className="text-primary focus-visible:ring-ring text-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
50
+ aria-expanded={manual}
51
+ onClick={() => setManual((open) => !open)}
52
+ >
53
+ Prefer manual configuration?
54
+ </button>
55
+ {manual && (
56
+ <ol className="text-muted-foreground mt-3 list-decimal space-y-2 pl-5 text-sm">
57
+ {guide.manual.map((step) => (
58
+ <li key={step}>{step}</li>
59
+ ))}
60
+ </ol>
61
+ )}
62
+ </div>
63
+ {guide.needsApiKey && onCreateKey && (
64
+ <button
65
+ type="button"
66
+ className="text-foreground focus-visible:ring-ring text-sm font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
67
+ onClick={onCreateKey}
68
+ >
69
+ Create a Content API key
70
+ </button>
71
+ )}
72
+ </div>
73
+ <div className="border-border bg-muted relative rounded-lg border">
74
+ <button
75
+ type="button"
76
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2 right-2 rounded-md p-2 focus-visible:ring-2 focus-visible:outline-none"
77
+ aria-label={guide.action}
78
+ onClick={() => void copyText(guide.copyText, guide.action.replace(/^Copy /, ''))}
79
+ >
80
+ <Copy size={16} aria-hidden="true" />
81
+ </button>
82
+ <pre className="max-h-80 overflow-auto p-4 pr-12 font-mono text-sm whitespace-pre-wrap">
83
+ {guide.panel}
84
+ </pre>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /**
91
+ * Settings → MCP. One tab per client, matching the "copy this and paste it
92
+ * there" setup other site builders use. The URL comes from the server so the
93
+ * Host header is never trusted.
94
+ */
95
+ export function McpConnectTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
96
+ const [mcpUrl, setMcpUrl] = useState('')
97
+ const [loadError, setLoadError] = useState<string | null>(null)
98
+ const [loading, setLoading] = useState(true)
99
+ const [grants, setGrants] = useState<Grant[]>([])
100
+ const [client, setClient] = useState<McpClientId>('claude-code')
101
+
102
+ function loadGrants() {
103
+ void cmsApi<Grant[]>('/mcp/oauth/grants').then((res) => {
104
+ if (res.data) setGrants(res.data.filter((grant) => !grant.revokedAt))
105
+ })
106
+ }
107
+
108
+ useEffect(() => {
109
+ let cancelled = false
110
+ void cmsApi<{ mcpUrl: string }>('/mcp/oauth/setup').then((res) => {
111
+ if (cancelled) return
112
+ setLoading(false)
113
+ if (res.error || !res.data?.mcpUrl) {
114
+ setLoadError(
115
+ res.error ?? 'This site has no public URL yet, so there is nothing to connect.',
116
+ )
117
+ return
118
+ }
119
+ setMcpUrl(res.data.mcpUrl)
120
+ })
121
+ loadGrants()
122
+ return () => {
123
+ cancelled = true
124
+ }
125
+ }, [])
126
+
127
+ async function revoke(id: string) {
128
+ const res = await cmsApi<{ note: string }>(`/mcp/oauth/grants/${encodeURIComponent(id)}`, {
129
+ method: 'DELETE',
130
+ })
131
+ if (res.error) {
132
+ toast.error(res.error)
133
+ return
134
+ }
135
+ toast.success(res.data?.note ?? 'Disconnected')
136
+ loadGrants()
137
+ }
138
+
139
+ const guides = mcpUrl ? mcpClientGuides(mcpUrl) : []
140
+
141
+ return (
142
+ <div className="space-y-6">
143
+ <div className="space-y-1">
144
+ <h2 className="text-lg font-medium">MCP</h2>
145
+ <p className="text-muted-foreground text-sm">
146
+ Connect an AI agent to this site. It can draft and edit pages, then you approve what goes
147
+ live.
148
+ </p>
149
+ </div>
150
+
151
+ {loading ? (
152
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
153
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
154
+ Loading connection details
155
+ </div>
156
+ ) : loadError ? (
157
+ <p role="alert" className="text-destructive text-sm">
158
+ {loadError}
159
+ </p>
160
+ ) : (
161
+ <Tabs.Root value={client} onValueChange={(value) => setClient(value as McpClientId)}>
162
+ <Tabs.List
163
+ aria-label="AI clients"
164
+ className="border-border mb-6 flex gap-1 overflow-x-auto border-b"
165
+ >
166
+ {guides.map((guide) => (
167
+ <Tabs.Trigger
168
+ key={guide.id}
169
+ value={guide.id}
170
+ className="text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground focus-visible:ring-ring px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none data-[state=active]:border-b-2"
171
+ >
172
+ {guide.label}
173
+ </Tabs.Trigger>
174
+ ))}
175
+ </Tabs.List>
176
+ {guides.map((guide) => (
177
+ <Tabs.Content key={guide.id} value={guide.id} className="focus-visible:outline-none">
178
+ <ClientPanel
179
+ guide={guide}
180
+ onCreateKey={onNavigate ? () => onNavigate('/settings?tab=api-keys') : undefined}
181
+ />
182
+ </Tabs.Content>
183
+ ))}
184
+ </Tabs.Root>
185
+ )}
186
+
187
+ <SettingsCard
188
+ title="Connected apps"
189
+ description="Revoke stops new requests immediately. An access token already issued can keep working for up to 15 minutes."
190
+ >
191
+ {grants.length === 0 ? (
192
+ <p className="text-muted-foreground text-sm">No apps connected yet.</p>
193
+ ) : (
194
+ <ul className="space-y-2">
195
+ {grants.map((grant) => (
196
+ <li key={grant.id} className="flex items-center justify-between gap-3 text-sm">
197
+ <span className="min-w-0 truncate">{grant.clientId}</span>
198
+ <button
199
+ type="button"
200
+ className="text-destructive focus-visible:ring-ring shrink-0 font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
201
+ onClick={() => void revoke(grant.id)}
202
+ >
203
+ Revoke
204
+ </button>
205
+ </li>
206
+ ))}
207
+ </ul>
208
+ )}
209
+ </SettingsCard>
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+
3
+ import type { PublicTheme } from '@prenta/core/appearance'
4
+
5
+ import type { UseAppearanceSettings } from './useAppearanceSettings.js'
6
+
7
+ const FIELDS: { key: keyof PublicTheme; label: string; hint: string }[] = [
8
+ { key: 'accent', label: 'Accent', hint: 'Buttons and highlights. Example: #7c3aed' },
9
+ { key: 'background', label: 'Background', hint: 'Page surface. Example: #ffffff' },
10
+ { key: 'foreground', label: 'Text', hint: 'Body text. Example: #18181b' },
11
+ { key: 'muted', label: 'Muted text', hint: 'Secondary text. Example: #71717a' },
12
+ { key: 'fontFamily', label: 'Font', hint: 'A font already on the site. Example: Georgia, serif' },
13
+ { key: 'radius', label: 'Corner radius', hint: 'Example: 0.75rem' },
14
+ ]
15
+
16
+ function isColorKey(key: keyof PublicTheme): boolean {
17
+ return key === 'accent' || key === 'background' || key === 'foreground' || key === 'muted'
18
+ }
19
+
20
+ /** Public site theme. Empty fields keep the built-in section palette. */
21
+ export function SiteThemeCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit: boolean }) {
22
+ return (
23
+ <section className="border-border bg-card space-y-4 rounded-lg border p-6">
24
+ <h3 className="text-lg font-medium">Site theme</h3>
25
+ <p className="text-muted-foreground text-sm">
26
+ Colors and type for published pages. Leave a field empty to keep the default.
27
+ </p>
28
+ <div className="grid gap-4 sm:grid-cols-2">
29
+ {FIELDS.map(({ key, label, hint }) => {
30
+ const value = s.appearance.publicTheme[key] ?? ''
31
+ const issue = s.issues.find((item) => item.field === `publicTheme.${key}`)
32
+ const id = `site-theme-${key}`
33
+ return (
34
+ <div key={key} className="space-y-1">
35
+ <label htmlFor={id} className="text-sm font-medium">
36
+ {label}
37
+ </label>
38
+ <div className="flex items-center gap-2">
39
+ {isColorKey(key) && value ? (
40
+ <span
41
+ aria-hidden
42
+ className="border-border size-8 shrink-0 rounded-md border"
43
+ style={{ background: value }}
44
+ />
45
+ ) : null}
46
+ <input
47
+ id={id}
48
+ value={value}
49
+ disabled={!canEdit}
50
+ aria-invalid={issue ? true : undefined}
51
+ aria-describedby={issue ? `${id}-error` : `${id}-hint`}
52
+ onChange={(event) => s.setPublicTheme({ [key]: event.target.value })}
53
+ className="border-input bg-background w-full rounded-md border px-3 py-2 text-sm disabled:opacity-60"
54
+ />
55
+ </div>
56
+ {issue ? (
57
+ <p id={`${id}-error`} className="text-destructive text-sm">
58
+ {issue.message}
59
+ </p>
60
+ ) : (
61
+ <p id={`${id}-hint`} className="text-muted-foreground text-sm">
62
+ {hint}
63
+ </p>
64
+ )}
65
+ </div>
66
+ )
67
+ })}
68
+ </div>
69
+ </section>
70
+ )
71
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Connection text for Settings → MCP. Each client gets one thing to copy.
3
+ * OAuth clients receive the remote URL. Editors that cannot sign in receive
4
+ * a stdio config with a placeholder key, never a real secret.
5
+ */
6
+
7
+ export type McpClientId =
8
+ | 'claude-code'
9
+ | 'claude'
10
+ | 'chatgpt'
11
+ | 'grok'
12
+ | 'cursor'
13
+ | 'vscode'
14
+ | 'other'
15
+
16
+ export interface McpClientGuide {
17
+ id: McpClientId
18
+ label: string
19
+ title: string
20
+ /** One sentence under the title. */
21
+ summary: string
22
+ /** Primary button. */
23
+ action: string
24
+ /** Exact text the primary button and the panel copy control place on the clipboard. */
25
+ copyText: string
26
+ /** Shown in the panel. Equals copyText except where the panel adds a label. */
27
+ panel: string
28
+ manual: string[]
29
+ /** This client needs a Content API key created on the API Keys tab. */
30
+ needsApiKey: boolean
31
+ }
32
+
33
+ export function mcpServerName(mcpUrl: string): string {
34
+ try {
35
+ const host = new URL(mcpUrl).hostname.replace(/^www\./, '')
36
+ const label = (host.split('.')[0] || 'site').toLowerCase().replace(/[^a-z0-9]+/g, '-')
37
+ return `${label.replace(/^-|-$/g, '') || 'site'}-prenta`
38
+ } catch {
39
+ return 'site-prenta'
40
+ }
41
+ }
42
+
43
+ function mcpSiteOrigin(mcpUrl: string): string {
44
+ try {
45
+ return new URL(mcpUrl).origin
46
+ } catch {
47
+ return mcpUrl.replace(/\/mcp\/?$/, '')
48
+ }
49
+ }
50
+
51
+ export function mcpClientGuides(mcpUrl: string): McpClientGuide[] {
52
+ const name = mcpServerName(mcpUrl)
53
+ const origin = mcpSiteOrigin(mcpUrl)
54
+ const claudeCodePrompt = [
55
+ 'Add this Prenta site as a remote MCP server.',
56
+ '',
57
+ 'Connection details:',
58
+ `- Server URL: ${mcpUrl}`,
59
+ '- Transport: HTTP',
60
+ `- Server name: ${name}`,
61
+ '',
62
+ 'Do not store an API key. This site signs in with OAuth. After the server is added, start the sign-in flow so I can approve it in the browser.',
63
+ '',
64
+ `Command: claude mcp add --transport http ${name} ${mcpUrl}`,
65
+ ].join('\n')
66
+
67
+ const cursorConfig = JSON.stringify(
68
+ {
69
+ mcpServers: {
70
+ [name]: {
71
+ command: 'npx',
72
+ args: ['-y', '@prenta/mcp-server'],
73
+ env: {
74
+ PRENTA_BASE_URL: origin,
75
+ PRENTA_API_KEY: 'act_sk_...',
76
+ },
77
+ },
78
+ },
79
+ },
80
+ null,
81
+ 2,
82
+ )
83
+
84
+ const vscodeConfig = JSON.stringify(
85
+ {
86
+ servers: {
87
+ [name]: {
88
+ type: 'stdio',
89
+ command: 'npx',
90
+ args: ['-y', '@prenta/mcp-server'],
91
+ env: {
92
+ PRENTA_BASE_URL: origin,
93
+ PRENTA_API_KEY: 'act_sk_...',
94
+ },
95
+ },
96
+ },
97
+ },
98
+ null,
99
+ 2,
100
+ )
101
+
102
+ const bearer = 'Authorization: Bearer act_sk_...'
103
+
104
+ return [
105
+ {
106
+ id: 'claude-code',
107
+ label: 'Claude Code',
108
+ title: 'Connect this site to Claude Code',
109
+ summary:
110
+ 'Paste this prompt into Claude Code. It adds the server, then this site asks you to approve access.',
111
+ action: 'Copy prompt',
112
+ copyText: claudeCodePrompt,
113
+ panel: claudeCodePrompt,
114
+ manual: [
115
+ `Run: claude mcp add --transport http ${name} ${mcpUrl}`,
116
+ 'Approve the sign-in page this site opens. Do not paste an API key into the command.',
117
+ ],
118
+ needsApiKey: false,
119
+ },
120
+ {
121
+ id: 'claude',
122
+ label: 'Claude',
123
+ title: 'Connect this site to Claude',
124
+ summary: 'Claude on the web, Desktop, and Cowork use this URL and sign in here.',
125
+ action: 'Copy URL',
126
+ copyText: mcpUrl,
127
+ panel: mcpUrl,
128
+ manual: [
129
+ 'Open Claude → Settings → Connectors → Add custom connector.',
130
+ 'Paste the URL and continue. This site opens so you can allow access.',
131
+ ],
132
+ needsApiKey: false,
133
+ },
134
+ {
135
+ id: 'chatgpt',
136
+ label: 'ChatGPT',
137
+ title: 'Connect this site to ChatGPT',
138
+ summary: 'ChatGPT signs in here. It cannot use an API key.',
139
+ action: 'Copy URL',
140
+ copyText: mcpUrl,
141
+ panel: mcpUrl,
142
+ manual: [
143
+ 'Open ChatGPT → Settings → Connectors and add a custom connector.',
144
+ 'Paste the URL. ChatGPT sends you back here to approve access.',
145
+ ],
146
+ needsApiKey: false,
147
+ },
148
+ {
149
+ id: 'grok',
150
+ label: 'Grok',
151
+ title: 'Connect this site to Grok',
152
+ summary:
153
+ 'Grok on the web signs in here. The Grok CLI and the xAI API send a Content API key instead.',
154
+ action: 'Copy URL',
155
+ copyText: mcpUrl,
156
+ panel: mcpUrl,
157
+ manual: [
158
+ 'On grok.com, add a connector and paste the URL, then approve access on this site.',
159
+ `For the Grok CLI or the xAI API, create a Content API key and send ${bearer}. Include the word Bearer.`,
160
+ ],
161
+ needsApiKey: true,
162
+ },
163
+ {
164
+ id: 'cursor',
165
+ label: 'Cursor',
166
+ title: 'Connect this site to Cursor',
167
+ summary:
168
+ 'Cursor runs the local server. Create a Content API key and replace the placeholder.',
169
+ action: 'Copy config',
170
+ copyText: cursorConfig,
171
+ panel: cursorConfig,
172
+ manual: [
173
+ 'Open Cursor → Settings → MCP and add a new server, or paste this into .cursor/mcp.json.',
174
+ 'Create a Content API key and replace act_sk_... . Do not commit the file.',
175
+ ],
176
+ needsApiKey: true,
177
+ },
178
+ {
179
+ id: 'vscode',
180
+ label: 'VS Code',
181
+ title: 'Connect this site to VS Code',
182
+ summary:
183
+ 'Save this as .vscode/mcp.json. Create a Content API key and replace the placeholder.',
184
+ action: 'Copy config',
185
+ copyText: vscodeConfig,
186
+ panel: vscodeConfig,
187
+ manual: [
188
+ 'Save the config as .vscode/mcp.json.',
189
+ 'Create a Content API key and replace act_sk_... . Do not commit the file.',
190
+ ],
191
+ needsApiKey: true,
192
+ },
193
+ {
194
+ id: 'other',
195
+ label: 'Other',
196
+ title: 'Connect another client',
197
+ summary:
198
+ 'Clients that can sign in use the URL. Clients that can set a header use a Content API key.',
199
+ action: 'Copy URL',
200
+ copyText: mcpUrl,
201
+ panel: mcpUrl,
202
+ manual: [
203
+ 'Point the client at the server URL.',
204
+ 'If it signs in, approve access on this site. If it sends a header, use a Content API key as Authorization: Bearer act_sk_... . Do not put the key in the query string.',
205
+ ],
206
+ needsApiKey: true,
207
+ },
208
+ ]
209
+ }
@@ -5,6 +5,8 @@ import {
5
5
  logoShapeWarning,
6
6
  resolveAdminBrand,
7
7
  resolvePublicBrand,
8
+ parsePublicTheme,
9
+ publicThemeFieldError,
8
10
  validateAccentColor,
9
11
  type AdminBrandingMode,
10
12
  type BrandAssetLike,
@@ -13,6 +15,7 @@ import {
13
15
  type ColorMode,
14
16
  type LogoShape,
15
17
  type ResolvedAdminBrand,
18
+ type PublicTheme,
16
19
  type ResolvedPublicBrand,
17
20
  type SidebarDensity,
18
21
  } from '@prenta/core/appearance'
@@ -39,6 +42,7 @@ interface AppearanceForm {
39
42
  density: SidebarDensity
40
43
  font: string
41
44
  reduceMotion: boolean
45
+ publicTheme: Required<PublicTheme>
42
46
  }
43
47
 
44
48
  /** Brand asset references + admin branding mode (the `brand` settings blob). */
@@ -136,6 +140,7 @@ export interface UseAppearanceSettings {
136
140
  setDensity: (v: SidebarDensity) => void
137
141
  setFont: (v: string) => void
138
142
  setReduceMotion: (v: boolean) => void
143
+ setPublicTheme: (patch: Partial<PublicTheme>) => void
139
144
  setBrandingMode: (v: AdminBrandingMode) => void
140
145
  setAltText: (v: string) => void
141
146
  uploadAsset: (field: BrandAssetField, file: File) => Promise<void>
@@ -147,12 +152,22 @@ export interface UseAppearanceSettings {
147
152
  reload: () => void
148
153
  }
149
154
 
155
+ const EMPTY_PUBLIC_THEME: Required<PublicTheme> = {
156
+ accent: '',
157
+ background: '',
158
+ foreground: '',
159
+ muted: '',
160
+ fontFamily: '',
161
+ radius: '',
162
+ }
163
+
150
164
  const DEFAULT_FORM: AppearanceForm = {
151
165
  colorMode: 'system',
152
166
  accent: 'purple',
153
167
  density: 'default',
154
168
  font: 'System UI',
155
169
  reduceMotion: false,
170
+ publicTheme: EMPTY_PUBLIC_THEME,
156
171
  }
157
172
 
158
173
  const DEFAULT_BRAND_FORM: BrandForm = { mode: 'inheritPublicBrand' }
@@ -263,12 +278,14 @@ export function useAppearanceSettings(
263
278
  return
264
279
  }
265
280
  const d = res.data
281
+ const storedTheme = d.appearance.publicTheme
266
282
  const nextAppearance: AppearanceForm = {
267
283
  colorMode: d.appearance.adminColorModeDefault,
268
284
  accent: d.appearance.adminAccentColor,
269
285
  density: d.appearance.sidebarDensity,
270
286
  font: d.appearance.contentFont,
271
287
  reduceMotion: d.appearance.reduceMotion,
288
+ publicTheme: { ...EMPTY_PUBLIC_THEME, ...storedTheme },
272
289
  }
273
290
  const nextBrand: BrandForm = { ...d.brand, mode: d.brand?.mode ?? 'inheritPublicBrand' }
274
291
  const nextAssets = parseAppearanceAssets(d.assets ?? [])
@@ -319,6 +336,15 @@ export function useAppearanceSettings(
319
336
  (v: boolean) => update({ ...appearance, reduceMotion: v }, brand, assets),
320
337
  [appearance, brand, assets, update],
321
338
  )
339
+ const setPublicTheme = useCallback(
340
+ (patch: Partial<PublicTheme>) =>
341
+ update(
342
+ { ...appearance, publicTheme: { ...appearance.publicTheme, ...patch } },
343
+ brand,
344
+ assets,
345
+ ),
346
+ [appearance, brand, assets, update],
347
+ )
322
348
  const setBrandingMode = useCallback(
323
349
  (v: AdminBrandingMode) => update(appearance, { ...brand, mode: v }, assets),
324
350
  [appearance, brand, assets, update],
@@ -434,6 +460,17 @@ export function useAppearanceSettings(
434
460
  }
435
461
  // Dark mode default but no dark logo.
436
462
  const hasDark = Boolean(brand.publicDarkLogoAssetId || brand.adminDarkLogoAssetId)
463
+ for (const field of [
464
+ 'accent',
465
+ 'background',
466
+ 'foreground',
467
+ 'muted',
468
+ 'fontFamily',
469
+ 'radius',
470
+ ] as const) {
471
+ const message = publicThemeFieldError(field, appearance.publicTheme[field])
472
+ if (message) out.push({ field: `publicTheme.${field}`, message, severity: 'error' })
473
+ }
437
474
  if (appearance.colorMode === 'dark' && !hasDark) {
438
475
  out.push({
439
476
  field: 'colorMode',
@@ -488,6 +525,7 @@ export function useAppearanceSettings(
488
525
  sidebarDensity: appearance.density,
489
526
  contentFont: appearance.font,
490
527
  reduceMotion: appearance.reduceMotion,
528
+ publicTheme: parsePublicTheme(appearance.publicTheme),
491
529
  updatedAt: now,
492
530
  }
493
531
  const merged = {
@@ -536,6 +574,7 @@ export function useAppearanceSettings(
536
574
  setDensity,
537
575
  setFont,
538
576
  setReduceMotion,
577
+ setPublicTheme,
539
578
  setBrandingMode,
540
579
  setAltText,
541
580
  uploadAsset,
@@ -1,2 +0,0 @@
1
- export declare function McpSetupCard(): import("react/jsx-runtime").JSX.Element | null;
2
- //# sourceMappingURL=McpSetupCard.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"McpSetupCard.d.ts","sourceRoot":"","sources":["../../../src/views/settings/McpSetupCard.tsx"],"names":[],"mappings":"AAeA,wBAAgB,YAAY,mDA+E3B"}
@@ -1,48 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
4
- import { Copy } from 'lucide-react';
5
- import { toast } from 'sonner';
6
- import { cmsApi } from '../../lib/api.js';
7
- import { SettingsCard } from './components.js';
8
- export function McpSetupCard() {
9
- const [mcpUrl, setMcpUrl] = useState('');
10
- const [grants, setGrants] = useState([]);
11
- const [hidden, setHidden] = useState(false);
12
- function load() {
13
- void cmsApi('/mcp/oauth/setup').then((res) => {
14
- if (res.error || !res.data?.mcpUrl) {
15
- setHidden(true);
16
- return;
17
- }
18
- setMcpUrl(res.data.mcpUrl);
19
- });
20
- void cmsApi('/mcp/oauth/grants').then((res) => {
21
- if (res.data)
22
- setGrants(res.data.filter((grant) => !grant.revokedAt));
23
- });
24
- }
25
- useEffect(() => {
26
- load();
27
- }, []);
28
- async function revoke(id) {
29
- const res = await cmsApi(`/mcp/oauth/grants/${encodeURIComponent(id)}`, {
30
- method: 'DELETE',
31
- });
32
- if (res.error) {
33
- toast.error(res.error);
34
- return;
35
- }
36
- toast.success(res.data?.note ?? 'Disconnected');
37
- load();
38
- }
39
- if (hidden)
40
- return null;
41
- return (_jsxs(SettingsCard, { title: "Remote MCP", description: "Paste this URL into Claude, ChatGPT, or Grok on the web. They sign in here. Cursor and VS Code keep using an API key from the list below.", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("code", { className: "bg-muted text-foreground rounded px-2 py-1 text-sm", children: mcpUrl || '…' }), _jsxs("button", { type: "button", className: "border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium", onClick: () => {
42
- if (!mcpUrl)
43
- return;
44
- void navigator.clipboard.writeText(mcpUrl);
45
- toast.success('MCP URL copied');
46
- }, "aria-label": "Copy MCP URL", children: [_jsx(Copy, { size: 16, "aria-hidden": "true" }), "Copy"] })] }), grants.length > 0 && (_jsx("ul", { className: "mt-4 space-y-2", children: grants.map((grant) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm", children: [_jsx("span", { className: "text-foreground min-w-0 truncate", children: grant.clientId }), _jsx("button", { type: "button", className: "text-destructive shrink-0 font-medium hover:underline", onClick: () => void revoke(grant.id), children: "Revoke" })] }, grant.id))) })), _jsx("p", { className: "text-muted-foreground mt-3 text-sm", children: "Revoke stops new requests immediately. An access token already issued can keep working for up to 15 minutes." })] }));
47
- }
48
- //# sourceMappingURL=McpSetupCard.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"McpSetupCard.js","sourceRoot":"","sources":["../../../src/views/settings/McpSetupCard.tsx"],"names":[],"mappings":"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"}