strapi-plugin-mcp-chat 0.1.0 → 0.5.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.
package/README.md CHANGED
@@ -1,9 +1,16 @@
1
- # strapi-plugin-mcp-chat
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
+ [![npm](https://img.shields.io/npm/v/strapi-plugin-mcp-chat.svg)](https://www.npmjs.com/package/strapi-plugin-mcp-chat)
12
+ [![license](https://img.shields.io/npm/l/strapi-plugin-mcp-chat.svg)](./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
- - 🌐 Bilingual UI/prompts (PT / EN).
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
- # Straight from GitHub (not on npm yet):
31
- npm install github:raulbalestra/strapi-plugin-mcp-chat
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
  ```
@@ -238,11 +294,16 @@ The plugin follows the documented Strapi 5 plugin APIs:
238
294
  (`register` / `bootstrap` / `destroy` / `config` / `controllers` / `services` / `routes`).
239
295
  Routes are declared with `type: 'admin'`, so the chat/STT/TTS endpoints require an
240
296
  authenticated admin session.
241
- - **MCP** — tools are registered with `strapi.ai.mcp.registerTool` during `register()`
242
- (the documented extension point), using `z` from `@strapi/utils` for the schemas and
243
- `auth.policies` (content-manager read/update/publish) for RBAC. They're organized in a
244
- modular `server/src/mcp/` (one file per tool in `tools/`, aggregated and looped) following
245
- the structure from [Paul Bratslavsky's MCP tool-extension example](https://github.com/PaulBratslavsky/strapi-mcp-demo-and-tool-extension).
297
+ - **MCP** — tools are **defined** as pure objects with a local typed `defineTool` helper
298
+ (`server/src/mcp/define.ts`) and **registered from an array** via
299
+ `strapi.ai.mcp.registerTool` during `register()`, using `z` from `@strapi/utils` for the
300
+ schemas and `auth.policies` (content-manager read/update/publish) for RBAC. The
301
+ `defineTool`/`defineResource`/`definePrompt` identity functions infer each handler's
302
+ `args` from its input schema (no `any`) and keep the definitions side-effect-free —
303
+ mirroring the direction of Strapi's [PR #26603](https://github.com/strapi/strapi/pull/26603)
304
+ (`ai.mcp.defineTool` + the `import { ai } from "@strapi/strapi"` namespace). When that API
305
+ ships stable, migrating is a one-line import swap; until then the plugin stays on the
306
+ released `registerTool` so it runs on any Strapi ≥ 5.47 (no experimental build required).
246
307
  - **Admin** — `register()` uses only documented APIs (`app.addMenuLink`, `app.registerPlugin`).
247
308
 
248
309
  One intentional deviation: the **global floating chat** is mounted via its own React root
@@ -260,6 +321,27 @@ single spot.
260
321
  token's permissions — scope the token to only what those clients should change.
261
322
  - The agent can edit and publish content — give the plugin only to trusted editors.
262
323
 
324
+ ## Reliability — never degrades or breaks the host Strapi
325
+
326
+ This plugin is built to be a good citizen: installing it must never slow down or take
327
+ down the host app. Concrete guarantees:
328
+
329
+ - **Can't crash boot.** `register()` degrades gracefully if the native MCP server / i18n /
330
+ OpenAI key are absent (logs a warning, disables the feature). MCP tools register inside a
331
+ per-tool `try/catch`, so a single bad tool can never abort the others or the boot.
332
+ `destroy()` is best-effort.
333
+ - **Can't blank the admin.** The global overlay (floating chat + preview) mounts inside a
334
+ React **error boundary** in its own root, after an SSR/double-mount guard; any render
335
+ error just hides the overlay, leaving the Strapi admin fully intact. Lingering preview
336
+ layout styles are reset on load, and screen/mic capture is stopped on unmount.
337
+ - **Provisioning is fail-safe.** Generated schemas are **validated before any file is
338
+ written** (kind/attributes/known types/relation targets) and writes are **all-or-nothing**
339
+ — a malformed manifest can never leave Strapi with a broken, unbootable schema. Generation
340
+ stays additive (never touches existing types), dev-only, with hardened zip-slip protection.
341
+ - **Won't become a bottleneck.** Every outbound call (OpenAI, MCP) has a **timeout** so a
342
+ slow upstream can't hold a request open; recursive content search has depth caps and a
343
+ result cap; tool outputs are size-capped before going back to the model.
344
+
263
345
  ## License
264
346
 
265
347
  [MIT](./LICENSE) © Raul Balestra
@@ -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}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Error boundary que ISOLA as sobreposições do plugin (chat + preview) do resto
3
+ * do admin do Strapi. Se algo dentro renderizar com erro, capturamos aqui e
4
+ * renderizamos `null` — o overlay some, mas o admin do Strapi continua intacto.
5
+ * Sem isto, um erro de render num root React próprio poderia quebrar a página.
6
+ */
7
+ import { Component, type ReactNode } from 'react';
8
+
9
+ type Props = { children: ReactNode };
10
+ type State = { failed: boolean };
11
+
12
+ export class ErrorBoundary extends Component<Props, State> {
13
+ state: State = { failed: false };
14
+
15
+ static getDerivedStateFromError(): State {
16
+ return { failed: true };
17
+ }
18
+
19
+ componentDidCatch(error: unknown) {
20
+ // Apenas loga; nunca propaga para o admin.
21
+ // eslint-disable-next-line no-console
22
+ console.error('[mcp-chat] overlay desativado após erro de render:', error);
23
+ }
24
+
25
+ render() {
26
+ if (this.state.failed) return null;
27
+ return this.props.children;
28
+ }
29
+ }
@@ -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
  });
@@ -105,6 +113,14 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
105
113
  if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
106
114
  }, [messages, loading]);
107
115
 
116
+ // ── Cleanup ao desmontar: encerra captura de tela / microfone (sem vazar) ──
117
+ useEffect(() => {
118
+ return () => {
119
+ try { streamRef.current?.getTracks().forEach((t) => t.stop()); } catch { /* noop */ }
120
+ try { recorderRef.current?.stop(); } catch { /* noop */ }
121
+ };
122
+ }, []);
123
+
108
124
  // ── Screenshare ───────────────────────────────────────────────────────────
109
125
  const startShare = async () => {
110
126
  setError(null);
@@ -169,6 +185,8 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
169
185
  // Página que o usuário está olhando no preview (se aberto). Dá à IA o
170
186
  // contexto do "isso aqui" sem precisar varrer o site inteiro.
171
187
  previewUrl: previewOn ? previewUrl : null,
188
+ // Draft-first: por padrão a IA só salva rascunho; só publica com isto ON.
189
+ autoPublish,
172
190
  });
173
191
  const reply = data?.reply || '(sem resposta)';
174
192
  setMessages((cur) => [...cur, { role: 'assistant', content: reply }]);
@@ -319,6 +337,10 @@ export const FloatingChat = ({ previewOn, previewUrl, onTogglePreview, onReply }
319
337
  title={t.voiceTitle}>
320
338
  {voiceOn ? t.voiceOn : t.voiceOff}
321
339
  </button>
340
+ <button style={btn(autoPublish)} onClick={() => setAutoPublish((v) => !v)}
341
+ title={t.pubTitle}>
342
+ {autoPublish ? t.pubOn : t.pubOff}
343
+ </button>
322
344
  <button style={btn(sharing)} onClick={sharing ? stopShare : startShare}
323
345
  title={t.shareTitle}>
324
346
  {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 };