strapi-plugin-mcp-chat 0.1.0 → 0.3.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/README.md +60 -4
- package/admin/src/components/AdminOverlays.tsx +35 -1
- package/admin/src/components/FloatingChat.tsx +14 -0
- package/admin/src/components/LangSwitcher.tsx +24 -0
- package/admin/src/components/Onboarding.tsx +192 -0
- package/admin/src/components/PreviewPanel.tsx +22 -1
- package/admin/src/components/StackLogos.tsx +60 -0
- package/admin/src/i18n.ts +214 -0
- package/admin/src/pages/HomePage.tsx +55 -24
- package/admin/src/pages/ProvisionPage.tsx +54 -59
- package/dist/server/index.js +60 -16
- package/package.json +1 -1
- package/server/src/content-tools.ts +26 -4
- package/server/src/controllers/chat.ts +2 -2
- package/server/src/provision/integrate.ts +15 -1
- package/server/src/services/chat.ts +28 -8
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/logo.png" alt="MCP Chat" width="120" height="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">strapi-plugin-mcp-chat</h1>
|
|
2
6
|
|
|
3
7
|
> AI chat inside the **Strapi 5** admin that actually **reads and edits your content** — including fields nested in **components and dynamic zones** — through **MCP**. Comes with **voice** (speech-to-text / text-to-speech) and a **side-by-side live preview** of your frontend.
|
|
4
8
|
|
|
5
9
|
Ask in plain language ("change the homepage hero title to …") and the assistant finds the text across all content-types, edits it, and publishes — then reloads the preview on the very page you were looking at.
|
|
6
10
|
|
|
11
|
+
[](https://www.npmjs.com/package/strapi-plugin-mcp-chat)
|
|
12
|
+
[](./LICENSE)
|
|
13
|
+
|
|
7
14
|
https://github.com/raulbalestra/strapi-plugin-mcp-chat
|
|
8
15
|
|
|
9
16
|
---
|
|
@@ -12,12 +19,36 @@ https://github.com/raulbalestra/strapi-plugin-mcp-chat
|
|
|
12
19
|
|
|
13
20
|
- 🤖 **AI chat in the admin** — a floating widget on every screen + a full-page view. Runs an agent loop on the OpenAI Chat Completions API.
|
|
14
21
|
- ✏️ **Edits real content via MCP + Document Service** — `buscar_texto` finds a phrase across **all** content-types, single types, **components and dynamic zones** (recursive); `editar_campo` updates it (preserving the other components); `publicar` publishes.
|
|
22
|
+
- 📝 **Draft-first by default** — the AI edits **drafts** and does **not** publish unless you ask (or flip the **Auto-publish** toggle). Pair it with the preview's **Draft / Live** switch to review unpublished changes before they go to production. See [Draft-first editing & previewing drafts](#draft-first-editing--previewing-drafts).
|
|
15
23
|
- 🎙️ **Voice** — record a request (Whisper STT) and hear replies (OpenAI TTS).
|
|
16
24
|
- 👁️ **Side-by-side preview panel** — the plugin's own docked iframe that shrinks the admin and shows your frontend; reloads after each edit and **stays on the same page + scroll position** (with the optional preview bridge below). This is a custom panel, *not* Strapi's official Live Preview (which is a Growth/Enterprise feature) — it works on any plan, including Community, and complements the official Preview if you have it configured.
|
|
17
25
|
- 🖥️ **Optional browser control** — if a [Playwright MCP](https://github.com/microsoft/playwright-mcp) server is reachable, the agent can drive a real browser to verify changes.
|
|
18
26
|
- 🧱 **Frontend provisioning** — upload your frontend (Next.js or TanStack Start) with a `strapi.manifest.json`; the plugin validates it, creates all content-types, seeds content and wires the preview. Never runs code from the upload — it acts only on the validated manifest.
|
|
19
27
|
- 🌍 **Translate every page to any language** — create locales and translate all localized content via Strapi 5's native i18n, with **no length limits and no context blowups** (see below).
|
|
20
|
-
- 🌐
|
|
28
|
+
- 🌐 **Fully bilingual (PT / EN)** — both the AI prompts/voice *and* the plugin's own admin pages, switchable with one click (the choice is shared across the chat and the menu pages).
|
|
29
|
+
- 🎓 **Built-in onboarding tour** — a first-run mini-course (re-openable any time via **❓ Tour**) walks new users through chat, editing, live preview, provisioning and translation.
|
|
30
|
+
|
|
31
|
+
## What makes MCP Chat unique
|
|
32
|
+
|
|
33
|
+
MCP Chat is built on Strapi 5's **native MCP server** and focuses on letting an assistant
|
|
34
|
+
**operate your content end-to-end** — find text anywhere (including fields nested in
|
|
35
|
+
components and dynamic zones), edit it as a draft, publish it, and translate it — while you
|
|
36
|
+
watch the result in a **live preview right next to the editor**.
|
|
37
|
+
|
|
38
|
+
A few things that set it apart:
|
|
39
|
+
|
|
40
|
+
- 👁️ **Docked, side-by-side live preview** that follows the exact page you're editing and
|
|
41
|
+
can show **drafts** before they're published — on any plan, including Community.
|
|
42
|
+
- 🧱 **Frontend provisioning** from a `strapi.manifest.json` (Next.js / TanStack Start): the
|
|
43
|
+
plugin infers the content model, creates the content-types, seeds the data and wires the
|
|
44
|
+
preview for you.
|
|
45
|
+
- 📝 **Draft-first & safe**: edits go to drafts and nothing is published until you say so.
|
|
46
|
+
- ✏️ **Operates content in place**: finds and edits text nested in components and dynamic
|
|
47
|
+
zones, then publishes — not just generating text into a single field.
|
|
48
|
+
- 🎙️ **Voice** (speech-to-text + text-to-speech) and a **bilingual** UI/prompts (PT / EN).
|
|
49
|
+
|
|
50
|
+
It's a complement to the rest of Strapi's AI ecosystem, not a replacement — reach for it
|
|
51
|
+
when you want the "chat that edits your content with a live preview" workflow.
|
|
21
52
|
|
|
22
53
|
## Requirements
|
|
23
54
|
|
|
@@ -27,8 +58,9 @@ https://github.com/raulbalestra/strapi-plugin-mcp-chat
|
|
|
27
58
|
## Install
|
|
28
59
|
|
|
29
60
|
```bash
|
|
30
|
-
|
|
31
|
-
|
|
61
|
+
npm install strapi-plugin-mcp-chat
|
|
62
|
+
# or pull the latest unreleased code straight from GitHub:
|
|
63
|
+
# npm install github:raulbalestra/strapi-plugin-mcp-chat
|
|
32
64
|
```
|
|
33
65
|
|
|
34
66
|
> Or just try the ready-to-run [Launchpad demo](https://github.com/raulbalestra/launchpad-mcp-chat) (the plugin is vendored there).
|
|
@@ -161,6 +193,30 @@ export function PreviewBridge() {
|
|
|
161
193
|
Then render `<PreviewBridge />` once in your root layout. The same idea works in any
|
|
162
194
|
framework — just post `{ type: 'preview:location', href: location.href }` to `window.parent`.
|
|
163
195
|
|
|
196
|
+
## Draft-first editing & previewing drafts
|
|
197
|
+
|
|
198
|
+
Strapi's Draft & Publish lets editors stage changes before they go live. MCP Chat
|
|
199
|
+
respects that:
|
|
200
|
+
|
|
201
|
+
- **The AI edits drafts, never auto-publishes.** `editar_campo` always writes the
|
|
202
|
+
**draft** version. By default the agent stops there and tells you it saved a draft —
|
|
203
|
+
it only calls `publicar` when you explicitly ask ("publish this", "make it live") or
|
|
204
|
+
when you turn **Auto-publish ON** in the chat toolbar. The setting is remembered
|
|
205
|
+
(`localStorage` `mcp-chat-autopublish`, default off).
|
|
206
|
+
- **Content-types without Draft & Publish are handled correctly.** Draft & Publish is per content-type and off by default; `publish()` only exists when it's enabled (calling it otherwise throws, per the Strapi 5 docs). When a type has no Draft & Publish, the edit *is* the live value — so the plugin skips publishing (no error) and the assistant tells you the change is already live.
|
|
207
|
+
- **Preview the draft before publishing.** The preview panel has a **📝 Draft / 🌐 Live**
|
|
208
|
+
toggle. In **Draft** it reloads the frontend with `?preview=1`; provisioned frontends
|
|
209
|
+
read that flag and fetch `status=draft` from the Content API, so you see unpublished
|
|
210
|
+
changes exactly as they'll look — without publishing. Flip to **Live** to compare with
|
|
211
|
+
what's currently public.
|
|
212
|
+
|
|
213
|
+
**Draft preview contract (for custom / non-provisioned frontends):** when the iframe URL
|
|
214
|
+
carries `?preview=1` (or `?status=draft`), fetch Strapi with `status: 'draft'` and an API
|
|
215
|
+
token that can read drafts (`STRAPI_API_TOKEN`). Provisioned frontends get this wired
|
|
216
|
+
automatically via the generated `strapi-client` data module. Note: draft fetching keys off
|
|
217
|
+
the URL on the **client**, so with SSR the first server render may show published content
|
|
218
|
+
until hydration — fine for preview, but don't rely on it for production rendering.
|
|
219
|
+
|
|
164
220
|
## How it works
|
|
165
221
|
|
|
166
222
|
```
|
|
@@ -22,6 +22,7 @@ import { PreviewPanel } from './PreviewPanel';
|
|
|
22
22
|
// edite na barra do painel — o valor fica salvo em localStorage.
|
|
23
23
|
const FALLBACK_PREVIEW_URL = 'http://localhost:3000';
|
|
24
24
|
const LS_KEY = 'mcp-chat-preview-url';
|
|
25
|
+
const LS_DRAFT = 'mcp-chat-preview-draft';
|
|
25
26
|
|
|
26
27
|
const initialPreviewUrl = (): string => {
|
|
27
28
|
try {
|
|
@@ -31,6 +32,28 @@ const initialPreviewUrl = (): string => {
|
|
|
31
32
|
}
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
const initialDraft = (): boolean => {
|
|
36
|
+
try {
|
|
37
|
+
return localStorage.getItem(LS_DRAFT) === '1';
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Aplica/remove o flag `?preview=1` na URL do iframe. Em modo rascunho, o
|
|
44
|
+
* frontend provisionado (e qualquer frontend que respeite o contrato de
|
|
45
|
+
* preview) busca `status=draft` no Strapi em vez do conteúdo publicado. */
|
|
46
|
+
const withPreviewFlag = (url: string, draft: boolean): string => {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
if (draft) u.searchParams.set('preview', '1');
|
|
50
|
+
else u.searchParams.delete('preview');
|
|
51
|
+
return u.toString();
|
|
52
|
+
} catch {
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
34
57
|
const originOf = (u: string): string | null => {
|
|
35
58
|
try {
|
|
36
59
|
return new URL(u).origin;
|
|
@@ -48,6 +71,8 @@ export const AdminOverlays = () => {
|
|
|
48
71
|
const liveRef = useRef(initialPreviewUrl());
|
|
49
72
|
const srcRef = useRef(initialPreviewUrl());
|
|
50
73
|
const [iframeKey, setIframeKey] = useState(0);
|
|
74
|
+
// Modo rascunho do preview (mostra conteúdo não publicado).
|
|
75
|
+
const [draftPreview, setDraftPreview] = useState(initialDraft);
|
|
51
76
|
|
|
52
77
|
// Auto-run do frontend provisionado SEMPRE que o preview é ligado.
|
|
53
78
|
const [runLoading, setRunLoading] = useState(false);
|
|
@@ -150,7 +175,7 @@ export const AdminOverlays = () => {
|
|
|
150
175
|
<>
|
|
151
176
|
<PreviewPanel
|
|
152
177
|
open={previewOn}
|
|
153
|
-
src={previewSrc}
|
|
178
|
+
src={withPreviewFlag(previewSrc, draftPreview)}
|
|
154
179
|
displayUrl={liveHref}
|
|
155
180
|
onUrl={navigate}
|
|
156
181
|
iframeKey={iframeKey}
|
|
@@ -159,6 +184,15 @@ export const AdminOverlays = () => {
|
|
|
159
184
|
loading={runLoading}
|
|
160
185
|
loadingText={runText}
|
|
161
186
|
loadingError={runError}
|
|
187
|
+
draft={draftPreview}
|
|
188
|
+
onToggleDraft={() => {
|
|
189
|
+
setDraftPreview((v) => {
|
|
190
|
+
const next = !v;
|
|
191
|
+
try { localStorage.setItem(LS_DRAFT, next ? '1' : '0'); } catch { /* noop */ }
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
setIframeKey((k) => k + 1); // recarrega o iframe com/sem o flag
|
|
195
|
+
}}
|
|
162
196
|
/>
|
|
163
197
|
<FloatingChat
|
|
164
198
|
previewOn={previewOn}
|
|
@@ -28,6 +28,7 @@ const STR: Record<Lang, Record<string, string>> = {
|
|
|
28
28
|
rec: '🎤 Enviar áudio', recStop: '⏹ Parar áudio', recTitle: 'Gravar áudio e enviar (transcreve e manda)',
|
|
29
29
|
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Abrir/fechar o preview do site ao lado da Strapi',
|
|
30
30
|
voiceOn: '🔊 Voz: ON', voiceOff: '🔈 Voz: OFF', voiceTitle: 'Ler as respostas em voz alta (TTS)',
|
|
31
|
+
pubOn: '🚀 Publicar: ON', pubOff: '📝 Rascunho', pubTitle: 'Auto-publicar OFF = a IA só salva rascunho (você revisa e publica). ON = publica direto no site.',
|
|
31
32
|
shareOn: '🛑 Parar tela', shareOff: '🖥 Compart. tela', shareTitle: 'Compartilhar a tela com a IA',
|
|
32
33
|
langTitle: 'Idioma do chat e da voz (PT-BR ↔ English)',
|
|
33
34
|
seeingScreen: '• vendo sua tela ', voiceStatus: '• voz ON',
|
|
@@ -44,6 +45,7 @@ const STR: Record<Lang, Record<string, string>> = {
|
|
|
44
45
|
rec: '🎤 Send audio', recStop: '⏹ Stop audio', recTitle: 'Record audio and send (transcribes and sends)',
|
|
45
46
|
previewOn: '🖼 Preview: ON', previewOff: '🖼 Preview: OFF', previewTitle: 'Toggle the site preview next to Strapi',
|
|
46
47
|
voiceOn: '🔊 Voice: ON', voiceOff: '🔈 Voice: OFF', voiceTitle: 'Read replies out loud (TTS)',
|
|
48
|
+
pubOn: '🚀 Publish: ON', pubOff: '📝 Draft', pubTitle: 'Auto-publish OFF = the AI only saves a draft (you review and publish). ON = publishes straight to the site.',
|
|
47
49
|
shareOn: '🛑 Stop screen', shareOff: '🖥 Share screen', shareTitle: 'Share your screen with the AI',
|
|
48
50
|
langTitle: 'Chat and voice language (PT-BR ↔ English)',
|
|
49
51
|
seeingScreen: '• seeing your screen ', voiceStatus: '• voice ON',
|
|
@@ -66,6 +68,12 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
66
68
|
const [sharing, setSharing] = useState(false);
|
|
67
69
|
const [voiceOn, setVoiceOn] = useState(false);
|
|
68
70
|
const [recording, setRecording] = useState(false);
|
|
71
|
+
const [autoPublish, setAutoPublish] = useState<boolean>(() => {
|
|
72
|
+
try { return localStorage.getItem('mcp-chat-autopublish') === '1'; } catch { return false; }
|
|
73
|
+
});
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
try { localStorage.setItem('mcp-chat-autopublish', autoPublish ? '1' : '0'); } catch { /* noop */ }
|
|
76
|
+
}, [autoPublish]);
|
|
69
77
|
const [lang, setLang] = useState<Lang>(() => {
|
|
70
78
|
try { return (localStorage.getItem('mcp-chat-lang') as Lang) || 'en'; } catch { return 'en'; }
|
|
71
79
|
});
|
|
@@ -169,6 +177,8 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
169
177
|
// Página que o usuário está olhando no preview (se aberto). Dá à IA o
|
|
170
178
|
// contexto do "isso aqui" sem precisar varrer o site inteiro.
|
|
171
179
|
previewUrl: previewOn ? previewUrl : null,
|
|
180
|
+
// Draft-first: por padrão a IA só salva rascunho; só publica com isto ON.
|
|
181
|
+
autoPublish,
|
|
172
182
|
});
|
|
173
183
|
const reply = data?.reply || '(sem resposta)';
|
|
174
184
|
setMessages((cur) => [...cur, { role: 'assistant', content: reply }]);
|
|
@@ -319,6 +329,10 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
|
|
|
319
329
|
title={t.voiceTitle}>
|
|
320
330
|
{voiceOn ? t.voiceOn : t.voiceOff}
|
|
321
331
|
</button>
|
|
332
|
+
<button style={btn(autoPublish)} onClick={() => setAutoPublish((v) => !v)}
|
|
333
|
+
title={t.pubTitle}>
|
|
334
|
+
{autoPublish ? t.pubOn : t.pubOff}
|
|
335
|
+
</button>
|
|
322
336
|
<button style={btn(sharing)} onClick={sharing ? stopShare : startShare}
|
|
323
337
|
title={t.shareTitle}>
|
|
324
338
|
{sharing ? t.shareOn : t.shareOff}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Botão de idioma das páginas do plugin (PT-BR ↔ English).
|
|
3
|
+
* Alterna a mesma chave `mcp-chat-lang` usada pelo chat flutuante.
|
|
4
|
+
*/
|
|
5
|
+
import { useLang } from '../i18n';
|
|
6
|
+
|
|
7
|
+
export const LangSwitcher = ({ size = 'M' as 'S' | 'M' }) => {
|
|
8
|
+
const [lang, setLang] = useLang();
|
|
9
|
+
const pad = size === 'S' ? '2px 8px' : '4px 10px';
|
|
10
|
+
return (
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={() => setLang(lang === 'pt' ? 'en' : 'pt')}
|
|
14
|
+
title="Idioma do plugin / Plugin language (PT-BR ↔ English)"
|
|
15
|
+
style={{
|
|
16
|
+
border: '1px solid #dcdce4', background: '#fff', color: '#32324d',
|
|
17
|
+
borderRadius: 6, padding: pad, cursor: 'pointer', fontSize: 12,
|
|
18
|
+
whiteSpace: 'nowrap', fontWeight: 600,
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
{lang === 'pt' ? '🌐 PT-BR' : '🌐 English'}
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mini-curso de onboarding: um carrossel de passos mostrado na PRIMEIRA vez que o
|
|
3
|
+
* usuário abre o plugin (flag em localStorage), e reabrível pelo botão "❓ Tour".
|
|
4
|
+
*
|
|
5
|
+
* Cada passo tem uma ilustração (SVG placeholder) + título + descrição, em PT/EN.
|
|
6
|
+
* Para usar screenshots reais, troque o componente `art` do passo por
|
|
7
|
+
* `<img src={...} />` (ou aponte para arquivos em admin/src/assets).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
import type { Lang } from '../i18n';
|
|
11
|
+
|
|
12
|
+
const LS_DONE = 'mcp-chat-tour-done';
|
|
13
|
+
|
|
14
|
+
export const tourWasSeen = (): boolean => {
|
|
15
|
+
try { return localStorage.getItem(LS_DONE) === '1'; } catch { return false; }
|
|
16
|
+
};
|
|
17
|
+
const markSeen = () => { try { localStorage.setItem(LS_DONE, '1'); } catch { /* noop */ } };
|
|
18
|
+
|
|
19
|
+
// ── Ilustrações (placeholders SVG; substitua por screenshots quando tiver) ──────
|
|
20
|
+
const Frame = ({ children }: { children: React.ReactNode }) => (
|
|
21
|
+
<svg viewBox="0 0 320 180" width="100%" style={{ display: 'block' }} xmlns="http://www.w3.org/2000/svg">
|
|
22
|
+
<rect x="0" y="0" width="320" height="180" rx="12" fill="#f0f0ff" />
|
|
23
|
+
<rect x="0" y="0" width="320" height="26" rx="12" fill="#181826" />
|
|
24
|
+
<circle cx="14" cy="13" r="4" fill="#ff5f57" />
|
|
25
|
+
<circle cx="30" cy="13" r="4" fill="#febc2e" />
|
|
26
|
+
<circle cx="46" cy="13" r="4" fill="#28c840" />
|
|
27
|
+
{children}
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
const artWelcome = (
|
|
31
|
+
<Frame>
|
|
32
|
+
<circle cx="160" cy="100" r="40" fill="#4945ff" />
|
|
33
|
+
<path d="M140 92h40a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6h-22l-12 10v-10h-6a6 6 0 0 1-6-6V98a6 6 0 0 1 6-6Z" fill="#fff" />
|
|
34
|
+
<circle cx="150" cy="109" r="3" fill="#4945ff" /><circle cx="160" cy="109" r="3" fill="#4945ff" /><circle cx="170" cy="109" r="3" fill="#4945ff" />
|
|
35
|
+
</Frame>
|
|
36
|
+
);
|
|
37
|
+
const artChat = (
|
|
38
|
+
<Frame>
|
|
39
|
+
<rect x="30" y="44" width="180" height="18" rx="9" fill="#dcd9ff" />
|
|
40
|
+
<rect x="110" y="74" width="180" height="18" rx="9" fill="#4945ff" />
|
|
41
|
+
<rect x="30" y="104" width="140" height="18" rx="9" fill="#dcd9ff" />
|
|
42
|
+
<circle cx="270" cy="140" r="16" fill="#4945ff" /><text x="270" y="146" fontSize="16" textAnchor="middle" fill="#fff">🎤</text>
|
|
43
|
+
</Frame>
|
|
44
|
+
);
|
|
45
|
+
const artEdit = (
|
|
46
|
+
<Frame>
|
|
47
|
+
<rect x="30" y="50" width="120" height="80" rx="8" fill="#fff" stroke="#dcdce4" />
|
|
48
|
+
<rect x="42" y="64" width="80" height="10" rx="5" fill="#c0bfff" />
|
|
49
|
+
<rect x="42" y="84" width="96" height="8" rx="4" fill="#e6e6f0" />
|
|
50
|
+
<rect x="42" y="100" width="70" height="8" rx="4" fill="#e6e6f0" />
|
|
51
|
+
<path d="M170 90h70m0 0-14-12m14 12-14 12" stroke="#4945ff" strokeWidth="4" fill="none" strokeLinecap="round" />
|
|
52
|
+
<rect x="250" y="58" width="46" height="64" rx="6" fill="#28c840" opacity="0.18" />
|
|
53
|
+
<text x="273" y="96" fontSize="22" textAnchor="middle">✓</text>
|
|
54
|
+
</Frame>
|
|
55
|
+
);
|
|
56
|
+
const artPreview = (
|
|
57
|
+
<Frame>
|
|
58
|
+
<rect x="24" y="40" width="130" height="120" rx="8" fill="#fff" stroke="#dcdce4" />
|
|
59
|
+
<rect x="166" y="40" width="130" height="120" rx="8" fill="#fff" stroke="#dcdce4" />
|
|
60
|
+
<rect x="36" y="54" width="100" height="10" rx="5" fill="#c0bfff" />
|
|
61
|
+
<rect x="36" y="74" width="80" height="8" rx="4" fill="#e6e6f0" />
|
|
62
|
+
<rect x="178" y="54" width="106" height="40" rx="6" fill="#6bdaff" opacity="0.5" />
|
|
63
|
+
<rect x="178" y="104" width="80" height="8" rx="4" fill="#e6e6f0" />
|
|
64
|
+
<text x="158" y="120" fontSize="16" textAnchor="middle">👁️</text>
|
|
65
|
+
</Frame>
|
|
66
|
+
);
|
|
67
|
+
const artProvision = (
|
|
68
|
+
<Frame>
|
|
69
|
+
<rect x="40" y="60" width="60" height="60" rx="10" fill="#000" /><text x="70" y="98" fontSize="26" textAnchor="middle" fill="#fff">N</text>
|
|
70
|
+
<circle cx="160" cy="90" r="30" fill="#f9ffb5" stroke="#0b1722" strokeWidth="2" />
|
|
71
|
+
<path d="M110 90h20m100 0h-20" stroke="#4945ff" strokeWidth="4" strokeLinecap="round" />
|
|
72
|
+
<rect x="220" y="60" width="60" height="60" rx="10" fill="#4945ff" /><text x="250" y="98" fontSize="22" textAnchor="middle" fill="#fff">DB</text>
|
|
73
|
+
</Frame>
|
|
74
|
+
);
|
|
75
|
+
const artTranslate = (
|
|
76
|
+
<Frame>
|
|
77
|
+
<text x="80" y="105" fontSize="34" textAnchor="middle">🇧🇷</text>
|
|
78
|
+
<path d="M120 90h80m0 0-14-12m14 12-14 12" stroke="#4945ff" strokeWidth="4" fill="none" strokeLinecap="round" />
|
|
79
|
+
<text x="240" y="105" fontSize="34" textAnchor="middle">🌍</text>
|
|
80
|
+
</Frame>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
type Step = { title: string; body: string; art: React.ReactNode };
|
|
84
|
+
|
|
85
|
+
const STEPS: Record<Lang, Step[]> = {
|
|
86
|
+
en: [
|
|
87
|
+
{ title: 'Welcome to MCP Chat', body: 'An AI assistant inside your Strapi admin that actually reads, edits and publishes your content via the native MCP server. This quick tour shows what it can do.', art: artWelcome },
|
|
88
|
+
{ title: 'Just ask, in plain language', body: 'Type, talk (🎤 voice) or share your screen. Example: “Change the homepage hero title to Welcome”. No need to find the field yourself — the AI searches everything.', art: artChat },
|
|
89
|
+
{ title: 'It edits & publishes for real', body: 'The assistant finds the text across content-types, components and dynamic zones, updates it and publishes — then confirms what changed.', art: artEdit },
|
|
90
|
+
{ title: 'Side-by-side live preview', body: 'Toggle Live Preview to see your frontend next to the admin. After each edit it reloads on the same page you were viewing.', art: artPreview },
|
|
91
|
+
{ title: 'Provision a frontend', body: 'Upload a Next.js or TanStack Start .zip — the AI infers the content model, you review it, and the plugin creates the content-types and seeds the data.', art: artProvision },
|
|
92
|
+
{ title: 'Translate the whole site', body: 'Ask “translate the whole site to French” and it creates the locales and translates every localized field via Strapi’s native i18n.', art: artTranslate },
|
|
93
|
+
],
|
|
94
|
+
pt: [
|
|
95
|
+
{ title: 'Bem-vindo ao MCP Chat', body: 'Um assistente de IA dentro do admin do Strapi que realmente lê, edita e publica seu conteúdo via o MCP nativo. Este tour rápido mostra o que ele faz.', art: artWelcome },
|
|
96
|
+
{ title: 'É só pedir, em linguagem natural', body: 'Escreva, fale (🎤 voz) ou compartilhe a tela. Ex.: “Troque o título do hero da home para Bem-vindo”. Não precisa achar o campo — a IA busca em tudo.', art: artChat },
|
|
97
|
+
{ title: 'Ele edita e publica de verdade', body: 'O assistente acha o texto em content-types, componentes e dynamic zones, atualiza e publica — e confirma o que mudou.', art: artEdit },
|
|
98
|
+
{ title: 'Preview ao vivo lado a lado', body: 'Ligue o Live Preview para ver seu frontend ao lado do admin. Após cada edição ele recarrega na mesma página que você estava vendo.', art: artPreview },
|
|
99
|
+
{ title: 'Provisione um frontend', body: 'Suba um .zip Next.js ou TanStack Start — a IA infere o modelo de conteúdo, você revisa, e o plugin cria as content-types e semeia os dados.', art: artProvision },
|
|
100
|
+
{ title: 'Traduza o site inteiro', body: 'Peça “traduza o site todo para francês” e ele cria os locales e traduz cada campo localizado via o i18n nativo do Strapi.', art: artTranslate },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const L = {
|
|
105
|
+
en: { skip: 'Skip', back: 'Back', next: 'Next', done: 'Got it!', step: 'Step' },
|
|
106
|
+
pt: { skip: 'Pular', back: 'Voltar', next: 'Próximo', done: 'Entendi!', step: 'Passo' },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const Onboarding = ({ lang, open, onClose }: { lang: Lang; open: boolean; onClose: () => void }) => {
|
|
110
|
+
const [i, setI] = useState(0);
|
|
111
|
+
const steps = STEPS[lang];
|
|
112
|
+
const t = L[lang];
|
|
113
|
+
|
|
114
|
+
useEffect(() => { if (open) setI(0); }, [open]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const onKey = (e: KeyboardEvent) => {
|
|
117
|
+
if (!open) return;
|
|
118
|
+
if (e.key === 'Escape') finish();
|
|
119
|
+
if (e.key === 'ArrowRight') setI((v) => Math.min(steps.length - 1, v + 1));
|
|
120
|
+
if (e.key === 'ArrowLeft') setI((v) => Math.max(0, v - 1));
|
|
121
|
+
};
|
|
122
|
+
window.addEventListener('keydown', onKey);
|
|
123
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
124
|
+
}, [open, steps.length]);
|
|
125
|
+
|
|
126
|
+
if (!open) return null;
|
|
127
|
+
const finish = () => { markSeen(); onClose(); };
|
|
128
|
+
const last = i === steps.length - 1;
|
|
129
|
+
const s = steps[i];
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
onClick={finish}
|
|
134
|
+
style={{
|
|
135
|
+
position: 'fixed', inset: 0, zIndex: 2147483600,
|
|
136
|
+
background: 'rgba(10,10,25,0.55)', display: 'flex',
|
|
137
|
+
alignItems: 'center', justifyContent: 'center', padding: 16,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
onClick={(e) => e.stopPropagation()}
|
|
142
|
+
style={{
|
|
143
|
+
width: 460, maxWidth: '100%', background: '#fff', borderRadius: 12,
|
|
144
|
+
boxShadow: '0 20px 60px rgba(0,0,0,0.35)', overflow: 'hidden',
|
|
145
|
+
fontFamily: 'inherit',
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div style={{ padding: 16 }}>{s.art}</div>
|
|
149
|
+
<div style={{ padding: '0 20px 8px' }}>
|
|
150
|
+
<h2 style={{ margin: '4px 0 8px', fontSize: 18, color: '#181826' }}>{s.title}</h2>
|
|
151
|
+
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.5, color: '#4a4a6a', minHeight: 64 }}>{s.body}</p>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* dots (hover para pular pra um passo) */}
|
|
155
|
+
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', padding: '8px 0 4px' }}>
|
|
156
|
+
{steps.map((st, idx) => (
|
|
157
|
+
<button
|
|
158
|
+
key={idx}
|
|
159
|
+
onClick={() => setI(idx)}
|
|
160
|
+
title={`${t.step} ${idx + 1}: ${st.title}`}
|
|
161
|
+
style={{
|
|
162
|
+
width: idx === i ? 22 : 8, height: 8, borderRadius: 4, border: 'none',
|
|
163
|
+
background: idx === i ? '#4945ff' : '#d9d9e8', cursor: 'pointer',
|
|
164
|
+
transition: 'width .2s, background .2s',
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: 16, borderTop: '1px solid #eee' }}>
|
|
171
|
+
<button onClick={finish} style={{ background: 'none', border: 'none', color: '#8e8ea9', cursor: 'pointer', fontSize: 13 }}>
|
|
172
|
+
{t.skip}
|
|
173
|
+
</button>
|
|
174
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
175
|
+
{i > 0 && (
|
|
176
|
+
<button onClick={() => setI((v) => v - 1)}
|
|
177
|
+
style={{ border: '1px solid #dcdce4', background: '#fff', color: '#32324d', borderRadius: 6, padding: '6px 14px', cursor: 'pointer', fontSize: 13 }}>
|
|
178
|
+
{t.back}
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => (last ? finish() : setI((v) => v + 1))}
|
|
183
|
+
style={{ border: 'none', background: '#4945ff', color: '#fff', borderRadius: 6, padding: '6px 16px', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}
|
|
184
|
+
>
|
|
185
|
+
{last ? t.done : t.next}
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
@@ -27,12 +27,15 @@ type Props = {
|
|
|
27
27
|
loading?: boolean;
|
|
28
28
|
loadingText?: string;
|
|
29
29
|
loadingError?: boolean;
|
|
30
|
+
/** modo rascunho: mostra conteúdo não publicado (draft) em vez do publicado. */
|
|
31
|
+
draft?: boolean;
|
|
32
|
+
onToggleDraft?: () => void;
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
const MIN_W = 320;
|
|
33
36
|
const clampW = (w: number) => Math.max(MIN_W, Math.min(window.innerWidth - 360, w));
|
|
34
37
|
|
|
35
|
-
export const PreviewPanel = ({ open, src, displayUrl, onUrl, iframeKey, onReload, onClose, loading, loadingText, loadingError }: Props) => {
|
|
38
|
+
export const PreviewPanel = ({ open, src, displayUrl, onUrl, iframeKey, onReload, onClose, loading, loadingText, loadingError, draft, onToggleDraft }: Props) => {
|
|
36
39
|
const [width, setWidth] = useState(() => Math.round(window.innerWidth * 0.42));
|
|
37
40
|
const [draftUrl, setDraftUrl] = useState(displayUrl);
|
|
38
41
|
const [resizing, setResizing] = useState(false);
|
|
@@ -132,6 +135,24 @@ export const PreviewPanel = ({ open, src, displayUrl, onUrl, iframeKey, onReload
|
|
|
132
135
|
border: '1px solid #4a4a6a', background: '#0f0f1a', color: '#fff',
|
|
133
136
|
}}
|
|
134
137
|
/>
|
|
138
|
+
{onToggleDraft && (
|
|
139
|
+
<button
|
|
140
|
+
onClick={onToggleDraft}
|
|
141
|
+
title={
|
|
142
|
+
draft
|
|
143
|
+
? 'Mostrando RASCUNHO (conteúdo não publicado). Clique para ver o publicado (Live).'
|
|
144
|
+
: 'Mostrando publicado (Live). Clique para ver o RASCUNHO (draft).'
|
|
145
|
+
}
|
|
146
|
+
style={{
|
|
147
|
+
...hdrBtn,
|
|
148
|
+
background: draft ? '#8c4bff' : '#2a2a45',
|
|
149
|
+
borderColor: draft ? '#8c4bff' : '#4a4a6a',
|
|
150
|
+
whiteSpace: 'nowrap',
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{draft ? '📝 Draft' : '🌐 Live'}
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
135
156
|
<button onClick={onReload} title="Recarregar" style={hdrBtn}>↻</button>
|
|
136
157
|
<button onClick={onClose} title="Fechar" style={hdrBtn}>✕</button>
|
|
137
158
|
</div>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logos dos stacks de frontend suportados pela provisão (Next.js + TanStack Start).
|
|
3
|
+
* SVG inline (sem assets externos) para não depender do bundler/CSP.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const NextLogo = ({ size = 28 }: { size?: number }) => (
|
|
7
|
+
<svg width={size} height={size} viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Next.js">
|
|
8
|
+
<mask id="nextmask" style={{ maskType: 'alpha' }} maskUnits="userSpaceOnUse" x="0" y="0" width="180" height="180">
|
|
9
|
+
<circle cx="90" cy="90" r="90" fill="black" />
|
|
10
|
+
</mask>
|
|
11
|
+
<g mask="url(#nextmask)">
|
|
12
|
+
<circle cx="90" cy="90" r="90" fill="black" />
|
|
13
|
+
<path d="M149.508 157.52 69.142 54H54v71.97h12.114V69.384l73.885 95.461a90.304 90.304 0 0 0 9.509-7.325Z" fill="url(#nextfill0)" />
|
|
14
|
+
<rect x="115" y="54" width="12" height="72" fill="url(#nextfill1)" />
|
|
15
|
+
</g>
|
|
16
|
+
<defs>
|
|
17
|
+
<linearGradient id="nextfill0" x1="109" y1="116.5" x2="144.5" y2="160.5" gradientUnits="userSpaceOnUse">
|
|
18
|
+
<stop stopColor="white" />
|
|
19
|
+
<stop offset="1" stopColor="white" stopOpacity="0" />
|
|
20
|
+
</linearGradient>
|
|
21
|
+
<linearGradient id="nextfill1" x1="121" y1="54" x2="120.799" y2="106.875" gradientUnits="userSpaceOnUse">
|
|
22
|
+
<stop stopColor="white" />
|
|
23
|
+
<stop offset="1" stopColor="white" stopOpacity="0" />
|
|
24
|
+
</linearGradient>
|
|
25
|
+
</defs>
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const TanStackLogo = ({ size = 28 }: { size?: number }) => (
|
|
30
|
+
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="TanStack">
|
|
31
|
+
<defs>
|
|
32
|
+
<linearGradient id="tsgrad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
|
33
|
+
<stop stopColor="#6BDAFF" />
|
|
34
|
+
<stop offset="0.5" stopColor="#F9FFB5" />
|
|
35
|
+
<stop offset="1" stopColor="#FFA770" />
|
|
36
|
+
</linearGradient>
|
|
37
|
+
</defs>
|
|
38
|
+
<circle cx="32" cy="32" r="30" fill="url(#tsgrad)" stroke="#0B1722" strokeWidth="3" />
|
|
39
|
+
<ellipse cx="24" cy="27" rx="6" ry="7" fill="#0B1722" />
|
|
40
|
+
<ellipse cx="40" cy="27" rx="6" ry="7" fill="#0B1722" />
|
|
41
|
+
<circle cx="26" cy="26" r="2" fill="#fff" />
|
|
42
|
+
<circle cx="42" cy="26" r="2" fill="#fff" />
|
|
43
|
+
<path d="M20 42c4 5 20 5 24 0" stroke="#0B1722" strokeWidth="3" strokeLinecap="round" fill="none" />
|
|
44
|
+
</svg>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const chip: React.CSSProperties = {
|
|
48
|
+
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
49
|
+
border: '1px solid #dcdce4', background: '#fff', borderRadius: 8,
|
|
50
|
+
padding: '6px 12px', fontSize: 13, fontWeight: 600, color: '#32324d',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const StackLogos = () => (
|
|
54
|
+
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
55
|
+
<span style={chip}><NextLogo /> Next.js</span>
|
|
56
|
+
<span style={chip}><TanStackLogo /> TanStack Start</span>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export { NextLogo, TanStackLogo };
|