| 1 |
import React, { useState, useEffect, useRef } from 'react'; |
| 2 |
import Creator from './components/Creator'; |
| 3 |
import SupportModal from './components/SupportModal'; |
| 4 |
import TemplatesModal from './components/TemplatesModal'; |
| 5 |
|
| 6 |
const App = () => { |
| 7 |
const [view, setView] = useState('list'); |
| 8 |
const [workflows, setWorkflows] = useState([]); |
| 9 |
const [selectedWorkflow, setSelectedWorkflow] = useState(null); |
| 10 |
const [loading, setLoading] = useState(true); |
| 11 |
const [isSupportOpen, setIsSupportOpen] = useState(false); |
| 12 |
const [isTemplatesModalOpen, setIsTemplatesModalOpen] = useState(false); |
| 13 |
const [importStatus, setImportStatus] = useState(null); // { type: 'success'|'error', message: '' } |
| 14 |
const importFileRef = useRef(null); |
| 15 |
|
| 16 |
useEffect(() => { |
| 17 |
fetchWorkflows(); |
| 18 |
}, []); |
| 19 |
|
| 20 |
const fetchWorkflows = async () => { |
| 21 |
setLoading(true); |
| 22 |
try { |
| 23 |
const response = await fetch(`${window.wpbotAutomator.apiUrl}/workflows`, { |
| 24 |
headers: { |
| 25 |
'X-WP-Nonce': window.wpbotAutomator.nonce, |
| 26 |
}, |
| 27 |
}); |
| 28 |
const data = await response.json(); |
| 29 |
setWorkflows(data || []); |
| 30 |
} catch (error) { |
| 31 |
console.error('Error fetching workflows:', error); |
| 32 |
} finally { |
| 33 |
setLoading(false); |
| 34 |
} |
| 35 |
}; |
| 36 |
|
| 37 |
const handleCreateNew = () => { |
| 38 |
setIsTemplatesModalOpen(true); |
| 39 |
}; |
| 40 |
|
| 41 |
const handleTemplateSelect = (template) => { |
| 42 |
setIsTemplatesModalOpen(false); |
| 43 |
if (template) { |
| 44 |
// Create a dummy workflow object from template |
| 45 |
setSelectedWorkflow({ |
| 46 |
id: null, |
| 47 |
name: template.name, |
| 48 |
description: template.description, |
| 49 |
workflow_data: template.workflow_data, |
| 50 |
status: 'active' |
| 51 |
}); |
| 52 |
} else { |
| 53 |
setSelectedWorkflow(null); |
| 54 |
} |
| 55 |
setView('creator'); |
| 56 |
}; |
| 57 |
|
| 58 |
const handleEdit = (workflow) => { |
| 59 |
setSelectedWorkflow(workflow); |
| 60 |
setView('creator'); |
| 61 |
}; |
| 62 |
|
| 63 |
const handleDelete = async (e, id) => { |
| 64 |
e.stopPropagation(); |
| 65 |
if (!window.confirm('Are you sure you want to delete this workflow?')) { |
| 66 |
return; |
| 67 |
} |
| 68 |
|
| 69 |
try { |
| 70 |
const response = await fetch(`${window.wpbotAutomator.apiUrl}/workflows/${id}`, { |
| 71 |
method: 'DELETE', |
| 72 |
headers: { |
| 73 |
'X-WP-Nonce': window.wpbotAutomator.nonce, |
| 74 |
}, |
| 75 |
}); |
| 76 |
const data = await response.json(); |
| 77 |
if (data.success) { |
| 78 |
fetchWorkflows(); |
| 79 |
} else { |
| 80 |
alert('Failed to delete workflow.'); |
| 81 |
} |
| 82 |
} catch (error) { |
| 83 |
console.error('Error deleting workflow:', error); |
| 84 |
} |
| 85 |
}; |
| 86 |
|
| 87 |
const handleToggleStatus = async (e, workflow) => { |
| 88 |
e.stopPropagation(); |
| 89 |
const newStatus = workflow.status === 'active' ? 'inactive' : 'active'; |
| 90 |
|
| 91 |
try { |
| 92 |
const response = await fetch(`${window.wpbotAutomator.apiUrl}/workflows/${workflow.id}`, { |
| 93 |
method: 'PUT', |
| 94 |
headers: { |
| 95 |
'Content-Type': 'application/json', |
| 96 |
'X-WP-Nonce': window.wpbotAutomator.nonce, |
| 97 |
}, |
| 98 |
body: JSON.stringify({ |
| 99 |
...workflow, |
| 100 |
status: newStatus, |
| 101 |
}), |
| 102 |
}); |
| 103 |
const data = await response.json(); |
| 104 |
if (data.success) { |
| 105 |
fetchWorkflows(); |
| 106 |
} else { |
| 107 |
alert('Failed to update workflow status.'); |
| 108 |
} |
| 109 |
} catch (error) { |
| 110 |
console.error('Error updating workflow status:', error); |
| 111 |
} |
| 112 |
}; |
| 113 |
|
| 114 |
const handleBack = () => { |
| 115 |
setView('list'); |
| 116 |
fetchWorkflows(); |
| 117 |
}; |
| 118 |
|
| 119 |
// ── Export helpers ──────────────────────────────────────────────────────── |
| 120 |
|
| 121 |
/** |
| 122 |
* Trigger a JSON file download from a plain-object payload. |
| 123 |
*/ |
| 124 |
const downloadJson = (data, filename) => { |
| 125 |
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); |
| 126 |
const url = URL.createObjectURL(blob); |
| 127 |
const a = document.createElement('a'); |
| 128 |
a.href = url; |
| 129 |
a.download = filename; |
| 130 |
a.click(); |
| 131 |
URL.revokeObjectURL(url); |
| 132 |
}; |
| 133 |
|
| 134 |
/** |
| 135 |
* Export a single workflow by ID. |
| 136 |
*/ |
| 137 |
const handleExportSingle = async (e, workflow) => { |
| 138 |
e.stopPropagation(); |
| 139 |
try { |
| 140 |
const response = await fetch( |
| 141 |
`${window.wpbotAutomator.apiUrl}/workflows/export?id=${workflow.id}`, |
| 142 |
{ headers: { 'X-WP-Nonce': window.wpbotAutomator.nonce } } |
| 143 |
); |
| 144 |
const data = await response.json(); |
| 145 |
const safeName = workflow.name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); |
| 146 |
downloadJson(data, `wpbot-workflow-${safeName}.json`); |
| 147 |
} catch (error) { |
| 148 |
console.error('Error exporting workflow:', error); |
| 149 |
alert('Failed to export workflow.'); |
| 150 |
} |
| 151 |
}; |
| 152 |
|
| 153 |
/** |
| 154 |
* Export all workflows. |
| 155 |
*/ |
| 156 |
const handleExportAll = async () => { |
| 157 |
try { |
| 158 |
const response = await fetch( |
| 159 |
`${window.wpbotAutomator.apiUrl}/workflows/export`, |
| 160 |
{ headers: { 'X-WP-Nonce': window.wpbotAutomator.nonce } } |
| 161 |
); |
| 162 |
const data = await response.json(); |
| 163 |
downloadJson(data, 'wpbot-workflows-all.json'); |
| 164 |
} catch (error) { |
| 165 |
console.error('Error exporting all workflows:', error); |
| 166 |
alert('Failed to export workflows.'); |
| 167 |
} |
| 168 |
}; |
| 169 |
|
| 170 |
// ── Import helpers ──────────────────────────────────────────────────────── |
| 171 |
|
| 172 |
const handleImportClick = () => { |
| 173 |
setImportStatus(null); |
| 174 |
importFileRef.current && importFileRef.current.click(); |
| 175 |
}; |
| 176 |
|
| 177 |
const handleImportFile = (e) => { |
| 178 |
const file = e.target.files && e.target.files[0]; |
| 179 |
if (!file) return; |
| 180 |
|
| 181 |
const reader = new FileReader(); |
| 182 |
reader.onload = async (evt) => { |
| 183 |
let parsed; |
| 184 |
try { |
| 185 |
parsed = JSON.parse(evt.target.result); |
| 186 |
} catch { |
| 187 |
setImportStatus({ type: 'error', message: 'Invalid JSON file. Please select a valid export file.' }); |
| 188 |
return; |
| 189 |
} |
| 190 |
|
| 191 |
if (!parsed.workflows || !Array.isArray(parsed.workflows)) { |
| 192 |
setImportStatus({ type: 'error', message: 'Invalid file format. Expected a "workflows" array.' }); |
| 193 |
return; |
| 194 |
} |
| 195 |
|
| 196 |
try { |
| 197 |
const response = await fetch(`${window.wpbotAutomator.apiUrl}/workflows/import`, { |
| 198 |
method: 'POST', |
| 199 |
headers: { |
| 200 |
'Content-Type': 'application/json', |
| 201 |
'X-WP-Nonce': window.wpbotAutomator.nonce, |
| 202 |
}, |
| 203 |
body: JSON.stringify({ workflows: parsed.workflows }), |
| 204 |
}); |
| 205 |
const data = await response.json(); |
| 206 |
|
| 207 |
if (data.success || data.imported > 0) { |
| 208 |
const msg = `Successfully imported ${data.imported} workflow${data.imported !== 1 ? 's' : ''}.` |
| 209 |
+ (data.errors && data.errors.length ? ` (${data.errors.length} skipped)` : ''); |
| 210 |
setImportStatus({ type: 'success', message: msg }); |
| 211 |
fetchWorkflows(); |
| 212 |
} else { |
| 213 |
const errMsg = (data.errors && data.errors.join(' ')) || 'Import failed. No workflows were imported.'; |
| 214 |
setImportStatus({ type: 'error', message: errMsg }); |
| 215 |
} |
| 216 |
} catch (error) { |
| 217 |
console.error('Error importing workflows:', error); |
| 218 |
setImportStatus({ type: 'error', message: 'Network error during import. Please try again.' }); |
| 219 |
} |
| 220 |
}; |
| 221 |
reader.readAsText(file); |
| 222 |
// Reset so the same file can be selected again |
| 223 |
e.target.value = ''; |
| 224 |
}; |
| 225 |
|
| 226 |
if (view === 'creator') { |
| 227 |
return <Creator workflow={selectedWorkflow} onBack={handleBack} />; |
| 228 |
} |
| 229 |
|
| 230 |
return ( |
| 231 |
<div className="p-8 bg-background min-h-screen text-foreground"> |
| 232 |
<div className="max-w-6xl mx-auto"> |
| 233 |
|
| 234 |
{/* Import status message */} |
| 235 |
{importStatus && ( |
| 236 |
<div className={`rounded-lg p-3 mb-6 flex items-center justify-between text-sm ${ |
| 237 |
importStatus.type === 'success' |
| 238 |
? 'bg-emerald-50 border border-emerald-200 text-emerald-800' |
| 239 |
: 'bg-rose-50 border border-rose-200 text-rose-800' |
| 240 |
}`}> |
| 241 |
<span>{importStatus.message}</span> |
| 242 |
<button onClick={() => setImportStatus(null)} className="ml-4 opacity-60 hover:opacity-100 transition-opacity"> |
| 243 |
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> |
| 244 |
</button> |
| 245 |
</div> |
| 246 |
)} |
| 247 |
|
| 248 |
<div className="flex justify-between items-center mb-8"> |
| 249 |
<h1 className="text-2xl font-bold">Workflows</h1> |
| 250 |
<div className="flex items-center space-x-2"> |
| 251 |
{/* Hidden file input for import */} |
| 252 |
<input |
| 253 |
ref={importFileRef} |
| 254 |
type="file" |
| 255 |
accept=".json,application/json" |
| 256 |
style={{ display: 'none' }} |
| 257 |
onChange={handleImportFile} |
| 258 |
/> |
| 259 |
{/* Import button */} |
| 260 |
<button |
| 261 |
onClick={handleImportClick} |
| 262 |
title="Import workflows from a JSON file" |
| 263 |
className="flex items-center space-x-1.5 px-3 py-2 border border-border text-sm rounded-md hover:bg-muted transition-colors" |
| 264 |
> |
| 265 |
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> |
| 266 |
<span>Import</span> |
| 267 |
</button> |
| 268 |
{/* Export All button — only shown when workflows exist */} |
| 269 |
{workflows.length > 0 && ( |
| 270 |
<button |
| 271 |
onClick={handleExportAll} |
| 272 |
title="Export all workflows as JSON" |
| 273 |
className="flex items-center space-x-1.5 px-3 py-2 border border-border text-sm rounded-md hover:bg-muted transition-colors" |
| 274 |
> |
| 275 |
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
| 276 |
<span>Export All</span> |
| 277 |
</button> |
| 278 |
)} |
| 279 |
{/* Create New button */} |
| 280 |
<button |
| 281 |
onClick={handleCreateNew} |
| 282 |
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors" |
| 283 |
> |
| 284 |
Create New Workflow |
| 285 |
</button> |
| 286 |
</div> |
| 287 |
</div> |
| 288 |
|
| 289 |
{loading ? ( |
| 290 |
<div className="text-center py-12 text-muted-foreground">Loading workflows...</div> |
| 291 |
) : workflows.length === 0 ? ( |
| 292 |
<div className="text-center py-12 border-2 border-dashed border-border rounded-lg text-muted-foreground"> |
| 293 |
No workflows found. Create your first one! |
| 294 |
</div> |
| 295 |
) : ( |
| 296 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> |
| 297 |
{workflows.map((workflow) => ( |
| 298 |
<div |
| 299 |
key={workflow.id} |
| 300 |
className="group relative p-6 bg-card border border-border rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer" |
| 301 |
onClick={() => handleEdit(workflow)} |
| 302 |
> |
| 303 |
<div className="flex justify-between items-start mb-2"> |
| 304 |
<h3 className="text-lg font-semibold">{workflow.name}</h3> |
| 305 |
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity"> |
| 306 |
{/* Toggle active/inactive */} |
| 307 |
<button |
| 308 |
onClick={(e) => handleToggleStatus(e, workflow)} |
| 309 |
title={workflow.status === 'active' ? 'Deactivate' : 'Activate'} |
| 310 |
className={`p-1.5 rounded-md transition-colors ${ |
| 311 |
workflow.status === 'active' |
| 312 |
? 'text-amber-600 hover:bg-amber-50' |
| 313 |
: 'text-emerald-600 hover:bg-emerald-50' |
| 314 |
}`} |
| 315 |
> |
| 316 |
{workflow.status === 'active' ? ( |
| 317 |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> |
| 318 |
) : ( |
| 319 |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> |
| 320 |
)} |
| 321 |
</button> |
| 322 |
{/* Export single workflow */} |
| 323 |
<button |
| 324 |
onClick={(e) => handleExportSingle(e, workflow)} |
| 325 |
title="Export workflow as JSON" |
| 326 |
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded-md transition-colors" |
| 327 |
> |
| 328 |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> |
| 329 |
</button> |
| 330 |
{/* Delete */} |
| 331 |
<button |
| 332 |
onClick={(e) => handleDelete(e, workflow.id)} |
| 333 |
title="Delete" |
| 334 |
className="p-1.5 text-rose-600 hover:bg-rose-50 rounded-md transition-colors" |
| 335 |
> |
| 336 |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> |
| 337 |
</button> |
| 338 |
</div> |
| 339 |
</div> |
| 340 |
<p className="text-sm text-muted-foreground mb-4 line-clamp-2"> |
| 341 |
{workflow.description || 'No description'} |
| 342 |
</p> |
| 343 |
<div className="flex justify-between items-center mt-auto"> |
| 344 |
<span className={`px-2 py-1 text-xs font-medium rounded-full ${ |
| 345 |
workflow.status === 'active' |
| 346 |
? 'bg-emerald-100/80 text-emerald-700' |
| 347 |
: 'bg-slate-100/80 text-slate-700' |
| 348 |
}`}> |
| 349 |
{workflow.status.charAt(0).toUpperCase() + workflow.status.slice(1)} |
| 350 |
</span> |
| 351 |
<span className="text-xs text-muted-foreground"> |
| 352 |
ID: {workflow.id} |
| 353 |
</span> |
| 354 |
</div> |
| 355 |
</div> |
| 356 |
))} |
| 357 |
</div> |
| 358 |
)} |
| 359 |
</div> |
| 360 |
<SupportModal isOpen={isSupportOpen} onClose={() => setIsSupportOpen(false)} /> |
| 361 |
<TemplatesModal |
| 362 |
isOpen={isTemplatesModalOpen} |
| 363 |
onClose={() => setIsTemplatesModalOpen(false)} |
| 364 |
onSelect={handleTemplateSelect} |
| 365 |
isProActive={!!window.wpbotAutomator?.isProActive} |
| 366 |
/> |
| 367 |
</div> |
| 368 |
); |
| 369 |
}; |
| 370 |
|
| 371 |
export default App; |
| 372 |
|