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);