/ wordpress / restai / admin / js / editor.js
editor.js
  1  /* global RESTaiAdmin, wp */
  2  /**
  3   * Adds the "RESTai" sidebar to Gutenberg with one-click actions:
  4   *   • Generate body content from the current title (or a brief)
  5   *   • Generate SEO meta (title / description / focus keyphrase)
  6   *   • Generate a featured image
  7   *   • Translate to a target language (saved as a new draft)
  8   *
  9   * Also adds a small floating button to the classic editor with the same
 10   * actions (when Gutenberg is disabled).
 11   */
 12  (function (wp) {
 13  	"use strict";
 14  
 15  	if (!wp || !wp.plugins || !wp.editPost) {
 16  		// classic editor fallback
 17  		document.addEventListener("DOMContentLoaded", function () {
 18  			const wrap = document.getElementById("postdivrich") || document.getElementById("postbody");
 19  			if (!wrap) return;
 20  			const btn = document.createElement("button");
 21  			btn.type = "button";
 22  			btn.className = "button button-primary";
 23  			btn.textContent = RESTaiAdmin.i18n.generate;
 24  			btn.style.margin = "8px 0";
 25  			btn.addEventListener("click", function () {
 26  				const titleEl = document.getElementById("title");
 27  				const title = titleEl ? titleEl.value : "";
 28  				if (!title) {
 29  					alert("Add a post title first.");
 30  					return;
 31  				}
 32  				generate("content_writer", "Write a blog post about: " + title)
 33  					.then((out) => insertClassic(out))
 34  					.catch((err) => alert(err.message || RESTaiAdmin.i18n.error));
 35  			});
 36  			wrap.parentNode.insertBefore(btn, wrap);
 37  		});
 38  		return;
 39  	}
 40  
 41  	const { registerPlugin } = wp.plugins;
 42  	const { PluginSidebar, PluginSidebarMoreMenuItem } = wp.editPost;
 43  	const { PanelBody, Button, TextareaControl, SelectControl, Spinner, Notice } = wp.components;
 44  	const { useState, createElement: el } = wp.element;
 45  	const { useSelect, useDispatch } = wp.data;
 46  	const { __ } = wp.i18n;
 47  	const apiFetch = wp.apiFetch;
 48  	apiFetch.use(apiFetch.createNonceMiddleware(RESTaiAdmin.nonce));
 49  
 50  	// Brain icon (Material Symbols "Psychology") used in the editor sidebar
 51  	// and the more-menu entry, since Dashicons has no brain glyph.
 52  	const BrainIcon = el(
 53  		"svg",
 54  		{ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", width: 24, height: 24, "aria-hidden": "true", focusable: "false" },
 55  		el("path", { d: "M13 8.57c-.79 0-1.43.64-1.43 1.43s.64 1.43 1.43 1.43 1.43-.64 1.43-1.43-.64-1.43-1.43-1.43z" }),
 56  		el("path", { d: "M13 3C9.25 3 6.2 5.94 6.02 9.64L4.1 12.2c-.41.55 0 1.34.69 1.34H6V16c0 1.1.9 2 2 2h1v3h7v-4.68c2.36-1.12 4-3.53 4-6.32 0-3.87-3.13-7-7-7zm3 7c0 .13-.01.26-.02.39l.83.66c.08.06.1.16.05.25l-.8 1.39c-.05.09-.16.12-.24.09l-.99-.4c-.21.16-.43.28-.67.39l-.15 1.06c-.01.1-.1.17-.2.17h-1.6c-.1 0-.18-.07-.2-.17l-.15-1.06c-.25-.1-.47-.23-.68-.38l-.99.4c-.09.04-.2 0-.24-.09l-.8-1.39c-.05-.08-.03-.19.05-.25l.84-.66c-.01-.13-.02-.26-.02-.39 0-.13.01-.26.03-.39l-.84-.66c-.08-.06-.1-.16-.05-.25l.8-1.39c.05-.09.16-.12.24-.09l.99.4c.21-.16.43-.28.67-.39l.15-1.06c.02-.1.1-.17.2-.17h1.6c.1 0 .18.07.2.17l.15 1.06c.24.1.46.22.67.39l.99-.4c.09-.04.2 0 .24.09l.8 1.39c.05.09.03.19-.05.25l-.83.66c.01.13.02.26.02.39z" })
 57  	);
 58  
 59  	const url = (path) => RESTaiAdmin.restUrl + path;
 60  
 61  	function generate(task, prompt) {
 62  		return apiFetch({ url: url("/generate"), method: "POST", data: { task, prompt } }).then(
 63  			(r) => r.output
 64  		);
 65  	}
 66  
 67  	function insertClassic(html) {
 68  		if (window.tinymce && window.tinymce.activeEditor) {
 69  			window.tinymce.activeEditor.setContent(html);
 70  		} else {
 71  			const ta = document.getElementById("content");
 72  			if (ta) ta.value = html;
 73  		}
 74  	}
 75  
 76  	const RestAiSidebar = () => {
 77  		const post = useSelect((sel) => sel("core/editor").getCurrentPost(), []);
 78  		const { editPost } = useDispatch("core/editor");
 79  
 80  		const [brief, setBrief] = useState("");
 81  		const [busy, setBusy] = useState(null);
 82  		const [error, setError] = useState("");
 83  		const [language, setLanguage] = useState("Spanish");
 84  		const [seoResult, setSeoResult] = useState(null);
 85  
 86  		const run = (label, fn) => {
 87  			setBusy(label);
 88  			setError("");
 89  			Promise.resolve()
 90  				.then(fn)
 91  				.catch((e) => setError(e.message || RESTaiAdmin.i18n.error))
 92  				.finally(() => setBusy(null));
 93  		};
 94  
 95  		const writeContent = () =>
 96  			run("content", () => {
 97  				const prompt = brief.trim()
 98  					? brief
 99  					: "Write a well-structured blog post about: " + (post.title || "");
100  				return generate("content_writer", prompt).then((html) => {
101  					if (!confirm(RESTaiAdmin.i18n.confirm_replace)) return;
102  					editPost({ content: html });
103  				});
104  			});
105  
106  		const writeExcerpt = () =>
107  			run("excerpt", () =>
108  				generate(
109  					"content_writer",
110  					'Summarize the following post in one sentence (max 25 words). HTML stripped to plain text.\n\nTITLE: ' +
111  						(post.title || "") +
112  						"\n\nCONTENT: " +
113  						(post.content || "")
114  				).then((excerpt) => editPost({ excerpt }))
115  			);
116  
117  		const seoMeta = () =>
118  			run("seo", () =>
119  				apiFetch({ url: url("/seo-meta"), method: "POST", data: { post_id: post.id } }).then(
120  					(res) => {
121  						setSeoResult(res);
122  						if (res.meta_title) editPost({ meta: { _yoast_wpseo_title: res.meta_title } });
123  						if (res.meta_description)
124  							editPost({ meta: { _yoast_wpseo_metadesc: res.meta_description } });
125  					}
126  				)
127  			);
128  
129  		const featuredImage = () =>
130  			run("image", () =>
131  				apiFetch({ url: url("/featured-image"), method: "POST", data: { post_id: post.id } }).then(
132  					(res) => {
133  						if (res && res.attachment_id) {
134  							editPost({ featured_media: res.attachment_id });
135  						}
136  					}
137  				)
138  			);
139  
140  		const translate = () =>
141  			run("translate", () =>
142  				apiFetch({
143  					url: url("/translate"),
144  					method: "POST",
145  					data: { post_id: post.id, language: language },
146  				}).then((res) => {
147  					if (res && res.new_post_id) {
148  						alert(__("Translation saved as draft #", "restai") + res.new_post_id);
149  					}
150  				})
151  			);
152  
153  		return wp.element.createElement(
154  			PluginSidebar,
155  			{ name: "restai-sidebar", title: __("RESTai", "restai"), icon: BrainIcon },
156  			wp.element.createElement(
157  				PanelBody,
158  				{ title: __("Content", "restai"), initialOpen: true },
159  				wp.element.createElement(TextareaControl, {
160  					label: __("Optional brief", "restai"),
161  					value: brief,
162  					onChange: setBrief,
163  					placeholder: __("Leave blank to use the post title as a brief.", "restai"),
164  				}),
165  				wp.element.createElement(
166  					Button,
167  					{ variant: "primary", onClick: writeContent, disabled: !!busy },
168  					busy === "content" ? wp.element.createElement(Spinner) : __("Generate body", "restai")
169  				),
170  				" ",
171  				wp.element.createElement(
172  					Button,
173  					{ variant: "secondary", onClick: writeExcerpt, disabled: !!busy },
174  					busy === "excerpt" ? wp.element.createElement(Spinner) : __("Generate excerpt", "restai")
175  				)
176  			),
177  			wp.element.createElement(
178  				PanelBody,
179  				{ title: __("SEO meta", "restai"), initialOpen: false },
180  				wp.element.createElement(
181  					Button,
182  					{ variant: "primary", onClick: seoMeta, disabled: !!busy },
183  					busy === "seo" ? wp.element.createElement(Spinner) : __("Generate SEO", "restai")
184  				),
185  				seoResult &&
186  					wp.element.createElement(
187  						"div",
188  						{ style: { marginTop: 8, fontSize: 12 } },
189  						wp.element.createElement("p", null, wp.element.createElement("strong", null, "Title: "), seoResult.meta_title || ""),
190  						wp.element.createElement("p", null, wp.element.createElement("strong", null, "Desc: "), seoResult.meta_description || ""),
191  						wp.element.createElement("p", null, wp.element.createElement("strong", null, "Focus: "), seoResult.focus_keyphrase || "")
192  					)
193  			),
194  			wp.element.createElement(
195  				PanelBody,
196  				{ title: __("Featured image", "restai"), initialOpen: false },
197  				wp.element.createElement(
198  					Button,
199  					{ variant: "primary", onClick: featuredImage, disabled: !!busy },
200  					busy === "image" ? wp.element.createElement(Spinner) : __("Generate image", "restai")
201  				)
202  			),
203  			wp.element.createElement(
204  				PanelBody,
205  				{ title: __("Translation", "restai"), initialOpen: false },
206  				wp.element.createElement(SelectControl, {
207  					label: __("Target language", "restai"),
208  					value: language,
209  					options: [
210  						{ label: "Spanish", value: "Spanish" },
211  						{ label: "Portuguese", value: "Portuguese" },
212  						{ label: "French", value: "French" },
213  						{ label: "German", value: "German" },
214  						{ label: "Italian", value: "Italian" },
215  						{ label: "Dutch", value: "Dutch" },
216  						{ label: "Polish", value: "Polish" },
217  						{ label: "Japanese", value: "Japanese" },
218  						{ label: "Chinese (Simplified)", value: "Chinese (Simplified)" },
219  					],
220  					onChange: setLanguage,
221  				}),
222  				wp.element.createElement(
223  					Button,
224  					{ variant: "primary", onClick: translate, disabled: !!busy },
225  					busy === "translate"
226  						? wp.element.createElement(Spinner)
227  						: __("Translate to " + language, "restai")
228  				)
229  			),
230  			error &&
231  				wp.element.createElement(Notice, { status: "error", isDismissible: true }, error)
232  		);
233  	};
234  
235  	registerPlugin("restai-sidebar", { render: RestAiSidebar, icon: BrainIcon });
236  })(window.wp);