feat: Phase 1 - Send to Factory Implementation
Core Features: - API endpoint: /api/factory/send-to-factory - SendToFactoryButton component (3 variants: default, small, icon) - FactoryOptionsModal with template/location/mode selection - Integration with Jumpstart QC screen Features: - One-click article generation from WordPress posts - Template selection (Long-Tail SEO, Local Authority, etc.) - Geo-targeting from Intelligence Library - Processing modes (Refactor, Rewrite, Enhance, Localize) - Auto-publish toggle - Success/error callbacks - Loading states and notifications Integration Points: - Added to Jumpstart QC items - Fetches geo clusters for location targeting - Creates generation job in Directus - Calls article generation API - Returns preview URL Ready for testing!
This commit is contained in:
211
frontend/src/components/admin/factory/FactoryOptionsModal.tsx
Normal file
211
frontend/src/components/admin/factory/FactoryOptionsModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
interface FactoryOptionsModalProps {
|
||||
postTitle: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (options: FactoryOptions) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface FactoryOptions {
|
||||
template: string;
|
||||
location?: string;
|
||||
mode: string;
|
||||
autoPublish: boolean;
|
||||
}
|
||||
|
||||
export default function FactoryOptionsModal({
|
||||
postTitle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading
|
||||
}: FactoryOptionsModalProps) {
|
||||
const [template, setTemplate] = useState('long_tail_seo');
|
||||
const [location, setLocation] = useState('');
|
||||
const [mode, setMode] = useState('refactor');
|
||||
const [autoPublish, setAutoPublish] = useState(false);
|
||||
const [geoClusters, setGeoClusters] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGeoClusters();
|
||||
}, []);
|
||||
|
||||
const loadGeoClusters = async () => {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const clusters = await client.request(readItems('geo_clusters', {
|
||||
fields: ['id', 'cluster_name'],
|
||||
limit: -1
|
||||
}));
|
||||
setGeoClusters(clusters);
|
||||
} catch (error) {
|
||||
console.error('Error loading geo clusters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
template,
|
||||
location,
|
||||
mode,
|
||||
autoPublish
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-white max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold text-white">
|
||||
🏭 Send to Factory
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Configure how you want to transform: <span className="text-purple-400 font-semibold">{postTitle}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<Label htmlFor="template" className="text-white mb-2 block">
|
||||
Content Template
|
||||
</Label>
|
||||
<Select value={template} onValueChange={setTemplate}>
|
||||
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="long_tail_seo">Long-Tail SEO</SelectItem>
|
||||
<SelectItem value="local_authority">Local Authority</SelectItem>
|
||||
<SelectItem value="thought_leadership">Thought Leadership</SelectItem>
|
||||
<SelectItem value="problem_solution">Problem → Solution</SelectItem>
|
||||
<SelectItem value="listicle">Listicle Format</SelectItem>
|
||||
<SelectItem value="how_to_guide">How-To Guide</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Choose the content structure and SEO strategy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Location Targeting */}
|
||||
<div>
|
||||
<Label htmlFor="location" className="text-white mb-2 block">
|
||||
Location Targeting (Optional)
|
||||
</Label>
|
||||
<Select value={location} onValueChange={setLocation}>
|
||||
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
|
||||
<SelectValue placeholder="No location targeting" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="">No location targeting</SelectItem>
|
||||
{geoClusters.map((cluster) => (
|
||||
<SelectItem key={cluster.id} value={cluster.cluster_name}>
|
||||
{cluster.cluster_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Apply geo-specific keywords and local SEO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Processing Mode */}
|
||||
<div>
|
||||
<Label htmlFor="mode" className="text-white mb-2 block">
|
||||
Processing Mode
|
||||
</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger className="bg-slate-900 border-slate-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="refactor">Refactor (Improve existing)</SelectItem>
|
||||
<SelectItem value="rewrite">Rewrite (Complete rewrite)</SelectItem>
|
||||
<SelectItem value="enhance">Enhance (Add sections)</SelectItem>
|
||||
<SelectItem value="localize">Localize (Geo-optimize)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
How aggressively to transform the content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-Publish Toggle */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<Label htmlFor="autoPublish" className="text-white font-semibold">
|
||||
Auto-Publish to WordPress
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Automatically publish back to WordPress after generation
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoPublish(!autoPublish)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoPublish ? 'bg-purple-600' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${autoPublish ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Info */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">ℹ️</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-400 mb-1">What happens next?</h4>
|
||||
<ul className="text-sm text-slate-300 space-y-1">
|
||||
<li>• Content will be sent to the Spark Factory</li>
|
||||
<li>• Intelligence Library will be applied (spintax, patterns, geo)</li>
|
||||
<li>• Article will be generated with your selected template</li>
|
||||
<li>• You'll get a preview link to review before publishing</li>
|
||||
{autoPublish && <li className="text-yellow-400">• Article will auto-publish to WordPress</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
variant="ghost"
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-semibold"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mr-2">🚀</span>
|
||||
Send to Factory
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/admin/factory/SendToFactoryButton.tsx
Normal file
138
frontend/src/components/admin/factory/SendToFactoryButton.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import FactoryOptionsModal from './FactoryOptionsModal';
|
||||
|
||||
interface SendToFactoryButtonProps {
|
||||
postId: number;
|
||||
postTitle: string;
|
||||
siteUrl: string;
|
||||
siteAuth?: string;
|
||||
onSuccess?: (result: any) => void;
|
||||
onError?: (error: string) => void;
|
||||
variant?: 'default' | 'small' | 'icon';
|
||||
}
|
||||
|
||||
export default function SendToFactoryButton({
|
||||
postId,
|
||||
postTitle,
|
||||
siteUrl,
|
||||
siteAuth,
|
||||
onSuccess,
|
||||
onError,
|
||||
variant = 'default'
|
||||
}: SendToFactoryButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSend = async (options: any) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/factory/send-to-factory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: {
|
||||
type: 'wordpress',
|
||||
url: siteUrl,
|
||||
postId: postId,
|
||||
auth: siteAuth
|
||||
},
|
||||
options: options
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result);
|
||||
setShowModal(false);
|
||||
} else {
|
||||
onError?.(result.error);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === 'icon') {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-purple-400 hover:text-purple-300 hover:bg-purple-500/10 rounded transition-colors"
|
||||
title="Send to Factory"
|
||||
>
|
||||
🏭
|
||||
</button>
|
||||
{showModal && (
|
||||
<FactoryOptionsModal
|
||||
postTitle={postTitle}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'small') {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white"
|
||||
>
|
||||
{isLoading ? '⏳ Processing...' : '🏭 Send to Factory'}
|
||||
</Button>
|
||||
{showModal && (
|
||||
<FactoryOptionsModal
|
||||
postTitle={postTitle}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-semibold px-6 py-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mr-2">🏭</span>
|
||||
Send to Factory
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{showModal && (
|
||||
<FactoryOptionsModal
|
||||
postTitle={postTitle}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import SendToFactoryButton from '@/components/admin/factory/SendToFactoryButton';
|
||||
|
||||
type Step = 'connect' | 'inventory' | 'qc' | 'launch';
|
||||
|
||||
@@ -253,7 +254,23 @@ export default function JumpstartWizard() {
|
||||
<span className="text-slate-200 font-medium truncate w-64">{item.title}</span>
|
||||
<a href={item.link} target="_blank" className="text-xs text-blue-400 hover:underline">View Original</a>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<SendToFactoryButton
|
||||
postId={item.id}
|
||||
postTitle={item.title}
|
||||
siteUrl={siteUrl}
|
||||
siteAuth={appPassword ? `${username}:${appPassword}` : undefined}
|
||||
variant="small"
|
||||
onSuccess={(result) => {
|
||||
addLog(`✅ Article generated: ${result.article.title}`);
|
||||
addLog(`🔗 Preview: ${result.previewUrl}`);
|
||||
}}
|
||||
onError={(error) => {
|
||||
addLog(`❌ Factory error: ${error}`);
|
||||
}}
|
||||
/>
|
||||
<Badge variant="outline" className="text-yellow-400 border-yellow-400">Review Needed</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
125
frontend/src/pages/api/factory/send-to-factory.ts
Normal file
125
frontend/src/pages/api/factory/send-to-factory.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||
import { WordPressClient } from '@/lib/wordpress/WordPressClient';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { source, options } = body;
|
||||
|
||||
// Validate input
|
||||
if (!source?.postId || !source?.url) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing required fields: source.postId and source.url'
|
||||
}), { status: 400 });
|
||||
}
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
// 1. Fetch WordPress post
|
||||
const wpClient = new WordPressClient(source.url, source.auth);
|
||||
const post = await wpClient.getPost(source.postId);
|
||||
|
||||
if (!post) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'WordPress post not found'
|
||||
}), { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Get or create site record
|
||||
// @ts-ignore
|
||||
const sites = await client.request(readItems('sites', {
|
||||
filter: { url: { _eq: source.url } },
|
||||
limit: 1
|
||||
}));
|
||||
|
||||
let siteId;
|
||||
if (sites.length > 0) {
|
||||
siteId = sites[0].id;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const newSite = await client.request(createItem('sites', {
|
||||
name: new URL(source.url).hostname,
|
||||
url: source.url
|
||||
}));
|
||||
siteId = newSite.id;
|
||||
}
|
||||
|
||||
// 3. Create generation job
|
||||
// @ts-ignore
|
||||
const job = await client.request(createItem('generation_jobs', {
|
||||
site_id: siteId,
|
||||
status: 'Pending',
|
||||
type: options.mode || 'Refactor',
|
||||
target_quantity: 1,
|
||||
config: {
|
||||
wordpress_url: source.url,
|
||||
wordpress_auth: source.auth,
|
||||
wordpress_post_id: source.postId,
|
||||
mode: options.mode || 'refactor',
|
||||
template: options.template || 'long_tail_seo',
|
||||
location: options.location,
|
||||
auto_publish: options.autoPublish || false,
|
||||
source_title: post.title.rendered,
|
||||
source_slug: post.slug
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. Generate article
|
||||
const generateResponse = await fetch(`${request.url.origin}/api/seo/generate-article`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
siteId: siteId,
|
||||
template: options.template || 'long_tail_seo',
|
||||
targetKeyword: post.title.rendered,
|
||||
sourceContent: post.content.rendered,
|
||||
metadata: {
|
||||
originalSlug: post.slug,
|
||||
originalId: post.id,
|
||||
originalDate: post.date,
|
||||
location: options.location,
|
||||
mode: options.mode
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!generateResponse.ok) {
|
||||
throw new Error('Article generation failed');
|
||||
}
|
||||
|
||||
const article = await generateResponse.json();
|
||||
|
||||
// 5. Update job status
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('generation_jobs', job.id, {
|
||||
status: 'Complete',
|
||||
current_offset: 1
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
jobId: job.id,
|
||||
articleId: article.id,
|
||||
previewUrl: `/preview/article/${article.id}`,
|
||||
status: 'complete',
|
||||
article: {
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
seoScore: article.metadata?.seo_score
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Send to Factory error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || 'Unknown error occurred'
|
||||
}), { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user