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:
alorig
2025-11-26 01:24:58 +05:00
parent ba842d8332
commit 53ea0c34ce
13 changed files with 1249 additions and 417 deletions

View File

@@ -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',

View File

@@ -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>

View File

@@ -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) {

View File

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

View File

@@ -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';