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 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
  ```
@@ -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 };