feat: Implement WordPress publishing and unpublishing actions
- Added conditional visibility for table actions based on content state (published/draft). - Introduced `publishContent` and `unpublishContent` API functions for handling WordPress integration. - Updated `Content` component to manage publish/unpublish actions with appropriate error handling and success notifications. - Refactored `PostEditor` to remove deprecated SEO fields and consolidate taxonomy management. - Enhanced `TablePageTemplate` to filter row actions based on visibility conditions. - Updated backend API to support publishing and unpublishing content with proper status updates and external references.
This commit is contained in:
@@ -12,6 +12,7 @@ export interface RowActionConfig {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant?: 'primary' | 'danger' | 'secondary' | 'success'; // For styling
|
||||
shouldShow?: (row: any) => boolean; // Optional conditional visibility
|
||||
}
|
||||
|
||||
export interface BulkActionConfig {
|
||||
@@ -257,6 +258,27 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
||||
icon: EditIcon,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
label: 'Publish to WordPress',
|
||||
icon: <CheckCircleIcon className="w-5 h-5 text-success-500" />,
|
||||
variant: 'success',
|
||||
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
||||
},
|
||||
{
|
||||
key: 'view_on_wordpress',
|
||||
label: 'View on WordPress',
|
||||
icon: <CheckCircleIcon className="w-5 h-5 text-blue-500" />,
|
||||
variant: 'secondary',
|
||||
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
||||
},
|
||||
{
|
||||
key: 'unpublish',
|
||||
label: 'Unpublish',
|
||||
icon: <TrashBinIcon className="w-5 h-5" />,
|
||||
variant: 'secondary',
|
||||
shouldShow: (row: any) => !!row.external_id, // Only show if published
|
||||
},
|
||||
{
|
||||
key: 'generate_image_prompts',
|
||||
label: 'Generate Image Prompts',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { SaveIcon, XIcon, EyeIcon, FileTextIcon, SettingsIcon, TagIcon, CheckCircleIcon, XCircleIcon, AlertCircleIcon } from 'lucide-react';
|
||||
import { SaveIcon, XIcon, FileTextIcon, TagIcon, CheckCircleIcon, XCircleIcon, AlertCircleIcon } from 'lucide-react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
@@ -18,8 +18,7 @@ import { fetchAPI, fetchContentValidation, validateContent, ContentValidationRes
|
||||
interface Content {
|
||||
id?: number;
|
||||
title: string;
|
||||
content_html?: string;
|
||||
content?: string;
|
||||
content_html: string;
|
||||
content_type: string; // post, page, product, service, category, tag
|
||||
content_structure?: string; // article, listicle, guide, comparison, product_page
|
||||
status: string; // draft, published
|
||||
@@ -30,6 +29,7 @@ interface Content {
|
||||
source?: string; // igny8, wordpress
|
||||
external_id?: string | null;
|
||||
external_url?: string | null;
|
||||
word_count?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
@@ -40,13 +40,12 @@ export default function PostEditor() {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'content' | 'seo' | 'metadata' | 'validation'>('content');
|
||||
const [activeTab, setActiveTab] = useState<'content' | 'taxonomy' | 'validation'>('content');
|
||||
const [validationResult, setValidationResult] = useState<ContentValidationResult | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [content, setContent] = useState<Content>({
|
||||
title: '',
|
||||
content_html: '',
|
||||
content: '',
|
||||
content_type: 'post',
|
||||
content_structure: 'article',
|
||||
status: 'draft',
|
||||
@@ -122,7 +121,6 @@ export default function PostEditor() {
|
||||
id: data.id,
|
||||
title: data.title || '',
|
||||
content_html: data.content_html || '',
|
||||
content: data.content_html || data.content || '',
|
||||
content_type: data.content_type || 'post',
|
||||
content_structure: data.content_structure || 'article',
|
||||
status: data.status || 'draft',
|
||||
@@ -133,6 +131,7 @@ export default function PostEditor() {
|
||||
source: data.source || 'igny8',
|
||||
external_id: data.external_id || null,
|
||||
external_url: data.external_url || null,
|
||||
word_count: data.word_count || 0,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
});
|
||||
@@ -281,27 +280,15 @@ export default function PostEditor() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('seo')}
|
||||
onClick={() => setActiveTab('taxonomy')}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'seo'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<EyeIcon className="w-4 h-4 inline mr-2" />
|
||||
SEO
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('metadata')}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'metadata'
|
||||
activeTab === 'taxonomy'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="w-4 h-4 inline mr-2" />
|
||||
Metadata
|
||||
Taxonomy & Cluster
|
||||
</button>
|
||||
{content.id && (
|
||||
<button
|
||||
@@ -367,8 +354,8 @@ export default function PostEditor() {
|
||||
<div>
|
||||
<Label>Content (HTML)</Label>
|
||||
<TextArea
|
||||
value={content.html_content || content.content}
|
||||
onChange={(value) => setContent({ ...content, html_content: value, content: value })}
|
||||
value={content.content_html || ''}
|
||||
onChange={(value) => setContent({ ...content, content_html: value })}
|
||||
rows={25}
|
||||
placeholder="Write your post content here (HTML supported)..."
|
||||
className="mt-1 font-mono text-sm"
|
||||
@@ -387,152 +374,55 @@ export default function PostEditor() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* SEO Tab */}
|
||||
{activeTab === 'seo' && (
|
||||
{/* Taxonomy & Cluster Tab */}
|
||||
{activeTab === 'taxonomy' && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Meta Title</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.meta_title || ''}
|
||||
onChange={(e) => setContent({ ...content, meta_title: e.target.value })}
|
||||
placeholder="SEO title (recommended: 50-60 characters)"
|
||||
maxLength={60}
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<Label>Cluster</Label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{content.cluster_name || 'No cluster assigned'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{content.meta_title?.length || 0}/60 characters
|
||||
Clusters help organize related content. Assign via the Planner module.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Meta Description</Label>
|
||||
<TextArea
|
||||
value={content.meta_description || ''}
|
||||
onChange={(value) => setContent({ ...content, meta_description: value })}
|
||||
rows={4}
|
||||
placeholder="SEO description (recommended: 150-160 characters)"
|
||||
maxLength={160}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{content.meta_description?.length || 0}/160 characters
|
||||
<Label>Taxonomies</Label>
|
||||
{content.taxonomy_terms && content.taxonomy_terms.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{content.taxonomy_terms.map((term) => (
|
||||
<span
|
||||
key={term.id}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-300"
|
||||
>
|
||||
{term.name} ({term.taxonomy})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
No taxonomies assigned
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Taxonomies include categories, tags, and custom classifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Primary Keyword</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.primary_keyword || ''}
|
||||
onChange={(e) => setContent({ ...content, primary_keyword: e.target.value })}
|
||||
placeholder="Main keyword for this content"
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<Label>Content Type</Label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{content.content_type}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Secondary Keywords (comma-separated)</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={content.secondary_keywords?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const keywords = e.target.value.split(',').map((k) => k.trim()).filter(Boolean);
|
||||
setContent({ ...content, secondary_keywords: keywords });
|
||||
}}
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata Tab */}
|
||||
{activeTab === 'metadata' && (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>Tags</Label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a tag and press Enter"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag} variant="outline">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{content.tags && content.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{content.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Categories</Label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCategory();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a category and press Enter"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<Button type="button" onClick={handleAddCategory} variant="outline">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{content.categories && content.categories.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{content.categories.map((category) => (
|
||||
<span
|
||||
key={category}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 dark:bg-blue-900 rounded-full text-sm"
|
||||
>
|
||||
{category}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCategory(category)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Label>Content Structure</Label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{content.content_structure || 'Not specified'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Content as ContentType,
|
||||
ContentFilters,
|
||||
generateImagePrompts,
|
||||
publishContent,
|
||||
unpublishContent,
|
||||
} from '../../services/api';
|
||||
import { optimizerApi } from '../../api/optimizer.api';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -162,7 +164,41 @@ export default function Content() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
||||
if (action === 'generate_image_prompts') {
|
||||
if (action === 'publish') {
|
||||
try {
|
||||
// Check if already published
|
||||
if (row.external_id) {
|
||||
toast.warning('Content is already published to WordPress');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await publishContent(row.id);
|
||||
toast.success(`Content published successfully! View at: ${result.external_url}`);
|
||||
loadContent(); // Reload to show updated external_id
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to publish content: ${error.message}`);
|
||||
}
|
||||
} else if (action === 'view_on_wordpress') {
|
||||
if (row.external_url) {
|
||||
window.open(row.external_url, '_blank');
|
||||
} else {
|
||||
toast.warning('WordPress URL not available');
|
||||
}
|
||||
} else if (action === 'unpublish') {
|
||||
try {
|
||||
// Check if not published
|
||||
if (!row.external_id) {
|
||||
toast.warning('Content is not currently published');
|
||||
return;
|
||||
}
|
||||
|
||||
await unpublishContent(row.id);
|
||||
toast.success('Content unpublished successfully');
|
||||
loadContent(); // Reload to show cleared external_id
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to unpublish content: ${error.message}`);
|
||||
}
|
||||
} else if (action === 'generate_image_prompts') {
|
||||
try {
|
||||
const result = await generateImagePrompts([row.id]);
|
||||
if (result.success) {
|
||||
|
||||
@@ -2074,6 +2074,38 @@ export async function fetchContentById(id: number): Promise<Content> {
|
||||
return fetchAPI(`/v1/writer/content/${id}/`);
|
||||
}
|
||||
|
||||
// Content Publishing API
|
||||
export interface PublishContentResult {
|
||||
content_id: number;
|
||||
status: string;
|
||||
external_id: string;
|
||||
external_url: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UnpublishContentResult {
|
||||
content_id: number;
|
||||
status: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function publishContent(id: number, site_id?: number): Promise<PublishContentResult> {
|
||||
const body: { site_id?: number } = {};
|
||||
if (site_id !== undefined) {
|
||||
body.site_id = site_id;
|
||||
}
|
||||
return fetchAPI(`/v1/writer/content/${id}/publish/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unpublishContent(id: number): Promise<UnpublishContentResult> {
|
||||
return fetchAPI(`/v1/writer/content/${id}/unpublish/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// Stage 3: Content Validation API
|
||||
export interface ContentValidationResult {
|
||||
content_id: number;
|
||||
|
||||
@@ -1012,7 +1012,9 @@ export default function TablePageTemplate({
|
||||
placement="right"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
{rowActions.map((action) => {
|
||||
{rowActions
|
||||
.filter((action) => !action.shouldShow || action.shouldShow(row))
|
||||
.map((action) => {
|
||||
const isEdit = action.key === 'edit';
|
||||
const isDelete = action.key === 'delete';
|
||||
const isExport = action.key === 'export';
|
||||
|
||||
Reference in New Issue
Block a user