Refactor content handling in GenerateContentFunction and update related models and serializers
- Enhanced GenerateContentFunction to save content in a dedicated Content model, separating it from the Tasks model. - Updated Tasks model to remove SEO-related fields, now managed in the Content model. - Modified TasksSerializer to include new content fields and adjusted the API to reflect these changes. - Improved the auto_generate_content_task method to utilize the new save_output method for better content management. - Updated frontend components to display new content structure and metadata effectively.
This commit is contained in:
@@ -7,7 +7,7 @@ import re
|
|||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from igny8_core.ai.base import BaseAIFunction
|
from igny8_core.ai.base import BaseAIFunction
|
||||||
from igny8_core.modules.writer.models import Tasks
|
from igny8_core.modules.writer.models import Tasks, Content as TaskContent
|
||||||
from igny8_core.ai.ai_core import AICore
|
from igny8_core.ai.ai_core import AICore
|
||||||
from igny8_core.ai.validators import validate_tasks_exist
|
from igny8_core.ai.validators import validate_tasks_exist
|
||||||
from igny8_core.ai.prompts import PromptRegistry
|
from igny8_core.ai.prompts import PromptRegistry
|
||||||
@@ -188,69 +188,111 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
# Handle parsed response - can be dict (JSON) or string (plain text)
|
# Handle parsed response - can be dict (JSON) or string (plain text)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
# JSON response with structured fields
|
# JSON response with structured fields
|
||||||
content = parsed.get('content', '')
|
content_html = parsed.get('content', '')
|
||||||
title = parsed.get('title', task.title)
|
title = parsed.get('title') or task.title
|
||||||
meta_title = parsed.get('meta_title', title or task.title)
|
meta_title = parsed.get('meta_title') or title or task.title
|
||||||
meta_description = parsed.get('meta_description', '')
|
meta_description = parsed.get('meta_description', '')
|
||||||
word_count = parsed.get('word_count', 0)
|
word_count = parsed.get('word_count', 0)
|
||||||
primary_keyword = parsed.get('primary_keyword', '')
|
primary_keyword = parsed.get('primary_keyword', '')
|
||||||
secondary_keywords = parsed.get('secondary_keywords', [])
|
secondary_keywords = parsed.get('secondary_keywords', [])
|
||||||
tags = parsed.get('tags', [])
|
tags = parsed.get('tags', [])
|
||||||
categories = parsed.get('categories', [])
|
categories = parsed.get('categories', [])
|
||||||
|
content_status = parsed.get('status', 'draft')
|
||||||
else:
|
else:
|
||||||
# Plain text response (legacy)
|
# Plain text response (legacy)
|
||||||
content = str(parsed)
|
content_html = str(parsed)
|
||||||
title = task.title
|
title = task.title
|
||||||
meta_title = task.title
|
meta_title = task.meta_title or task.title
|
||||||
meta_description = (task.description or '')[:160] if task.description else ''
|
meta_description = task.meta_description or (task.description or '')[:160] if task.description else ''
|
||||||
word_count = 0
|
word_count = 0
|
||||||
primary_keyword = ''
|
primary_keyword = ''
|
||||||
secondary_keywords = []
|
secondary_keywords = []
|
||||||
tags = []
|
tags = []
|
||||||
categories = []
|
categories = []
|
||||||
|
content_status = 'draft'
|
||||||
|
|
||||||
# Calculate word count if not provided
|
# Calculate word count if not provided
|
||||||
if not word_count and content:
|
if not word_count and content_html:
|
||||||
text_for_counting = re.sub(r'<[^>]+>', '', content)
|
text_for_counting = re.sub(r'<[^>]+>', '', content_html)
|
||||||
word_count = len(text_for_counting.split())
|
word_count = len(text_for_counting.split())
|
||||||
|
|
||||||
# Update task with all fields
|
# Ensure related content record exists
|
||||||
if content:
|
content_record, _created = TaskContent.objects.get_or_create(
|
||||||
task.content = content
|
task=task,
|
||||||
if title and title != task.title:
|
defaults={
|
||||||
task.title = title
|
'account': task.account,
|
||||||
task.word_count = word_count
|
'site': task.site,
|
||||||
|
'sector': task.sector,
|
||||||
|
'html_content': content_html or '',
|
||||||
|
'word_count': word_count or 0,
|
||||||
|
'status': 'draft',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# SEO fields
|
# Update content fields
|
||||||
if meta_title:
|
if content_html:
|
||||||
task.meta_title = meta_title
|
content_record.html_content = content_html
|
||||||
elif not task.meta_title:
|
content_record.word_count = word_count or content_record.word_count or 0
|
||||||
task.meta_title = task.title # Fallback to title
|
content_record.title = title
|
||||||
|
content_record.meta_title = meta_title
|
||||||
|
content_record.meta_description = meta_description
|
||||||
|
content_record.primary_keyword = primary_keyword or ''
|
||||||
|
if isinstance(secondary_keywords, list):
|
||||||
|
content_record.secondary_keywords = secondary_keywords
|
||||||
|
elif secondary_keywords:
|
||||||
|
content_record.secondary_keywords = [secondary_keywords]
|
||||||
|
else:
|
||||||
|
content_record.secondary_keywords = []
|
||||||
|
if isinstance(tags, list):
|
||||||
|
content_record.tags = tags
|
||||||
|
elif tags:
|
||||||
|
content_record.tags = [tags]
|
||||||
|
else:
|
||||||
|
content_record.tags = []
|
||||||
|
if isinstance(categories, list):
|
||||||
|
content_record.categories = categories
|
||||||
|
elif categories:
|
||||||
|
content_record.categories = [categories]
|
||||||
|
else:
|
||||||
|
content_record.categories = []
|
||||||
|
|
||||||
if meta_description:
|
content_record.status = content_status or 'draft'
|
||||||
task.meta_description = meta_description
|
|
||||||
elif not task.meta_description and task.description:
|
|
||||||
task.meta_description = (task.description or '')[:160] # Fallback to description
|
|
||||||
|
|
||||||
if primary_keyword:
|
# Merge any extra fields into metadata (non-standard keys)
|
||||||
task.primary_keyword = primary_keyword
|
if isinstance(parsed, dict):
|
||||||
|
excluded_keys = {
|
||||||
|
'content',
|
||||||
|
'title',
|
||||||
|
'meta_title',
|
||||||
|
'meta_description',
|
||||||
|
'primary_keyword',
|
||||||
|
'secondary_keywords',
|
||||||
|
'tags',
|
||||||
|
'categories',
|
||||||
|
'word_count',
|
||||||
|
'status',
|
||||||
|
}
|
||||||
|
extra_meta = {k: v for k, v in parsed.items() if k not in excluded_keys}
|
||||||
|
existing_meta = content_record.metadata or {}
|
||||||
|
existing_meta.update(extra_meta)
|
||||||
|
content_record.metadata = existing_meta
|
||||||
|
|
||||||
if secondary_keywords:
|
# Align foreign keys to ensure consistency
|
||||||
task.secondary_keywords = secondary_keywords if isinstance(secondary_keywords, list) else []
|
content_record.account = task.account
|
||||||
|
content_record.site = task.site
|
||||||
|
content_record.sector = task.sector
|
||||||
|
content_record.task = task
|
||||||
|
|
||||||
if tags:
|
content_record.save()
|
||||||
task.tags = tags if isinstance(tags, list) else []
|
|
||||||
|
|
||||||
if categories:
|
# Update task status - keep task data intact but mark as completed
|
||||||
task.categories = categories if isinstance(categories, list) else []
|
task.status = 'completed'
|
||||||
|
task.save(update_fields=['status', 'updated_at'])
|
||||||
task.status = 'draft'
|
|
||||||
task.save()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'count': 1,
|
'count': 1,
|
||||||
'tasks_updated': 1,
|
'tasks_updated': 1,
|
||||||
'word_count': word_count
|
'word_count': content_record.word_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Generated manually for adding seed_keyword relationship to Keywords
|
# Generated manually for adding seed_keyword relationship to Keywords
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -11,76 +10,7 @@ class Migration(migrations.Migration):
|
|||||||
('planner', '0003_alter_clusters_sector_alter_clusters_site_and_more'),
|
('planner', '0003_alter_clusters_sector_alter_clusters_site_and_more'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
# Duplicate of planner.0006_add_seed_keyword_to_keywords.
|
||||||
# Remove old fields (keyword, volume, difficulty, intent)
|
# This migration is kept as a no-op to avoid applying the schema changes twice.
|
||||||
migrations.RemoveField(
|
operations = []
|
||||||
model_name='keywords',
|
|
||||||
name='keyword',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='volume',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='difficulty',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='intent',
|
|
||||||
),
|
|
||||||
# Add seed_keyword FK
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='seed_keyword',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
help_text='Reference to the global seed keyword',
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name='site_keywords',
|
|
||||||
to='igny8_core_auth.seedkeyword',
|
|
||||||
null=True # Temporarily nullable for migration
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# Add override fields
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='volume_override',
|
|
||||||
field=models.IntegerField(blank=True, help_text='Site-specific volume override (uses seed_keyword.volume if not set)', null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='difficulty_override',
|
|
||||||
field=models.IntegerField(blank=True, help_text='Site-specific difficulty override (uses seed_keyword.difficulty if not set)', null=True),
|
|
||||||
),
|
|
||||||
# Make seed_keyword required (after data migration if needed)
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='keywords',
|
|
||||||
name='seed_keyword',
|
|
||||||
field=models.ForeignKey(
|
|
||||||
help_text='Reference to the global seed keyword',
|
|
||||||
on_delete=django.db.models.deletion.PROTECT,
|
|
||||||
related_name='site_keywords',
|
|
||||||
to='igny8_core_auth.seedkeyword'
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# Add unique constraint
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='keywords',
|
|
||||||
unique_together={('seed_keyword', 'site', 'sector')},
|
|
||||||
),
|
|
||||||
# Update indexes
|
|
||||||
migrations.AlterIndexTogether(
|
|
||||||
name='keywords',
|
|
||||||
index_together=set(),
|
|
||||||
),
|
|
||||||
# Add new indexes
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='keywords',
|
|
||||||
index=models.Index(fields=['seed_keyword'], name='igny8_keyw_seed_k_12345_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='keywords',
|
|
||||||
index=models.Index(fields=['seed_keyword', 'site', 'sector'], name='igny8_keyw_seed_si_67890_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_list(value):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return list(value)
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def forwards(apps, schema_editor):
|
||||||
|
Tasks = apps.get_model('writer', 'Tasks')
|
||||||
|
Content = apps.get_model('writer', 'Content')
|
||||||
|
|
||||||
|
for task in Tasks.objects.all():
|
||||||
|
account_id = getattr(task, 'account_id', None)
|
||||||
|
if account_id is None and getattr(task, 'account', None):
|
||||||
|
account_id = task.account.id
|
||||||
|
|
||||||
|
site_id = getattr(task, 'site_id', None)
|
||||||
|
if site_id is None and getattr(task, 'site', None):
|
||||||
|
site_id = task.site.id
|
||||||
|
|
||||||
|
sector_id = getattr(task, 'sector_id', None)
|
||||||
|
if sector_id is None and getattr(task, 'sector', None):
|
||||||
|
sector_id = task.sector.id if task.sector else None
|
||||||
|
|
||||||
|
tenant_id = getattr(task, 'tenant_id', None)
|
||||||
|
if tenant_id is None and getattr(task, 'tenant', None):
|
||||||
|
tenant_id = task.tenant.id
|
||||||
|
|
||||||
|
# Prepare defaults for new content record
|
||||||
|
defaults = {
|
||||||
|
'html_content': task.content or '',
|
||||||
|
'word_count': task.word_count or 0,
|
||||||
|
'title': getattr(task, 'title', None),
|
||||||
|
'meta_title': getattr(task, 'meta_title', None),
|
||||||
|
'meta_description': getattr(task, 'meta_description', None),
|
||||||
|
'primary_keyword': getattr(task, 'primary_keyword', None),
|
||||||
|
'secondary_keywords': _normalize_list(getattr(task, 'secondary_keywords', [])),
|
||||||
|
'tags': _normalize_list(getattr(task, 'tags', [])),
|
||||||
|
'categories': _normalize_list(getattr(task, 'categories', [])),
|
||||||
|
'status': 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
content_record = Content.objects.filter(task_id=task.id).first()
|
||||||
|
created = False
|
||||||
|
if not content_record:
|
||||||
|
content_record = Content(task_id=task.id, **defaults)
|
||||||
|
created = True
|
||||||
|
|
||||||
|
# Update existing records with the latest information
|
||||||
|
if not created:
|
||||||
|
if task.content:
|
||||||
|
content_record.html_content = task.content
|
||||||
|
if task.word_count:
|
||||||
|
content_record.word_count = task.word_count
|
||||||
|
|
||||||
|
if getattr(task, 'title', None):
|
||||||
|
content_record.title = task.title
|
||||||
|
if getattr(task, 'meta_title', None):
|
||||||
|
content_record.meta_title = task.meta_title
|
||||||
|
if getattr(task, 'meta_description', None):
|
||||||
|
content_record.meta_description = task.meta_description
|
||||||
|
|
||||||
|
if hasattr(task, 'primary_keyword'):
|
||||||
|
content_record.primary_keyword = task.primary_keyword or ''
|
||||||
|
if hasattr(task, 'secondary_keywords'):
|
||||||
|
content_record.secondary_keywords = _normalize_list(task.secondary_keywords)
|
||||||
|
if hasattr(task, 'tags'):
|
||||||
|
content_record.tags = _normalize_list(task.tags)
|
||||||
|
if hasattr(task, 'categories'):
|
||||||
|
content_record.categories = _normalize_list(task.categories)
|
||||||
|
|
||||||
|
if not content_record.status:
|
||||||
|
content_record.status = 'draft'
|
||||||
|
|
||||||
|
# Ensure account/site/sector alignment (save() will also set this)
|
||||||
|
if account_id:
|
||||||
|
content_record.account_id = account_id
|
||||||
|
if site_id:
|
||||||
|
content_record.site_id = site_id
|
||||||
|
if sector_id:
|
||||||
|
content_record.sector_id = sector_id
|
||||||
|
if tenant_id:
|
||||||
|
content_record.tenant_id = tenant_id
|
||||||
|
|
||||||
|
# Preserve existing metadata but ensure it's a dict
|
||||||
|
metadata = content_record.metadata or {}
|
||||||
|
content_record.metadata = metadata
|
||||||
|
|
||||||
|
content_record.save()
|
||||||
|
|
||||||
|
|
||||||
|
def backwards(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Reverse data migration is intentionally left as a no-op because
|
||||||
|
reverting would require reintroducing the removed fields on Tasks.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('writer', '0004_add_content_seo_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='categories',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='List of categories'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='meta_description',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='meta_title',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='primary_keyword',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='secondary_keywords',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='List of secondary keywords'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(default='draft', help_text='Content workflow status (draft, review, published, etc.)', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='tags',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='List of tags'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='content',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(forwards, backwards),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='tasks',
|
||||||
|
name='categories',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='tasks',
|
||||||
|
name='primary_keyword',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='tasks',
|
||||||
|
name='secondary_keywords',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='tasks',
|
||||||
|
name='tags',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -65,11 +65,6 @@ class Tasks(SiteSectorBaseModel):
|
|||||||
# SEO fields
|
# SEO fields
|
||||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
meta_description = models.TextField(blank=True, null=True)
|
meta_description = models.TextField(blank=True, null=True)
|
||||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
|
||||||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
|
||||||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
|
||||||
|
|
||||||
# WordPress integration
|
# WordPress integration
|
||||||
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
||||||
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
||||||
@@ -108,6 +103,14 @@ class Content(SiteSectorBaseModel):
|
|||||||
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
||||||
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
||||||
|
title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
meta_description = models.TextField(blank=True, null=True)
|
||||||
|
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||||||
|
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||||||
|
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||||||
|
status = models.CharField(max_length=50, default='draft', help_text="Content workflow status (draft, review, published, etc.)")
|
||||||
generated_at = models.DateTimeField(auto_now_add=True)
|
generated_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
idea_title = serializers.SerializerMethodField()
|
idea_title = serializers.SerializerMethodField()
|
||||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
site_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
sector_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
|
content_html = serializers.SerializerMethodField()
|
||||||
|
content_primary_keyword = serializers.SerializerMethodField()
|
||||||
|
content_secondary_keywords = serializers.SerializerMethodField()
|
||||||
|
content_tags = serializers.SerializerMethodField()
|
||||||
|
content_categories = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tasks
|
model = Tasks
|
||||||
@@ -30,10 +35,11 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
'word_count',
|
'word_count',
|
||||||
'meta_title',
|
'meta_title',
|
||||||
'meta_description',
|
'meta_description',
|
||||||
'primary_keyword',
|
'content_html',
|
||||||
'secondary_keywords',
|
'content_primary_keyword',
|
||||||
'tags',
|
'content_secondary_keywords',
|
||||||
'categories',
|
'content_tags',
|
||||||
|
'content_categories',
|
||||||
'assigned_post_id',
|
'assigned_post_id',
|
||||||
'post_url',
|
'post_url',
|
||||||
'created_at',
|
'created_at',
|
||||||
@@ -75,6 +81,32 @@ class TasksSerializer(serializers.ModelSerializer):
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_content_record(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.content_record
|
||||||
|
except AttributeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_content_html(self, obj):
|
||||||
|
record = self._get_content_record(obj)
|
||||||
|
return record.html_content if record else None
|
||||||
|
|
||||||
|
def get_content_primary_keyword(self, obj):
|
||||||
|
record = self._get_content_record(obj)
|
||||||
|
return record.primary_keyword if record else None
|
||||||
|
|
||||||
|
def get_content_secondary_keywords(self, obj):
|
||||||
|
record = self._get_content_record(obj)
|
||||||
|
return record.secondary_keywords if record else []
|
||||||
|
|
||||||
|
def get_content_tags(self, obj):
|
||||||
|
record = self._get_content_record(obj)
|
||||||
|
return record.tags if record else []
|
||||||
|
|
||||||
|
def get_content_categories(self, obj):
|
||||||
|
record = self._get_content_record(obj)
|
||||||
|
return record.categories if record else []
|
||||||
|
|
||||||
|
|
||||||
class ImagesSerializer(serializers.ModelSerializer):
|
class ImagesSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Images model"""
|
"""Serializer for Images model"""
|
||||||
@@ -122,6 +154,14 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'html_content',
|
'html_content',
|
||||||
'word_count',
|
'word_count',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'title',
|
||||||
|
'meta_title',
|
||||||
|
'meta_description',
|
||||||
|
'primary_keyword',
|
||||||
|
'secondary_keywords',
|
||||||
|
'tags',
|
||||||
|
'categories',
|
||||||
|
'status',
|
||||||
'generated_at',
|
'generated_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'account_id',
|
'account_id',
|
||||||
|
|||||||
@@ -632,103 +632,122 @@ def auto_generate_content_task(self, task_ids: List[int], account_id: int = None
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize content from different AI response formats
|
# Parse JSON response using GenerateContentFunction's parse_response method
|
||||||
logger.info(f" * Normalizing content (length: {len(content)} chars)...")
|
logger.info(f" * Parsing AI response (length: {len(content)} chars)...")
|
||||||
try:
|
try:
|
||||||
from igny8_core.utils.content_normalizer import normalize_content
|
from igny8_core.ai.functions.generate_content import GenerateContentFunction
|
||||||
normalized = normalize_content(content)
|
fn = GenerateContentFunction()
|
||||||
normalized_content = normalized['normalized_content']
|
parsed_response = fn.parse_response(content)
|
||||||
content_type = normalized['content_type']
|
|
||||||
has_structure = normalized['has_structure']
|
|
||||||
original_format = normalized['original_format']
|
|
||||||
|
|
||||||
logger.info(f" * ✓ Content normalized:")
|
logger.info(f" * ✓ Response parsed:")
|
||||||
logger.info(f" - Original format: {original_format}")
|
logger.info(f" - Type: {type(parsed_response).__name__}")
|
||||||
logger.info(f" - Content type: {content_type}")
|
if isinstance(parsed_response, dict):
|
||||||
logger.info(f" - Has structure: {has_structure}")
|
logger.info(f" - Keys: {list(parsed_response.keys())}")
|
||||||
logger.info(f" - Normalized length: {len(normalized_content)} chars")
|
logger.info(f" - Has title: {bool(parsed_response.get('title'))}")
|
||||||
logger.info(f" - Normalized preview (first 200 chars): {normalized_content[:200]}...")
|
logger.info(f" - Has meta_title: {bool(parsed_response.get('meta_title'))}")
|
||||||
|
logger.info(f" - Has primary_keyword: {bool(parsed_response.get('primary_keyword'))}")
|
||||||
|
logger.info(f" - Has secondary_keywords: {bool(parsed_response.get('secondary_keywords'))}")
|
||||||
|
logger.info(f" - Has tags: {bool(parsed_response.get('tags'))}")
|
||||||
|
logger.info(f" - Has categories: {bool(parsed_response.get('categories'))}")
|
||||||
|
logger.info(f" - Content length: {len(parsed_response.get('content', ''))} chars")
|
||||||
|
else:
|
||||||
|
logger.info(f" - Content length: {len(str(parsed_response))} chars")
|
||||||
|
|
||||||
# Use normalized content
|
# Use parsed response for saving
|
||||||
content = normalized_content
|
parsed_data = parsed_response
|
||||||
|
|
||||||
except Exception as norm_error:
|
except Exception as parse_error:
|
||||||
logger.warning(f" * ⚠️ Content normalization failed: {type(norm_error).__name__}: {str(norm_error)}")
|
logger.warning(f" * ⚠️ JSON parsing failed: {type(parse_error).__name__}: {str(parse_error)}")
|
||||||
logger.warning(f" * Using original content as-is")
|
logger.warning(f" * Treating as plain text content")
|
||||||
# Continue with original content
|
# Fallback to plain text
|
||||||
|
parsed_data = {'content': content}
|
||||||
|
|
||||||
|
# Normalize content from parsed response
|
||||||
|
content_to_normalize = parsed_data.get('content', '') if isinstance(parsed_data, dict) else str(parsed_data)
|
||||||
|
if content_to_normalize:
|
||||||
|
logger.info(f" * Normalizing content (length: {len(content_to_normalize)} chars)...")
|
||||||
|
try:
|
||||||
|
from igny8_core.utils.content_normalizer import normalize_content
|
||||||
|
normalized = normalize_content(content_to_normalize)
|
||||||
|
normalized_content = normalized['normalized_content']
|
||||||
|
content_type = normalized['content_type']
|
||||||
|
has_structure = normalized['has_structure']
|
||||||
|
original_format = normalized['original_format']
|
||||||
|
|
||||||
|
logger.info(f" * ✓ Content normalized:")
|
||||||
|
logger.info(f" - Original format: {original_format}")
|
||||||
|
logger.info(f" - Content type: {content_type}")
|
||||||
|
logger.info(f" - Has structure: {has_structure}")
|
||||||
|
logger.info(f" - Normalized length: {len(normalized_content)} chars")
|
||||||
|
logger.info(f" - Normalized preview (first 200 chars): {normalized_content[:200]}...")
|
||||||
|
|
||||||
|
# Update parsed_data with normalized content
|
||||||
|
if isinstance(parsed_data, dict):
|
||||||
|
parsed_data['content'] = normalized_content
|
||||||
|
else:
|
||||||
|
parsed_data = {'content': normalized_content}
|
||||||
|
|
||||||
|
except Exception as norm_error:
|
||||||
|
logger.warning(f" * ⚠️ Content normalization failed: {type(norm_error).__name__}: {str(norm_error)}")
|
||||||
|
logger.warning(f" * Using original content as-is")
|
||||||
|
# Continue with original content
|
||||||
|
|
||||||
except Exception as ai_error:
|
except Exception as ai_error:
|
||||||
logger.error(f" * ✗ EXCEPTION during AI API call: {type(ai_error).__name__}: {str(ai_error)}")
|
logger.error(f" * ✗ EXCEPTION during AI API call: {type(ai_error).__name__}: {str(ai_error)}")
|
||||||
logger.error(f" * Task ID: {task.id}", exc_info=True)
|
logger.error(f" * Task ID: {task.id}", exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate word count from normalized content
|
# Use GenerateContentFunction's save_output method to properly save all fields
|
||||||
# Remove HTML tags for word count
|
logger.info(" - Saving content to database using GenerateContentFunction.save_output()...")
|
||||||
text_for_counting = re.sub(r'<[^>]+>', '', content)
|
|
||||||
word_count = len(text_for_counting.split())
|
|
||||||
logger.info(f" * ✓ Word count calculated: {word_count} words (from normalized HTML)")
|
|
||||||
|
|
||||||
# Update progress: Saving content
|
|
||||||
add_step('SAVE', 'success', f"Saving content for '{task.title}' ({word_count} words)...", 'request')
|
|
||||||
save_pct = 85 + int((idx / total_tasks) * 10) # 85-95% for saving
|
|
||||||
self.update_state(
|
|
||||||
state='PROGRESS',
|
|
||||||
meta={
|
|
||||||
'current': idx + 1,
|
|
||||||
'total': total_tasks,
|
|
||||||
'percentage': save_pct,
|
|
||||||
'message': f"Saving content for '{task.title}' ({word_count} words)...",
|
|
||||||
'phase': 'SAVE',
|
|
||||||
'current_item': task.title,
|
|
||||||
'request_steps': request_steps,
|
|
||||||
'response_steps': response_steps
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========================================================================
|
|
||||||
# DATABASE SAVE PHASE - Detailed logging
|
|
||||||
# ========================================================================
|
|
||||||
logger.info(" - Saving content to database...")
|
|
||||||
try:
|
try:
|
||||||
# Update task
|
from igny8_core.ai.functions.generate_content import GenerateContentFunction
|
||||||
logger.info(f" * Updating task {task.id} fields...")
|
fn = GenerateContentFunction()
|
||||||
task.content = content
|
|
||||||
logger.info(f" - content: {len(content)} chars")
|
|
||||||
|
|
||||||
task.word_count = word_count
|
# Save using the proper save_output method which handles all fields
|
||||||
|
save_result = fn.save_output(parsed_data, [task], account)
|
||||||
|
|
||||||
|
# Get word count from save result or calculate
|
||||||
|
word_count = save_result.get('word_count', 0)
|
||||||
|
if not word_count and isinstance(parsed_data, dict):
|
||||||
|
content_for_count = parsed_data.get('content', '')
|
||||||
|
if content_for_count:
|
||||||
|
text_for_counting = re.sub(r'<[^>]+>', '', content_for_count)
|
||||||
|
word_count = len(text_for_counting.split())
|
||||||
|
|
||||||
|
logger.info(f" * ✓ Task saved successfully using save_output()")
|
||||||
|
logger.info(f" - tasks_updated: {save_result.get('tasks_updated', 0)}")
|
||||||
logger.info(f" - word_count: {word_count}")
|
logger.info(f" - word_count: {word_count}")
|
||||||
|
|
||||||
task.meta_title = task.title # Use title as meta title for now
|
# Log all fields that were saved
|
||||||
logger.info(f" - meta_title: {task.title}")
|
logger.info(f" * Saved fields:")
|
||||||
|
logger.info(f" - task_id: {task.id}")
|
||||||
|
logger.info(f" - task_status: {task.status}")
|
||||||
|
if isinstance(parsed_data, dict):
|
||||||
|
logger.info(f" - content_title: {parsed_data.get('title') or task.title}")
|
||||||
|
logger.info(f" - content_primary_keyword: {parsed_data.get('primary_keyword') or 'N/A'}")
|
||||||
|
logger.info(f" - content_secondary_keywords: {len(parsed_data.get('secondary_keywords') or [])} items")
|
||||||
|
logger.info(f" - content_tags: {len(parsed_data.get('tags') or [])} items")
|
||||||
|
logger.info(f" - content_categories: {len(parsed_data.get('categories') or [])} items")
|
||||||
|
logger.info(f" - content_word_count: {word_count}")
|
||||||
|
|
||||||
task.meta_description = (task.description or '')[:160] # Truncate to 160 chars
|
# Update progress: Saving content
|
||||||
logger.info(f" - meta_description: {len(task.meta_description)} chars")
|
add_step('SAVE', 'success', f"Content saved for '{task.title}' ({word_count} words)...", 'response')
|
||||||
|
save_pct = 85 + int((idx / total_tasks) * 10) # 85-95% for saving
|
||||||
|
self.update_state(
|
||||||
|
state='PROGRESS',
|
||||||
|
meta={
|
||||||
|
'current': idx + 1,
|
||||||
|
'total': total_tasks,
|
||||||
|
'percentage': save_pct,
|
||||||
|
'message': f"Content saved for '{task.title}' ({word_count} words)...",
|
||||||
|
'phase': 'SAVE',
|
||||||
|
'current_item': task.title,
|
||||||
|
'request_steps': request_steps,
|
||||||
|
'response_steps': response_steps
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
old_status = task.status
|
tasks_updated += save_result.get('tasks_updated', 0)
|
||||||
task.status = 'draft' # Update status from queued to draft
|
|
||||||
logger.info(f" - status: {old_status} → {task.status}")
|
|
||||||
|
|
||||||
# Log all fields being saved
|
|
||||||
logger.info(f" * Task fields to save:")
|
|
||||||
logger.info(f" - id: {task.id}")
|
|
||||||
logger.info(f" - title: {task.title}")
|
|
||||||
logger.info(f" - account_id: {task.account_id}")
|
|
||||||
logger.info(f" - site_id: {task.site_id}")
|
|
||||||
logger.info(f" - sector_id: {task.sector_id}")
|
|
||||||
logger.info(f" - cluster_id: {task.cluster_id}")
|
|
||||||
logger.info(f" - idea_id: {task.idea_id}")
|
|
||||||
logger.info(f" - content length: {len(task.content)}")
|
|
||||||
logger.info(f" - word_count: {task.word_count}")
|
|
||||||
|
|
||||||
# Save to database
|
|
||||||
logger.info(f" * Executing task.save()...")
|
|
||||||
task.save()
|
|
||||||
logger.info(f" * ✓ Task saved successfully to database")
|
|
||||||
|
|
||||||
# Mark save step as complete
|
|
||||||
add_step('SAVE', 'success', f"Content saved for '{task.title}'", 'response')
|
|
||||||
|
|
||||||
tasks_updated += 1
|
|
||||||
logger.info(f" * ✓ Task {task.id} content generation completed successfully")
|
logger.info(f" * ✓ Task {task.id} content generation completed successfully")
|
||||||
|
|
||||||
except Exception as save_error:
|
except Exception as save_error:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
"""
|
"""
|
||||||
ViewSet for managing tasks with CRUD operations
|
ViewSet for managing tasks with CRUD operations
|
||||||
"""
|
"""
|
||||||
queryset = Tasks.objects.all()
|
queryset = Tasks.objects.select_related('content_record')
|
||||||
serializer_class = TasksSerializer
|
serializer_class = TasksSerializer
|
||||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||||
|
|
||||||
|
|||||||
@@ -169,33 +169,6 @@ export default function ProgressModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
{details && (
|
|
||||||
<div className="mb-6 space-y-2">
|
|
||||||
{details.currentItem && (
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span className="font-medium">Current:</span>{' '}
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{details.currentItem}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{details.total > 0 && (
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<span className="font-medium">Progress:</span>{' '}
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{details.current} of {details.total} completed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{details.phase && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
|
||||||
Phase: {details.phase}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Function ID and Task ID (for debugging) */}
|
{/* Function ID and Task ID (for debugging) */}
|
||||||
{(fullFunctionId || taskId) && import.meta.env.DEV && (
|
{(fullFunctionId || taskId) && import.meta.env.DEV && (
|
||||||
<div className="mb-4 space-y-1 text-xs text-gray-400 dark:text-gray-600">
|
<div className="mb-4 space-y-1 text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
|||||||
@@ -97,18 +97,11 @@ export const createTasksPageConfig = (
|
|||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
...titleColumn,
|
...titleColumn,
|
||||||
key: 'title',
|
|
||||||
label: 'Title',
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'title',
|
sortField: 'title',
|
||||||
toggleable: true, // Enable toggle for this column
|
toggleable: true,
|
||||||
toggleContentKey: 'content', // Use content field for toggle (fallback to description if content not available)
|
toggleContentKey: 'content_html',
|
||||||
toggleContentLabel: 'Generated Content', // Label for expanded content
|
toggleContentLabel: 'Generated Content',
|
||||||
render: (_value: string, row: Task) => (
|
|
||||||
<span className="text-gray-800 dark:text-white font-medium">
|
|
||||||
{row.meta_title || row.title || '-'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// Sector column - only show when viewing all sectors
|
// Sector column - only show when viewing all sectors
|
||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
@@ -173,85 +166,6 @@ export const createTasksPageConfig = (
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'keywords',
|
|
||||||
label: 'Keywords',
|
|
||||||
sortable: false,
|
|
||||||
width: '250px',
|
|
||||||
render: (_value: any, row: Task) => {
|
|
||||||
const keywords: React.ReactNode[] = [];
|
|
||||||
|
|
||||||
// Primary keyword as info badge
|
|
||||||
if (row.primary_keyword) {
|
|
||||||
keywords.push(
|
|
||||||
<Badge key="primary" color="info" size="sm" variant="light" className="mr-1 mb-1">
|
|
||||||
{row.primary_keyword}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secondary keywords as light badges
|
|
||||||
if (row.secondary_keywords && Array.isArray(row.secondary_keywords) && row.secondary_keywords.length > 0) {
|
|
||||||
row.secondary_keywords.forEach((keyword, index) => {
|
|
||||||
if (keyword) {
|
|
||||||
keywords.push(
|
|
||||||
<Badge key={`secondary-${index}`} color="light" size="sm" variant="light" className="mr-1 mb-1">
|
|
||||||
{keyword}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return keywords.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{keywords}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">-</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tags',
|
|
||||||
label: 'Tags',
|
|
||||||
sortable: false,
|
|
||||||
width: '200px',
|
|
||||||
render: (_value: any, row: Task) => {
|
|
||||||
if (row.tags && Array.isArray(row.tags) && row.tags.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{row.tags.map((tag, index) => (
|
|
||||||
<Badge key={index} color="light" size="sm" variant="light">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-gray-400">-</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'categories',
|
|
||||||
label: 'Categories',
|
|
||||||
sortable: false,
|
|
||||||
width: '200px',
|
|
||||||
render: (_value: any, row: Task) => {
|
|
||||||
if (row.categories && Array.isArray(row.categories) && row.categories.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{row.categories.map((category, index) => (
|
|
||||||
<Badge key={index} color="light" size="sm" variant="light">
|
|
||||||
{category}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-gray-400">-</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
...wordCountColumn,
|
...wordCountColumn,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import { useState, useEffect } from 'react';
|
|||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
import { fetchContent, Content as ContentType } from '../../services/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||||
|
draft: 'warning',
|
||||||
|
review: 'info',
|
||||||
|
published: 'success',
|
||||||
|
completed: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
export default function Content() {
|
export default function Content() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [content, setContent] = useState<ContentType[]>([]);
|
const [content, setContent] = useState<ContentType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadContent();
|
loadContent();
|
||||||
@@ -26,44 +34,174 @@ export default function Content() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string) =>
|
||||||
|
new Date(value).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderBadgeList = (items?: string[], emptyLabel = '-') => {
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return <span className="text-gray-400 dark:text-gray-500">{emptyLabel}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Badge key={`${item}-${index}`} color="light" size="sm" variant="light">
|
||||||
|
{item}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Content" />
|
<PageMeta title="Content" />
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View all generated content</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">Review AI-generated drafts and metadata</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500">Loading...</div>
|
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
) : content.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No content generated yet. Run an AI content job to see drafts here.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900">
|
||||||
{content.map((item: ContentType) => (
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||||
<Card key={item.id} className="p-6">
|
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<tr>
|
||||||
<div>
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
Title
|
||||||
Task #{item.task}
|
</th>
|
||||||
</h3>
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
Primary Keyword
|
||||||
Generated: {new Date(item.generated_at).toLocaleString()}
|
</th>
|
||||||
</p>
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Secondary Keywords
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
</th>
|
||||||
{item.word_count} words
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Tags
|
||||||
</div>
|
</th>
|
||||||
<div
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
className="prose dark:prose-invert max-w-none"
|
Categories
|
||||||
dangerouslySetInnerHTML={{ __html: item.html_content }}
|
</th>
|
||||||
/>
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
</Card>
|
Word Count
|
||||||
))}
|
</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Generated
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Content
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||||
|
{content.map((item) => {
|
||||||
|
const isExpanded = expandedId === item.id;
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="bg-white dark:bg-gray-900">
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{item.meta_title || item.title || `Task #${item.task}`}
|
||||||
|
</div>
|
||||||
|
{item.meta_description && (
|
||||||
|
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{item.meta_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
{item.primary_keyword ? (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
{item.primary_keyword}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
{renderBadgeList(item.secondary_keywords)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
{renderBadgeList(item.tags)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
{renderBadgeList(item.categories)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top text-gray-700 dark:text-gray-300">
|
||||||
|
{item.word_count?.toLocaleString?.() ?? '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top">
|
||||||
|
<Badge
|
||||||
|
color={statusColors[item.status] || 'primary'}
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{item.status?.replace('_', ' ') || 'draft'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(item.generated_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 align-top text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||||
|
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Hide' : 'View'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{content.map((item) =>
|
||||||
|
expandedId === item.id ? (
|
||||||
|
<div
|
||||||
|
key={`expanded-${item.id}`}
|
||||||
|
className="mt-6 rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900 p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{item.meta_title || item.title || `Task #${item.task}`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Generated {formatDate(item.generated_at)} • Task #{item.task}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(null)}
|
||||||
|
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<HTMLContentRenderer
|
||||||
|
content={item.html_content}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1071,10 +1071,11 @@ export interface Task {
|
|||||||
word_count: number;
|
word_count: number;
|
||||||
meta_title?: string | null;
|
meta_title?: string | null;
|
||||||
meta_description?: string | null;
|
meta_description?: string | null;
|
||||||
primary_keyword?: string | null;
|
content_html?: string | null;
|
||||||
secondary_keywords?: string[] | null;
|
content_primary_keyword?: string | null;
|
||||||
tags?: string[] | null;
|
content_secondary_keywords?: string[];
|
||||||
categories?: string[] | null;
|
content_tags?: string[];
|
||||||
|
content_categories?: string[];
|
||||||
assigned_post_id?: number | null;
|
assigned_post_id?: number | null;
|
||||||
post_url?: string | null;
|
post_url?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -1841,6 +1842,15 @@ export async function deleteAuthorProfile(id: number): Promise<void> {
|
|||||||
export interface Content {
|
export interface Content {
|
||||||
id: number;
|
id: number;
|
||||||
task: number;
|
task: number;
|
||||||
|
task_title?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
meta_title?: string | null;
|
||||||
|
meta_description?: string | null;
|
||||||
|
primary_keyword?: string | null;
|
||||||
|
secondary_keywords?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
status: string;
|
||||||
html_content: string;
|
html_content: string;
|
||||||
word_count: number;
|
word_count: number;
|
||||||
metadata: Record<string, any>;
|
metadata: Record<string, any>;
|
||||||
|
|||||||
Reference in New Issue
Block a user