@prenta/admin 2.5.0 → 2.6.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.
- package/CHANGELOG.md +13 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +3 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/mcp-connect.render.test.d.ts +2 -0
- package/dist/__tests__/views/mcp-connect.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/mcp-connect.render.test.js +56 -0
- package/dist/__tests__/views/mcp-connect.render.test.js.map +1 -0
- package/dist/__tests__/views/mcp-connect.test.d.ts +2 -0
- package/dist/__tests__/views/mcp-connect.test.d.ts.map +1 -0
- package/dist/__tests__/views/mcp-connect.test.js +37 -0
- package/dist/__tests__/views/mcp-connect.test.js.map +1 -0
- package/dist/lib/appearance-api.d.ts +8 -0
- package/dist/lib/appearance-api.d.ts.map +1 -1
- package/dist/lib/appearance-api.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/ApiKeys.d.ts +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +2 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +2 -1
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/SectionThemeRoot.d.ts +10 -0
- package/dist/views/page-editor/SectionThemeRoot.d.ts.map +1 -0
- package/dist/views/page-editor/SectionThemeRoot.js +27 -0
- package/dist/views/page-editor/SectionThemeRoot.js.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js +1 -1
- package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -1
- package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
- package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
- package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
- package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
- package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
- package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
- package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
- package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
- package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
- package/dist/views/page-editor/sections/parts.js +11 -15
- package/dist/views/page-editor/sections/parts.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +3 -2
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/settings/AppearanceTab.d.ts.map +1 -1
- package/dist/views/settings/AppearanceTab.js +2 -1
- package/dist/views/settings/AppearanceTab.js.map +1 -1
- package/dist/views/settings/McpConnectTab.d.ts +9 -0
- package/dist/views/settings/McpConnectTab.d.ts.map +1 -0
- package/dist/views/settings/McpConnectTab.js +71 -0
- package/dist/views/settings/McpConnectTab.js.map +1 -0
- package/dist/views/settings/SiteThemeCard.d.ts +7 -0
- package/dist/views/settings/SiteThemeCard.d.ts.map +1 -0
- package/dist/views/settings/SiteThemeCard.js +23 -0
- package/dist/views/settings/SiteThemeCard.js.map +1 -0
- package/dist/views/settings/mcp-connect.d.ts +25 -0
- package/dist/views/settings/mcp-connect.d.ts.map +1 -0
- package/dist/views/settings/mcp-connect.js +166 -0
- package/dist/views/settings/mcp-connect.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.d.ts +3 -1
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +27 -1
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/views/appearance-settings.render.test.tsx +8 -0
- package/src/__tests__/views/mcp-connect.render.test.tsx +65 -0
- package/src/__tests__/views/mcp-connect.test.ts +42 -0
- package/src/lib/appearance-api.ts +8 -0
- package/src/views/ApiKeys.tsx +4 -5
- package/src/views/Settings.tsx +13 -0
- package/src/views/page-editor/EditorCanvas.tsx +2 -1
- package/src/views/page-editor/SectionThemeRoot.tsx +33 -0
- package/src/views/page-editor/sections/AuthorBioSection.tsx +2 -2
- package/src/views/page-editor/sections/ByTheNumbersSection.tsx +1 -1
- package/src/views/page-editor/sections/CtaBannerSection.tsx +1 -1
- package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -3
- package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
- package/src/views/page-editor/sections/parts.tsx +12 -15
- package/src/views/post-editor/PostEditorCanvas.tsx +21 -18
- package/src/views/settings/AppearanceTab.tsx +2 -0
- package/src/views/settings/McpConnectTab.tsx +213 -0
- package/src/views/settings/SiteThemeCard.tsx +71 -0
- package/src/views/settings/mcp-connect.ts +209 -0
- package/src/views/settings/useAppearanceSettings.ts +39 -0
- package/dist/views/settings/McpSetupCard.d.ts +0 -2
- package/dist/views/settings/McpSetupCard.d.ts.map +0 -1
- package/dist/views/settings/McpSetupCard.js +0 -48
- package/dist/views/settings/McpSetupCard.js.map +0 -1
- package/src/views/settings/McpSetupCard.tsx +0 -95
|
@@ -0,0 +1,213 @@
|
|
|
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. To build from a design, attach the Figma export as the reference and send each photo
|
|
148
|
+
as its own file.
|
|
149
|
+
</p>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{loading ? (
|
|
153
|
+
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
154
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
155
|
+
Loading connection details
|
|
156
|
+
</div>
|
|
157
|
+
) : loadError ? (
|
|
158
|
+
<p role="alert" className="text-destructive text-sm">
|
|
159
|
+
{loadError}
|
|
160
|
+
</p>
|
|
161
|
+
) : (
|
|
162
|
+
<Tabs.Root value={client} onValueChange={(value) => setClient(value as McpClientId)}>
|
|
163
|
+
<Tabs.List
|
|
164
|
+
aria-label="AI clients"
|
|
165
|
+
className="border-border mb-6 flex gap-1 overflow-x-auto border-b"
|
|
166
|
+
>
|
|
167
|
+
{guides.map((guide) => (
|
|
168
|
+
<Tabs.Trigger
|
|
169
|
+
key={guide.id}
|
|
170
|
+
value={guide.id}
|
|
171
|
+
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"
|
|
172
|
+
>
|
|
173
|
+
{guide.label}
|
|
174
|
+
</Tabs.Trigger>
|
|
175
|
+
))}
|
|
176
|
+
</Tabs.List>
|
|
177
|
+
{guides.map((guide) => (
|
|
178
|
+
<Tabs.Content key={guide.id} value={guide.id} className="focus-visible:outline-none">
|
|
179
|
+
<ClientPanel
|
|
180
|
+
guide={guide}
|
|
181
|
+
onCreateKey={onNavigate ? () => onNavigate('/settings?tab=api-keys') : undefined}
|
|
182
|
+
/>
|
|
183
|
+
</Tabs.Content>
|
|
184
|
+
))}
|
|
185
|
+
</Tabs.Root>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
<SettingsCard
|
|
189
|
+
title="Connected apps"
|
|
190
|
+
description="Revoke stops new requests immediately. An access token already issued can keep working for up to 15 minutes."
|
|
191
|
+
>
|
|
192
|
+
{grants.length === 0 ? (
|
|
193
|
+
<p className="text-muted-foreground text-sm">No apps connected yet.</p>
|
|
194
|
+
) : (
|
|
195
|
+
<ul className="space-y-2">
|
|
196
|
+
{grants.map((grant) => (
|
|
197
|
+
<li key={grant.id} className="flex items-center justify-between gap-3 text-sm">
|
|
198
|
+
<span className="min-w-0 truncate">{grant.clientId}</span>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className="text-destructive focus-visible:ring-ring shrink-0 font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
|
202
|
+
onClick={() => void revoke(grant.id)}
|
|
203
|
+
>
|
|
204
|
+
Revoke
|
|
205
|
+
</button>
|
|
206
|
+
</li>
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
)}
|
|
210
|
+
</SettingsCard>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -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 +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":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AACnC,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAC9B,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACzC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAS9C,MAAM,UAAU,YAAY;IAC1B,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAA;IACxC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAU,EAAE,CAAC,CAAA;IACjD,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAE3C,SAAS,IAAI;QACX,KAAK,MAAM,CAAqB,kBAAkB,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;YAC/D,IAAI,GAAG,CAAC,KAAK,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;gBACnC,SAAS,CAAC,IAAI,CAAC,CAAA;gBACf,OAAM;YACR,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAC5B,CAAC,CAAC,CAAA;QACF,KAAK,MAAM,CAAU,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE;YACrD,IAAI,GAAG,CAAC,IAAI;gBAAE,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAA;QACvE,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,EAAE,CAAA;IACR,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,KAAK,UAAU,MAAM,CAAC,EAAU;QAC9B,MAAM,GAAG,GAAG,MAAM,MAAM,CAAmB,qBAAqB,kBAAkB,CAAC,EAAE,CAAC,EAAE,EAAE;YACxF,MAAM,EAAE,QAAQ;SACjB,CAAC,CAAA;QACF,IAAI,GAAG,CAAC,KAAK,EAAE,CAAC;YACd,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;YACtB,OAAM;QACR,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,IAAI,cAAc,CAAC,CAAA;QAC/C,IAAI,EAAE,CAAA;IACR,CAAC;IAED,IAAI,MAAM;QAAE,OAAO,IAAI,CAAA;IAEvB,OAAO,CACL,MAAC,YAAY,IACX,KAAK,EAAC,YAAY,EAClB,WAAW,EAAC,2IAA2I,aAEvJ,eAAK,SAAS,EAAC,mCAAmC,aAChD,eAAM,SAAS,EAAC,oDAAoD,YAAE,MAAM,IAAI,GAAG,GAAQ,EAC3F,kBACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,8HAA8H,EACxI,OAAO,EAAE,GAAG,EAAE;4BACZ,IAAI,CAAC,MAAM;gCAAE,OAAM;4BACnB,KAAK,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,MAAM,CAAC,CAAA;4BAC1C,KAAK,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;wBACjC,CAAC,gBACU,cAAc,aAEzB,KAAC,IAAI,IAAC,IAAI,EAAE,EAAE,iBAAc,MAAM,GAAG,YAE9B,IACL,EACL,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,CACpB,aAAI,SAAS,EAAC,gBAAgB,YAC3B,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CACrB,cAAmB,SAAS,EAAC,iDAAiD,aAC5E,eAAM,SAAS,EAAC,kCAAkC,YAAE,KAAK,CAAC,QAAQ,GAAQ,EAC1E,iBACE,IAAI,EAAC,QAAQ,EACb,SAAS,EAAC,uDAAuD,EACjE,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,uBAG7B,KARF,KAAK,CAAC,EAAE,CASZ,CACN,CAAC,GACC,CACN,EACD,YAAG,SAAS,EAAC,oCAAoC,6HAG7C,IACS,CAChB,CAAA;AACH,CAAC"}
|