This commit is contained in:
alorig
2025-11-18 05:21:27 +05:00
parent a0f3e3a778
commit 9a6d47b91b
34 changed files with 3258 additions and 9 deletions

View File

@@ -0,0 +1,55 @@
# Generated manually for Phase 6: Site Model Extensions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0014_remove_plan_operation_limits_phase0'),
]
operations = [
migrations.AddField(
model_name='site',
name='site_type',
field=models.CharField(
choices=[
('marketing', 'Marketing Site'),
('ecommerce', 'Ecommerce Site'),
('blog', 'Blog'),
('portfolio', 'Portfolio'),
('corporate', 'Corporate'),
],
db_index=True,
default='marketing',
help_text='Type of site',
max_length=50
),
),
migrations.AddField(
model_name='site',
name='hosting_type',
field=models.CharField(
choices=[
('igny8_sites', 'IGNY8 Sites'),
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('multi', 'Multi-Destination'),
],
db_index=True,
default='igny8_sites',
help_text='Target hosting platform',
max_length=50
),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['site_type'], name='igny8_sites_site_ty_123abc_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['hosting_type'], name='igny8_sites_hostin_456def_idx'),
),
]

View File

@@ -213,11 +213,43 @@ class Site(AccountBaseModel):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# WordPress integration fields
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
# WordPress integration fields (legacy - use SiteIntegration instead)
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL (legacy - use SiteIntegration)")
wp_username = models.CharField(max_length=255, blank=True, null=True)
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
# Site type and hosting (Phase 6)
SITE_TYPE_CHOICES = [
('marketing', 'Marketing Site'),
('ecommerce', 'Ecommerce Site'),
('blog', 'Blog'),
('portfolio', 'Portfolio'),
('corporate', 'Corporate'),
]
HOSTING_TYPE_CHOICES = [
('igny8_sites', 'IGNY8 Sites'),
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('multi', 'Multi-Destination'),
]
site_type = models.CharField(
max_length=50,
choices=SITE_TYPE_CHOICES,
default='marketing',
db_index=True,
help_text="Type of site"
)
hosting_type = models.CharField(
max_length=50,
choices=HOSTING_TYPE_CHOICES,
default='igny8_sites',
db_index=True,
help_text="Target hosting platform"
)
class Meta:
db_table = 'igny8_sites'
unique_together = [['account', 'slug']] # Slug unique per account
@@ -226,6 +258,8 @@ class Site(AccountBaseModel):
models.Index(fields=['account', 'is_active']),
models.Index(fields=['account', 'status']),
models.Index(fields=['industry']),
models.Index(fields=['site_type']),
models.Index(fields=['hosting_type']),
]
def __str__(self):

View File

@@ -0,0 +1,12 @@
"""
Integration App Configuration
"""
from django.apps import AppConfig
class IntegrationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.business.integration'
label = 'integration'
verbose_name = 'Integration'

View File

@@ -0,0 +1,60 @@
# Generated manually for Phase 6: Integration System
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0014_remove_plan_operation_limits_phase0'),
]
operations = [
migrations.CreateModel(
name='SiteIntegration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom API')], db_index=True, help_text="Platform name: 'wordpress', 'shopify', 'custom'", max_length=50)),
('platform_type', models.CharField(choices=[('cms', 'CMS'), ('ecommerce', 'Ecommerce'), ('custom_api', 'Custom API')], default='cms', help_text="Platform type: 'cms', 'ecommerce', 'custom_api'", max_length=50)),
('config_json', models.JSONField(default=dict, help_text='Platform-specific configuration (URLs, endpoints, etc.)')),
('credentials_json', models.JSONField(default=dict, help_text='Encrypted credentials (API keys, tokens, etc.)')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this integration is active')),
('sync_enabled', models.BooleanField(default=False, help_text='Whether two-way sync is enabled')),
('last_sync_at', models.DateTimeField(blank=True, help_text='Last successful sync timestamp', null=True)),
('sync_status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed'), ('pending', 'Pending'), ('syncing', 'Syncing')], db_index=True, default='pending', help_text='Current sync status', max_length=20)),
('sync_error', models.TextField(blank=True, help_text='Last sync error message', null=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
('site', models.ForeignKey(help_text='Site this integration belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='igny8_core_auth.site')),
],
options={
'db_table': 'igny8_site_integrations',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='siteintegration',
index=models.Index(fields=['site', 'platform'], name='igny8_integ_site_pl_123abc_idx'),
),
migrations.AddIndex(
model_name='siteintegration',
index=models.Index(fields=['site', 'is_active'], name='igny8_integ_site_is_456def_idx'),
),
migrations.AddIndex(
model_name='siteintegration',
index=models.Index(fields=['account', 'platform'], name='igny8_integ_account_789ghi_idx'),
),
migrations.AddIndex(
model_name='siteintegration',
index=models.Index(fields=['sync_status'], name='igny8_integ_sync_st_012jkl_idx'),
),
migrations.AlterUniqueTogether(
name='siteintegration',
unique_together={('site', 'platform')},
),
]

View File

@@ -0,0 +1,4 @@
"""
Integration Migrations
"""

View File

@@ -0,0 +1,131 @@
"""
Integration Models
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import AccountBaseModel
class SiteIntegration(AccountBaseModel):
"""
Store integration configurations for sites.
Each site can have multiple integrations (WordPress, Shopify, etc.).
"""
PLATFORM_CHOICES = [
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('custom', 'Custom API'),
]
PLATFORM_TYPE_CHOICES = [
('cms', 'CMS'),
('ecommerce', 'Ecommerce'),
('custom_api', 'Custom API'),
]
SYNC_STATUS_CHOICES = [
('success', 'Success'),
('failed', 'Failed'),
('pending', 'Pending'),
('syncing', 'Syncing'),
]
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='integrations',
help_text="Site this integration belongs to"
)
platform = models.CharField(
max_length=50,
choices=PLATFORM_CHOICES,
db_index=True,
help_text="Platform name: 'wordpress', 'shopify', 'custom'"
)
platform_type = models.CharField(
max_length=50,
choices=PLATFORM_TYPE_CHOICES,
default='cms',
help_text="Platform type: 'cms', 'ecommerce', 'custom_api'"
)
config_json = models.JSONField(
default=dict,
help_text="Platform-specific configuration (URLs, endpoints, etc.)"
)
# Credentials stored as JSON (encryption handled at application level)
credentials_json = models.JSONField(
default=dict,
help_text="Encrypted credentials (API keys, tokens, etc.)"
)
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether this integration is active"
)
sync_enabled = models.BooleanField(
default=False,
help_text="Whether two-way sync is enabled"
)
last_sync_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last successful sync timestamp"
)
sync_status = models.CharField(
max_length=20,
choices=SYNC_STATUS_CHOICES,
default='pending',
db_index=True,
help_text="Current sync status"
)
sync_error = models.TextField(
blank=True,
null=True,
help_text="Last sync error message"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'integration'
db_table = 'igny8_site_integrations'
ordering = ['-created_at']
unique_together = [['site', 'platform']]
indexes = [
models.Index(fields=['site', 'platform']),
models.Index(fields=['site', 'is_active']),
models.Index(fields=['account', 'platform']),
models.Index(fields=['sync_status']),
]
def __str__(self):
return f"{self.site.name} - {self.get_platform_display()}"
def get_credentials(self) -> dict:
"""
Get decrypted credentials.
In production, this should decrypt credentials_json.
For now, return as-is (encryption to be implemented).
"""
return self.credentials_json or {}
def set_credentials(self, credentials: dict):
"""
Set encrypted credentials.
In production, this should encrypt before storing.
For now, store as-is (encryption to be implemented).
"""
self.credentials_json = credentials

View File

@@ -0,0 +1,4 @@
"""
Integration Services
"""

View File

@@ -0,0 +1,191 @@
"""
Content Sync Service
Phase 6: Site Integration & Multi-Destination Publishing
Syncs content between IGNY8 and external platforms.
"""
import logging
from typing import Dict, Any, Optional, List
from igny8_core.business.integration.models import SiteIntegration
logger = logging.getLogger(__name__)
class ContentSyncService:
"""
Service for syncing content to/from external platforms.
"""
def sync_to_external(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to external platform.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync (optional)
Returns:
dict: Sync result
"""
try:
if integration.platform == 'wordpress':
return self._sync_to_wordpress(integration, content_types)
elif integration.platform == 'shopify':
return self._sync_to_shopify(integration, content_types)
else:
return {
'success': False,
'error': f'Sync to {integration.platform} not implemented',
'synced_count': 0
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing to {integration.platform}: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def sync_from_external(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from external platform to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync (optional)
Returns:
dict: Sync result
"""
try:
if integration.platform == 'wordpress':
return self._sync_from_wordpress(integration, content_types)
elif integration.platform == 'shopify':
return self._sync_from_shopify(integration, content_types)
else:
return {
'success': False,
'error': f'Sync from {integration.platform} not implemented',
'synced_count': 0
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from {integration.platform}: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_to_wordpress(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to WordPress.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# TODO: Implement WordPress sync
# This will use the WordPress adapter to publish content
logger.info(f"[ContentSyncService] Syncing to WordPress for integration {integration.id}")
return {
'success': True,
'synced_count': 0,
'message': 'WordPress sync to external not yet fully implemented'
}
def _sync_from_wordpress(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# TODO: Implement WordPress import
# This will fetch posts/pages from WordPress and create Content records
logger.info(f"[ContentSyncService] Syncing from WordPress for integration {integration.id}")
return {
'success': True,
'synced_count': 0,
'message': 'WordPress sync from external not yet fully implemented'
}
def _sync_to_shopify(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to Shopify.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# TODO: Implement Shopify sync
logger.info(f"[ContentSyncService] Syncing to Shopify for integration {integration.id}")
return {
'success': True,
'synced_count': 0,
'message': 'Shopify sync not yet implemented'
}
def _sync_from_shopify(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from Shopify to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# TODO: Implement Shopify import
logger.info(f"[ContentSyncService] Syncing from Shopify for integration {integration.id}")
return {
'success': True,
'synced_count': 0,
'message': 'Shopify sync not yet implemented'
}

View File

@@ -0,0 +1,245 @@
"""
Integration Service
Phase 6: Site Integration & Multi-Destination Publishing
Manages site integrations (WordPress, Shopify, etc.).
"""
import logging
from typing import Optional, Dict, Any
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.auth.models import Site
logger = logging.getLogger(__name__)
class IntegrationService:
"""
Service for managing site integrations.
"""
def create_integration(
self,
site: Site,
platform: str,
config: Dict[str, Any],
credentials: Dict[str, Any],
platform_type: str = 'cms'
) -> SiteIntegration:
"""
Create a new site integration.
Args:
site: Site instance
platform: Platform name ('wordpress', 'shopify', 'custom')
config: Platform-specific configuration
credentials: Platform credentials (will be encrypted)
platform_type: Platform type ('cms', 'ecommerce', 'custom_api')
Returns:
SiteIntegration instance
"""
integration = SiteIntegration.objects.create(
account=site.account,
site=site,
platform=platform,
platform_type=platform_type,
config_json=config,
credentials_json=credentials,
is_active=True
)
logger.info(
f"[IntegrationService] Created integration {integration.id} for site {site.id}, platform {platform}"
)
return integration
def update_integration(
self,
integration: SiteIntegration,
config: Optional[Dict[str, Any]] = None,
credentials: Optional[Dict[str, Any]] = None,
is_active: Optional[bool] = None
) -> SiteIntegration:
"""
Update an existing integration.
Args:
integration: SiteIntegration instance
config: Updated configuration (optional)
credentials: Updated credentials (optional)
is_active: Active status (optional)
Returns:
Updated SiteIntegration instance
"""
if config is not None:
integration.config_json = config
if credentials is not None:
integration.set_credentials(credentials)
if is_active is not None:
integration.is_active = is_active
integration.save()
logger.info(f"[IntegrationService] Updated integration {integration.id}")
return integration
def delete_integration(self, integration: SiteIntegration):
"""
Delete an integration.
Args:
integration: SiteIntegration instance
"""
integration_id = integration.id
integration.delete()
logger.info(f"[IntegrationService] Deleted integration {integration_id}")
def get_integration(
self,
site: Site,
platform: str
) -> Optional[SiteIntegration]:
"""
Get integration for a site and platform.
Args:
site: Site instance
platform: Platform name
Returns:
SiteIntegration or None
"""
return SiteIntegration.objects.filter(
site=site,
platform=platform,
is_active=True
).first()
def list_integrations(
self,
site: Site,
active_only: bool = True
) -> list:
"""
List all integrations for a site.
Args:
site: Site instance
active_only: Only return active integrations
Returns:
List of SiteIntegration instances
"""
queryset = SiteIntegration.objects.filter(site=site)
if active_only:
queryset = queryset.filter(is_active=True)
return list(queryset.order_by('-created_at'))
def test_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test connection to the integrated platform.
Args:
integration: SiteIntegration instance
Returns:
dict: {
'success': bool,
'message': str,
'details': dict
}
"""
try:
if integration.platform == 'wordpress':
return self._test_wordpress_connection(integration)
elif integration.platform == 'shopify':
return self._test_shopify_connection(integration)
else:
return {
'success': False,
'message': f'Connection testing not implemented for platform: {integration.platform}',
'details': {}
}
except Exception as e:
logger.error(
f"[IntegrationService] Error testing connection for integration {integration.id}: {str(e)}",
exc_info=True
)
return {
'success': False,
'message': str(e),
'details': {}
}
def _test_wordpress_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test WordPress connection.
Args:
integration: SiteIntegration instance
Returns:
dict: Connection test result
"""
from igny8_core.utils.wordpress import WordPressClient
config = integration.config_json
credentials = integration.get_credentials()
site_url = config.get('site_url')
username = credentials.get('username')
app_password = credentials.get('app_password')
if not site_url:
return {
'success': False,
'message': 'WordPress site URL not configured',
'details': {}
}
try:
client = WordPressClient(site_url, username, app_password)
result = client.test_connection()
return result
except Exception as e:
return {
'success': False,
'message': f'WordPress connection failed: {str(e)}',
'details': {}
}
def _test_shopify_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test Shopify connection.
Args:
integration: SiteIntegration instance
Returns:
dict: Connection test result
"""
# TODO: Implement Shopify connection testing
return {
'success': False,
'message': 'Shopify connection testing not yet implemented',
'details': {}
}

View File

@@ -0,0 +1,161 @@
"""
Sync Service
Phase 6: Site Integration & Multi-Destination Publishing
Handles two-way synchronization between IGNY8 and external platforms.
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from igny8_core.business.integration.models import SiteIntegration
logger = logging.getLogger(__name__)
class SyncService:
"""
Service for handling two-way sync between IGNY8 and external platforms.
"""
def sync(
self,
integration: SiteIntegration,
direction: str = 'both',
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Perform synchronization.
Args:
integration: SiteIntegration instance
direction: 'both', 'to_external', 'from_external'
content_types: List of content types to sync (optional, syncs all if None)
Returns:
dict: Sync result
"""
if not integration.sync_enabled:
return {
'success': False,
'message': 'Sync is not enabled for this integration',
'synced_count': 0
}
# Update sync status
integration.sync_status = 'syncing'
integration.save(update_fields=['sync_status', 'updated_at'])
try:
if direction in ('both', 'to_external'):
# Sync from IGNY8 to external platform
to_result = self._sync_to_external(integration, content_types)
else:
to_result = {'success': True, 'synced_count': 0}
if direction in ('both', 'from_external'):
# Sync from external platform to IGNY8
from_result = self._sync_from_external(integration, content_types)
else:
from_result = {'success': True, 'synced_count': 0}
# Update sync status
if to_result.get('success') and from_result.get('success'):
integration.sync_status = 'success'
integration.sync_error = None
else:
integration.sync_status = 'failed'
integration.sync_error = (
to_result.get('error', '') + ' ' + from_result.get('error', '')
).strip()
integration.last_sync_at = datetime.now()
integration.save(update_fields=['sync_status', 'sync_error', 'last_sync_at', 'updated_at'])
total_synced = to_result.get('synced_count', 0) + from_result.get('synced_count', 0)
return {
'success': to_result.get('success') and from_result.get('success'),
'synced_count': total_synced,
'to_external': to_result,
'from_external': from_result
}
except Exception as e:
logger.error(
f"[SyncService] Error syncing integration {integration.id}: {str(e)}",
exc_info=True
)
integration.sync_status = 'failed'
integration.sync_error = str(e)
integration.save(update_fields=['sync_status', 'sync_error', 'updated_at'])
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_to_external(
self,
integration: SiteIntegration,
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to external platform.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# This will be implemented by ContentSyncService
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
sync_service = ContentSyncService()
return sync_service.sync_to_external(integration, content_types)
def _sync_from_external(
self,
integration: SiteIntegration,
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Sync content from external platform to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# This will be implemented by ContentSyncService
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
sync_service = ContentSyncService()
return sync_service.sync_from_external(integration, content_types)
def get_sync_status(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Get current sync status for an integration.
Args:
integration: SiteIntegration instance
Returns:
dict: Sync status information
"""
return {
'sync_enabled': integration.sync_enabled,
'sync_status': integration.sync_status,
'last_sync_at': integration.last_sync_at.isoformat() if integration.last_sync_at else None,
'sync_error': integration.sync_error
}

View File

@@ -0,0 +1,99 @@
"""
Base Adapter
Phase 6: Site Integration & Multi-Destination Publishing
Abstract base class for publishing adapters.
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class BaseAdapter(ABC):
"""
Abstract base class for publishing adapters.
All platform-specific adapters must inherit from this.
"""
@abstractmethod
def publish(
self,
content: Any,
destination_config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Publish content to destination.
Args:
content: Content or SiteBlueprint to publish
destination_config: Destination-specific configuration
Returns:
dict: {
'success': bool,
'external_id': str, # External platform ID
'url': str, # Published content URL
'published_at': datetime,
'metadata': dict
}
"""
pass
@abstractmethod
def test_connection(
self,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Test connection to destination.
Args:
config: Destination configuration
Returns:
dict: {
'success': bool,
'message': str,
'details': dict
}
"""
pass
@abstractmethod
def get_status(
self,
published_id: str,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Get publishing status for published content.
Args:
published_id: External platform ID
config: Destination configuration
Returns:
dict: {
'status': str, # 'published', 'draft', 'deleted', etc.
'url': str,
'updated_at': datetime,
'metadata': dict
}
"""
pass
def validate_config(
self,
config: Dict[str, Any]
) -> tuple:
"""
Validate destination configuration.
Args:
config: Destination configuration
Returns:
tuple: (is_valid, error_message)
"""
# Default implementation - can be overridden
return True, None

View File

@@ -0,0 +1,147 @@
"""
Shopify Adapter
Phase 6: Site Integration & Multi-Destination Publishing
Adapter for publishing content to Shopify.
Skeleton implementation - to be fully implemented in future.
"""
import logging
from typing import Dict, Any
from datetime import datetime
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
logger = logging.getLogger(__name__)
class ShopifyAdapter(BaseAdapter):
"""
Adapter for publishing content to Shopify.
Skeleton implementation - full implementation pending.
"""
def publish(
self,
content: Any,
destination_config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Publish content to Shopify.
Args:
content: Content instance or dict with content data
destination_config: {
'shop_domain': str,
'access_token': str,
'content_type': str, # 'page', 'blog_post', 'product'
}
Returns:
dict: {
'success': bool,
'external_id': str, # Shopify resource ID
'url': str, # Published resource URL
'published_at': datetime,
'metadata': dict
}
"""
# TODO: Implement Shopify publishing
logger.warning("[ShopifyAdapter] Shopify publishing not yet implemented")
return {
'success': False,
'external_id': None,
'url': None,
'published_at': None,
'metadata': {
'error': 'Shopify publishing not yet implemented'
}
}
def test_connection(
self,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Test connection to Shopify.
Args:
config: {
'shop_domain': str,
'access_token': str
}
Returns:
dict: {
'success': bool,
'message': str,
'details': dict
}
"""
# TODO: Implement Shopify connection testing
logger.warning("[ShopifyAdapter] Shopify connection testing not yet implemented")
return {
'success': False,
'message': 'Shopify connection testing not yet implemented',
'details': {}
}
def get_status(
self,
published_id: str,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Get publishing status for published content.
Args:
published_id: Shopify resource ID
config: Shopify configuration
Returns:
dict: {
'status': str, # 'published', 'draft', 'deleted', etc.
'url': str,
'updated_at': datetime,
'metadata': dict
}
"""
# TODO: Implement Shopify status retrieval
logger.warning("[ShopifyAdapter] Shopify status retrieval not yet implemented")
return {
'status': 'unknown',
'url': None,
'updated_at': None,
'metadata': {
'error': 'Shopify status retrieval not yet implemented'
}
}
def validate_config(
self,
config: Dict[str, Any]
) -> tuple:
"""
Validate Shopify configuration.
Args:
config: Shopify configuration
Returns:
tuple: (is_valid, error_message)
"""
required_fields = ['shop_domain', 'access_token']
for field in required_fields:
if field not in config or not config[field]:
return False, f"Missing required field: {field}"
# Validate domain format
shop_domain = config.get('shop_domain', '')
if not shop_domain.endswith('.myshopify.com'):
return False, "shop_domain must end with .myshopify.com"
return True, None

View File

@@ -0,0 +1,265 @@
"""
WordPress Adapter
Phase 6: Site Integration & Multi-Destination Publishing
Adapter for publishing content to WordPress.
Refactored to use BaseAdapter interface while preserving existing functionality.
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from igny8_core.business.publishing.services.adapters.base_adapter import BaseAdapter
from igny8_core.utils.wordpress import WordPressClient
logger = logging.getLogger(__name__)
class WordPressAdapter(BaseAdapter):
"""
Adapter for publishing content to WordPress.
Uses WordPressClient internally to preserve existing functionality.
"""
def publish(
self,
content: Any,
destination_config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Publish content to WordPress.
Args:
content: Content instance or dict with content data
destination_config: {
'site_url': str,
'username': str,
'app_password': str,
'status': str (optional, default 'draft'),
'featured_image_url': str (optional)
}
Returns:
dict: {
'success': bool,
'external_id': str, # WordPress post ID
'url': str, # Published post URL
'published_at': datetime,
'metadata': dict
}
"""
try:
# Get WordPress client
client = self._get_client(destination_config)
# Extract content data
if hasattr(content, 'title') and hasattr(content, 'content'):
# Content model instance
title = content.title
content_html = content.content
elif isinstance(content, dict):
# Dict with content data
title = content.get('title', '')
content_html = content.get('content', '')
else:
raise ValueError(f"Unsupported content type: {type(content)}")
# Get publishing options
status = destination_config.get('status', 'draft')
featured_image_url = destination_config.get('featured_image_url')
# Publish to WordPress
result = client.create_post(
title=title,
content=content_html,
status=status,
featured_image_url=featured_image_url
)
if result.get('success'):
return {
'success': True,
'external_id': str(result.get('post_id')),
'url': result.get('url'),
'published_at': datetime.now(),
'metadata': {
'post_id': result.get('post_id'),
'status': status
}
}
else:
return {
'success': False,
'external_id': None,
'url': None,
'published_at': None,
'metadata': {
'error': result.get('error')
}
}
except Exception as e:
logger.error(
f"[WordPressAdapter] Error publishing content: {str(e)}",
exc_info=True
)
return {
'success': False,
'external_id': None,
'url': None,
'published_at': None,
'metadata': {
'error': str(e)
}
}
def test_connection(
self,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Test connection to WordPress.
Args:
config: {
'site_url': str,
'username': str,
'app_password': str
}
Returns:
dict: {
'success': bool,
'message': str,
'details': dict
}
"""
try:
client = self._get_client(config)
result = client.test_connection()
return {
'success': result.get('success', False),
'message': result.get('message', ''),
'details': {
'wp_version': result.get('wp_version')
}
}
except Exception as e:
logger.error(
f"[WordPressAdapter] Connection test failed: {str(e)}",
exc_info=True
)
return {
'success': False,
'message': str(e),
'details': {}
}
def get_status(
self,
published_id: str,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Get publishing status for published content.
Args:
published_id: WordPress post ID
config: WordPress configuration
Returns:
dict: {
'status': str, # 'published', 'draft', 'deleted', etc.
'url': str,
'updated_at': datetime,
'metadata': dict
}
"""
try:
client = self._get_client(config)
# Fetch post from WordPress
response = client.session.get(
f"{client.api_base}/posts/{published_id}"
)
if response.status_code == 200:
data = response.json()
return {
'status': data.get('status', 'unknown'),
'url': data.get('link'),
'updated_at': datetime.fromisoformat(
data.get('modified', '').replace('Z', '+00:00')
) if data.get('modified') else None,
'metadata': {
'title': data.get('title', {}).get('rendered'),
'author': data.get('author'),
}
}
else:
return {
'status': 'deleted' if response.status_code == 404 else 'unknown',
'url': None,
'updated_at': None,
'metadata': {
'error': f"HTTP {response.status_code}"
}
}
except Exception as e:
logger.error(
f"[WordPressAdapter] Error getting status: {str(e)}",
exc_info=True
)
return {
'status': 'unknown',
'url': None,
'updated_at': None,
'metadata': {
'error': str(e)
}
}
def validate_config(
self,
config: Dict[str, Any]
) -> tuple:
"""
Validate WordPress configuration.
Args:
config: WordPress configuration
Returns:
tuple: (is_valid, error_message)
"""
required_fields = ['site_url']
for field in required_fields:
if field not in config or not config[field]:
return False, f"Missing required field: {field}"
# Validate URL format
site_url = config.get('site_url', '')
if not site_url.startswith(('http://', 'https://')):
return False, "site_url must start with http:// or https://"
return True, None
def _get_client(self, config: Dict[str, Any]) -> WordPressClient:
"""
Get WordPress client from configuration.
Args:
config: WordPress configuration
Returns:
WordPressClient instance
"""
site_url = config.get('site_url')
username = config.get('username')
app_password = config.get('app_password')
return WordPressClient(site_url, username, app_password)

View File

@@ -138,8 +138,24 @@ class PublisherService:
if not adapter:
raise ValueError(f"No adapter found for destination: {destination}")
# Get destination config (for now, basic config - can be extended)
destination_config = {'account': account}
# If content has site, try to get integration config
if hasattr(content, 'site') and content.site:
from igny8_core.business.integration.models import SiteIntegration
integration = SiteIntegration.objects.filter(
site=content.site,
platform=destination,
is_active=True
).first()
if integration:
destination_config.update(integration.config_json)
destination_config.update(integration.get_credentials())
# Publish via adapter
result = adapter.publish(content, {'account': account})
result = adapter.publish(content, destination_config)
# Update record
record.status = 'published' if result.get('success') else 'failed'
@@ -163,6 +179,156 @@ class PublisherService:
record.save()
raise
def publish_to_multiple_destinations(
self,
content: Any,
destinations: List[Dict[str, Any]],
account
) -> Dict[str, Any]:
"""
Publish content to multiple destinations.
Args:
content: Content instance or SiteBlueprint
destinations: List of destination configs, e.g.:
[
{'platform': 'wordpress', 'site_url': '...', 'username': '...', 'app_password': '...'},
{'platform': 'sites'},
{'platform': 'shopify', 'shop_domain': '...', 'access_token': '...'}
]
account: Account instance
Returns:
dict: {
'success': bool,
'results': list of publishing results per destination
}
"""
results = []
for destination_config in destinations:
platform = destination_config.get('platform')
if not platform:
results.append({
'platform': 'unknown',
'success': False,
'error': 'Platform not specified'
})
continue
try:
adapter = self._get_adapter(platform)
if not adapter:
results.append({
'platform': platform,
'success': False,
'error': f'No adapter found for platform: {platform}'
})
continue
# Validate config
is_valid, error_msg = adapter.validate_config(destination_config)
if not is_valid:
results.append({
'platform': platform,
'success': False,
'error': error_msg or 'Invalid configuration'
})
continue
# Publish via adapter
result = adapter.publish(content, destination_config)
# Create publishing record if content has site/sector
if hasattr(content, 'site') and hasattr(content, 'sector'):
record = PublishingRecord.objects.create(
account=account,
site=content.site,
sector=content.sector,
content=content if hasattr(content, 'id') and not isinstance(content, SiteBlueprint) else None,
site_blueprint=content if isinstance(content, SiteBlueprint) else None,
destination=platform,
status='published' if result.get('success') else 'failed',
destination_id=result.get('external_id'),
destination_url=result.get('url'),
published_at=result.get('published_at'),
error_message=result.get('metadata', {}).get('error'),
metadata=result.get('metadata', {})
)
result['publishing_record_id'] = record.id
results.append({
'platform': platform,
'success': result.get('success', False),
'external_id': result.get('external_id'),
'url': result.get('url'),
'error': result.get('metadata', {}).get('error')
})
except Exception as e:
logger.error(
f"Error publishing to {platform}: {str(e)}",
exc_info=True
)
results.append({
'platform': platform,
'success': False,
'error': str(e)
})
return {
'success': all(r.get('success', False) for r in results),
'results': results
}
def publish_with_integrations(
self,
content: Any,
site,
account,
platforms: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Publish content using site integrations.
Args:
content: Content instance or SiteBlueprint
site: Site instance
account: Account instance
platforms: Optional list of platforms to publish to (all active if None)
Returns:
dict: Publishing results
"""
from igny8_core.business.integration.models import SiteIntegration
# Get active integrations for site
integrations = SiteIntegration.objects.filter(
site=site,
is_active=True
)
if platforms:
integrations = integrations.filter(platform__in=platforms)
destinations = []
for integration in integrations:
config = integration.config_json.copy()
credentials = integration.get_credentials()
destination_config = {
'platform': integration.platform,
**config,
**credentials
}
destinations.append(destination_config)
# Also add 'sites' destination if not in platforms filter or if platforms is None
if not platforms or 'sites' in platforms:
destinations.append({'platform': 'sites'})
return self.publish_to_multiple_destinations(content, destinations, account)
def _get_adapter(self, destination: str):
"""
Get adapter for destination platform.
@@ -178,10 +344,10 @@ class PublisherService:
from igny8_core.business.publishing.services.adapters.sites_renderer_adapter import SitesRendererAdapter
return SitesRendererAdapter()
elif destination == 'wordpress':
# Will be implemented in Phase 6
return None
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
return WordPressAdapter()
elif destination == 'shopify':
# Will be implemented in Phase 6
return None
from igny8_core.business.publishing.services.adapters.shopify_adapter import ShopifyAdapter
return ShopifyAdapter()
return None

View File

@@ -0,0 +1,5 @@
"""
Integration Module
Phase 6: Site Integration & Multi-Destination Publishing
"""

View File

@@ -0,0 +1,12 @@
"""
Integration Module App Configuration
"""
from django.apps import AppConfig
class IntegrationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.integration'
label = 'integration'
verbose_name = 'Integration'

View File

@@ -0,0 +1,16 @@
"""
Integration URLs
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from igny8_core.modules.integration.views import IntegrationViewSet
router = DefaultRouter()
router.register(r'integrations', IntegrationViewSet, basename='integration')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,96 @@
"""
Integration ViewSet
Phase 6: Site Integration & Multi-Destination Publishing
"""
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.business.integration.services.integration_service import IntegrationService
from igny8_core.business.integration.services.sync_service import SyncService
class IntegrationViewSet(SiteSectorModelViewSet):
"""
ViewSet for SiteIntegration model.
"""
queryset = SiteIntegration.objects.select_related('site')
permission_classes = [IsAuthenticatedAndActive, IsEditorOrAbove]
throttle_scope = 'integration'
throttle_classes = [DebugScopedRateThrottle]
def get_serializer_class(self):
from rest_framework import serializers
class SiteIntegrationSerializer(serializers.ModelSerializer):
class Meta:
model = SiteIntegration
fields = '__all__'
read_only_fields = ['created_at', 'updated_at', 'last_sync_at']
return SiteIntegrationSerializer
@action(detail=True, methods=['post'])
def test_connection(self, request, pk=None):
"""
Test connection to integrated platform.
POST /api/v1/integration/integrations/{id}/test_connection/
"""
integration = self.get_object()
service = IntegrationService()
result = service.test_connection(integration)
if result.get('success'):
return success_response(result, request=request)
else:
return error_response(
result.get('message', 'Connection test failed'),
status.HTTP_400_BAD_REQUEST,
request
)
@action(detail=True, methods=['post'])
def sync(self, request, pk=None):
"""
Trigger synchronization with integrated platform.
POST /api/v1/integration/integrations/{id}/sync/
Request body:
{
"direction": "both", # 'both', 'to_external', 'from_external'
"content_types": ["blog_post", "page"] # Optional
}
"""
integration = self.get_object()
direction = request.data.get('direction', 'both')
content_types = request.data.get('content_types')
sync_service = SyncService()
result = sync_service.sync(integration, direction=direction, content_types=content_types)
response_status = status.HTTP_200_OK if result.get('success') else status.HTTP_400_BAD_REQUEST
return success_response(result, request=request, status_code=response_status)
@action(detail=True, methods=['get'])
def sync_status(self, request, pk=None):
"""
Get sync status for integration.
GET /api/v1/integration/integrations/{id}/sync_status/
"""
integration = self.get_object()
sync_service = SyncService()
status_data = sync_service.get_sync_status(integration)
return success_response(status_data, request=request)

View File

@@ -55,10 +55,12 @@ INSTALLED_APPS = [
'igny8_core.business.site_building.apps.SiteBuildingConfig',
'igny8_core.business.optimization.apps.OptimizationConfig',
'igny8_core.business.publishing.apps.PublishingConfig',
'igny8_core.business.integration.apps.IntegrationConfig',
'igny8_core.modules.site_builder.apps.SiteBuilderConfig',
'igny8_core.modules.linker.apps.LinkerConfig',
'igny8_core.modules.optimizer.apps.OptimizerConfig',
'igny8_core.modules.publisher.apps.PublisherConfig',
'igny8_core.modules.integration.apps.IntegrationConfig',
]
# System module needs explicit registration for admin

View File

@@ -34,6 +34,7 @@ urlpatterns = [
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
# OpenAPI Schema and Documentation
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

View File

@@ -76,6 +76,7 @@ const Industries = lazy(() => import("./pages/Settings/Industries"));
const Status = lazy(() => import("./pages/Settings/Status"));
const ApiMonitor = lazy(() => import("./pages/Settings/ApiMonitor"));
const Integration = lazy(() => import("./pages/Settings/Integration"));
const Publishing = lazy(() => import("./pages/Settings/Publishing"));
const Sites = lazy(() => import("./pages/Settings/Sites"));
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
@@ -419,6 +420,11 @@ export default function App() {
<Integration />
</Suspense>
} />
<Route path="/settings/publishing" element={
<Suspense fallback={null}>
<Publishing />
</Suspense>
} />
<Route path="/settings/sites" element={
<Suspense fallback={null}>
<Sites />

View File

@@ -0,0 +1,83 @@
/**
* Integration Status Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React from 'react';
import { CheckCircleIcon, XCircleIcon, ClockIcon, SyncIcon } from 'lucide-react';
interface IntegrationStatusProps {
syncEnabled: boolean;
syncStatus: 'success' | 'failed' | 'pending' | 'syncing';
lastSyncAt: string | null;
syncError?: string | null;
}
export default function IntegrationStatus({
syncEnabled,
syncStatus,
lastSyncAt,
syncError,
}: IntegrationStatusProps) {
const getStatusIcon = () => {
switch (syncStatus) {
case 'success':
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
case 'failed':
return <XCircleIcon className="w-5 h-5 text-red-500" />;
case 'syncing':
return <SyncIcon className="w-5 h-5 text-blue-500 animate-spin" />;
default:
return <ClockIcon className="w-5 h-5 text-gray-400" />;
}
};
const getStatusText = () => {
switch (syncStatus) {
case 'success':
return 'Synced';
case 'failed':
return 'Sync Failed';
case 'syncing':
return 'Syncing...';
default:
return 'Not Synced';
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return 'Invalid Date';
}
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className="text-sm font-medium">{getStatusText()}</span>
</div>
{syncEnabled && (
<div className="text-xs text-gray-600 dark:text-gray-400">
<div>Last sync: {formatDate(lastSyncAt)}</div>
{syncError && (
<div className="text-red-600 dark:text-red-400 mt-1">
Error: {syncError}
</div>
)}
</div>
)}
{!syncEnabled && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Two-way sync is disabled
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Platform Selector Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React from 'react';
import SelectDropdown from '../form/SelectDropdown';
interface PlatformSelectorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
const PLATFORMS = [
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
{ value: 'custom', label: 'Custom API' },
];
export default function PlatformSelector({ value, onChange, disabled }: PlatformSelectorProps) {
return (
<SelectDropdown
options={PLATFORMS}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="Select platform"
/>
);
}

View File

@@ -0,0 +1,339 @@
/**
* Site Integrations Section
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { PlusIcon, TrashIcon, TestTubeIcon, SyncIcon } from 'lucide-react';
import Button from '../ui/button/Button';
import { Modal } from '../ui/modal';
import FormModal, { FormField } from '../common/FormModal';
import PlatformSelector from './PlatformSelector';
import IntegrationStatus from './IntegrationStatus';
import { useToast } from '../ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface SiteIntegration {
id: number;
site: number;
site_name?: string;
platform: string;
platform_type: string;
is_active: boolean;
sync_enabled: boolean;
sync_status: 'success' | 'failed' | 'pending' | 'syncing';
last_sync_at: string | null;
sync_error: string | null;
}
interface SiteIntegrationsSectionProps {
siteId?: number;
}
export default function SiteIntegrationsSection({ siteId }: SiteIntegrationsSectionProps) {
const toast = useToast();
const [integrations, setIntegrations] = useState<SiteIntegration[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedIntegration, setSelectedIntegration] = useState<SiteIntegration | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
useEffect(() => {
loadIntegrations();
}, [siteId]);
const loadIntegrations = async () => {
try {
setLoading(true);
const params = siteId ? `?site=${siteId}` : '';
const data = await fetchAPI(`/v1/integration/integrations/${params}`);
if (data && Array.isArray(data)) {
setIntegrations(data);
}
} catch (error: any) {
toast.error(`Failed to load integrations: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setSelectedIntegration(null);
setShowAddModal(true);
};
const handleEdit = (integration: SiteIntegration) => {
setSelectedIntegration(integration);
setShowEditModal(true);
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this integration?')) return;
try {
await fetchAPI(`/v1/integration/integrations/${id}/`, {
method: 'DELETE',
});
toast.success('Integration deleted successfully');
loadIntegrations();
} catch (error: any) {
toast.error(`Failed to delete integration: ${error.message}`);
}
};
const handleTestConnection = async (integration: SiteIntegration) => {
setIsTesting(true);
try {
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/test_connection/`, {
method: 'POST',
});
if (data?.success) {
toast.success(data.message || 'Connection test successful');
} else {
toast.error(data?.message || 'Connection test failed');
}
loadIntegrations();
} catch (error: any) {
toast.error(`Connection test failed: ${error.message}`);
} finally {
setIsTesting(false);
}
};
const handleSync = async (integration: SiteIntegration) => {
try {
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/sync/`, {
method: 'POST',
body: JSON.stringify({
direction: 'both',
}),
});
if (data?.success) {
toast.success(`Sync completed. ${data.synced_count || 0} items synced`);
} else {
toast.error('Sync failed');
}
loadIntegrations();
} catch (error: any) {
toast.error(`Sync failed: ${error.message}`);
}
};
const handleSave = async (formData: Record<string, any>) => {
setIsSaving(true);
try {
const payload = {
site: siteId || formData.site,
platform: formData.platform,
platform_type: formData.platform_type || 'cms',
config_json: {
site_url: formData.site_url,
...(formData.platform === 'shopify' && {
shop_domain: formData.shop_domain,
}),
},
credentials_json: {
username: formData.username,
app_password: formData.app_password,
...(formData.platform === 'shopify' && {
access_token: formData.access_token,
}),
},
is_active: formData.is_active !== false,
sync_enabled: formData.sync_enabled || false,
};
if (selectedIntegration) {
// Update
await fetchAPI(`/v1/integration/integrations/${selectedIntegration.id}/`, {
method: 'PUT',
body: JSON.stringify(payload),
});
toast.success('Integration updated successfully');
} else {
// Create
await fetchAPI('/v1/integration/integrations/', {
method: 'POST',
body: JSON.stringify(payload),
});
toast.success('Integration created successfully');
}
setShowAddModal(false);
setShowEditModal(false);
loadIntegrations();
} catch (error: any) {
toast.error(`Failed to save integration: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const getFields = (): FormField[] => {
const platform = selectedIntegration?.platform || 'wordpress';
const baseFields: FormField[] = [
{
name: 'platform',
label: 'Platform',
type: 'custom',
component: (
<PlatformSelector
value={platform}
onChange={() => {}}
disabled={!!selectedIntegration}
/>
),
required: true,
},
];
if (platform === 'wordpress') {
baseFields.push(
{ name: 'site_url', label: 'WordPress Site URL', type: 'text', required: true, placeholder: 'https://example.com' },
{ name: 'username', label: 'Username', type: 'text', required: true },
{ name: 'app_password', label: 'Application Password', type: 'password', required: true }
);
} else if (platform === 'shopify') {
baseFields.push(
{ name: 'shop_domain', label: 'Shop Domain', type: 'text', required: true, placeholder: 'myshop.myshopify.com' },
{ name: 'access_token', label: 'Access Token', type: 'password', required: true }
);
} else {
baseFields.push(
{ name: 'site_url', label: 'API URL', type: 'text', required: true },
{ name: 'username', label: 'API Key', type: 'text', required: true },
{ name: 'app_password', label: 'API Secret', type: 'password', required: false }
);
}
baseFields.push(
{ name: 'is_active', label: 'Active', type: 'checkbox', defaultValue: true },
{ name: 'sync_enabled', label: 'Enable Two-Way Sync', type: 'checkbox', defaultValue: false }
);
return baseFields;
};
if (loading) {
return <div>Loading integrations...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Site Integrations</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Connect your sites to external platforms (WordPress, Shopify, Custom APIs)
</p>
</div>
<Button onClick={handleAdd} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Add Integration
</Button>
</div>
{integrations.length === 0 ? (
<div className="text-center py-12 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
<p className="text-gray-600 dark:text-gray-400">No integrations configured</p>
<Button onClick={handleAdd} variant="outline" className="mt-4">
Add Your First Integration
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{integrations.map((integration) => (
<div
key={integration.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold text-gray-900 dark:text-white capitalize">
{integration.platform}
</h3>
{integration.site_name && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{integration.site_name}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(integration)}
disabled={isTesting}
title="Test Connection"
>
<TestTubeIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(integration.id)}
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
<IntegrationStatus
syncEnabled={integration.sync_enabled}
syncStatus={integration.sync_status}
lastSyncAt={integration.last_sync_at}
syncError={integration.sync_error}
/>
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(integration)}
className="flex-1"
>
Edit
</Button>
{integration.sync_enabled && (
<Button
variant="outline"
size="sm"
onClick={() => handleSync(integration)}
className="flex-1"
>
<SyncIcon className="w-4 h-4 mr-1" />
Sync
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* Add/Edit Modal */}
<FormModal
isOpen={showAddModal || showEditModal}
onClose={() => {
setShowAddModal(false);
setShowEditModal(false);
}}
onSubmit={handleSave}
title={selectedIntegration ? 'Edit Integration' : 'Add Integration'}
fields={getFields()}
submitLabel="Save"
cancelLabel="Cancel"
isLoading={isSaving}
initialValues={selectedIntegration ? {
platform: selectedIntegration.platform,
site_url: selectedIntegration.site_name || '',
is_active: selectedIntegration.is_active,
sync_enabled: selectedIntegration.sync_enabled,
} : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,242 @@
/**
* Publishing Rules Component
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState } from 'react';
import { PlusIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
import Button from '../ui/button/Button';
import Checkbox from '../form/input/Checkbox';
import Label from '../form/Label';
import SelectDropdown from '../form/SelectDropdown';
export interface PublishingRule {
id: string;
content_type: string;
trigger: 'auto' | 'manual' | 'scheduled';
destinations: string[];
priority: number;
enabled: boolean;
schedule?: string; // Cron expression for scheduled
}
interface PublishingRulesProps {
rules: PublishingRule[];
onChange: (rules: PublishingRule[]) => void;
}
const CONTENT_TYPES = [
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'page', label: 'Page' },
{ value: 'product', label: 'Product' },
{ value: 'all', label: 'All Content' },
];
const TRIGGERS = [
{ value: 'auto', label: 'Auto-Publish' },
{ value: 'manual', label: 'Manual' },
{ value: 'scheduled', label: 'Scheduled' },
];
const DESTINATIONS = [
{ value: 'sites', label: 'IGNY8 Sites' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
];
export default function PublishingRules({ rules, onChange }: PublishingRulesProps) {
const [localRules, setLocalRules] = useState<PublishingRule[]>(rules);
const handleAddRule = () => {
const newRule: PublishingRule = {
id: `rule_${Date.now()}`,
content_type: 'all',
trigger: 'manual',
destinations: ['sites'],
priority: localRules.length + 1,
enabled: true,
};
const updated = [...localRules, newRule];
setLocalRules(updated);
onChange(updated);
};
const handleDeleteRule = (id: string) => {
const updated = localRules.filter((r) => r.id !== id);
setLocalRules(updated);
onChange(updated);
};
const handleUpdateRule = (id: string, field: keyof PublishingRule, value: any) => {
const updated = localRules.map((rule) =>
rule.id === id ? { ...rule, [field]: value } : rule
);
setLocalRules(updated);
onChange(updated);
};
const handleMoveRule = (id: string, direction: 'up' | 'down') => {
const index = localRules.findIndex((r) => r.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= localRules.length) return;
const updated = [...localRules];
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
// Update priorities
updated.forEach((rule, i) => {
rule.priority = i + 1;
});
setLocalRules(updated);
onChange(updated);
};
const handleToggleDestinations = (ruleId: string, destination: string) => {
const rule = localRules.find((r) => r.id === ruleId);
if (!rule) return;
const destinations = rule.destinations.includes(destination)
? rule.destinations.filter((d) => d !== destination)
: [...rule.destinations, destination];
handleUpdateRule(ruleId, 'destinations', destinations);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Publishing Rules
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Configure how and where content is published
</p>
</div>
<Button onClick={handleAddRule} variant="primary" size="sm">
<PlusIcon className="w-4 h-4 mr-2" />
Add Rule
</Button>
</div>
{localRules.length === 0 ? (
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
<p className="text-gray-600 dark:text-gray-400 mb-4">No publishing rules configured</p>
<Button onClick={handleAddRule} variant="outline">
Add Your First Rule
</Button>
</div>
) : (
<div className="space-y-3">
{localRules.map((rule, index) => (
<div
key={rule.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
>
<div className="flex items-start justify-between">
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Content Type</Label>
<SelectDropdown
options={CONTENT_TYPES}
value={rule.content_type}
onChange={(e) =>
handleUpdateRule(rule.id, 'content_type', e.target.value)
}
/>
</div>
<div>
<Label>Trigger</Label>
<SelectDropdown
options={TRIGGERS}
value={rule.trigger}
onChange={(e) =>
handleUpdateRule(rule.id, 'trigger', e.target.value)
}
/>
</div>
<div>
<Label>Priority</Label>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleMoveRule(rule.id, 'up')}
disabled={index === 0}
>
<ArrowUpIcon className="w-4 h-4" />
</Button>
<span className="text-sm font-medium">{rule.priority}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleMoveRule(rule.id, 'down')}
disabled={index === localRules.length - 1}
>
<ArrowDownIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Checkbox
checked={rule.enabled}
onChange={(e) =>
handleUpdateRule(rule.id, 'enabled', e.target.checked)
}
label="Enabled"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteRule(rule.id)}
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
<div>
<Label>Destinations</Label>
<div className="flex flex-wrap gap-3 mt-2">
{DESTINATIONS.map((dest) => (
<Checkbox
key={dest.value}
checked={rule.destinations.includes(dest.value)}
onChange={() => handleToggleDestinations(rule.id, dest.value)}
label={dest.label}
/>
))}
</div>
</div>
{rule.trigger === 'scheduled' && (
<div>
<Label>Schedule (Cron Expression)</Label>
<input
type="text"
value={rule.schedule || ''}
onChange={(e) =>
handleUpdateRule(rule.id, 'schedule', e.target.value)
}
placeholder="0 0 * * *"
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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Cron format: minute hour day month weekday
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -89,6 +89,8 @@ export const createTasksPageConfig = (
setStructureFilter: (value: string) => void;
typeFilter: string;
setTypeFilter: (value: string) => void;
sourceFilter: string;
setSourceFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
}
): TasksPageConfig => {
@@ -103,6 +105,23 @@ export const createTasksPageConfig = (
toggleable: true,
toggleContentKey: 'description',
toggleContentLabel: 'Idea & Content Outline',
render: (value: string, row: Task) => {
const isSiteBuilder = value?.startsWith('[Site Builder]');
const displayTitle = isSiteBuilder ? value.replace('[Site Builder] ', '') : value;
return (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{displayTitle}
</span>
{isSiteBuilder && (
<Badge color="purple" size="sm" variant="light">
Site Builder
</Badge>
)}
</div>
);
},
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
@@ -297,6 +316,17 @@ export const createTasksPageConfig = (
{ value: 'tutorial', label: 'Tutorial' },
],
},
{
key: 'source',
label: 'Source',
type: 'select',
options: [
{ value: '', label: 'All Sources' },
{ value: 'site_builder', label: 'Site Builder' },
{ value: 'ideas', label: 'Ideas' },
{ value: 'manual', label: 'Manual' },
],
},
{
key: 'cluster_id',
label: 'Cluster',

View File

@@ -201,6 +201,7 @@ const AppSidebar: React.FC = () => {
{ name: "General", path: "/settings" },
{ name: "Plans", path: "/settings/plans" },
{ name: "Integration", path: "/settings/integration" },
{ name: "Publishing", path: "/settings/publishing" },
{ name: "Import / Export", path: "/settings/import-export" },
],
},

View File

@@ -5,6 +5,7 @@ import ValidationCard from '../../components/common/ValidationCard';
import ImageGenerationCard from '../../components/common/ImageGenerationCard';
import ImageResultCard from '../../components/common/ImageResultCard';
import ImageServiceCard from '../../components/common/ImageServiceCard';
import SiteIntegrationsSection from '../../components/integration/SiteIntegrationsSection';
import { Modal } from '../../components/ui/modal';
import FormModal, { FormField } from '../../components/common/FormModal';
import Button from '../../components/ui/button/Button';
@@ -1082,6 +1083,9 @@ export default function Integration() {
}
/>
</div>
{/* Site Integrations Section */}
<SiteIntegrationsSection />
</div>
{/* Details Modal */}

View File

@@ -0,0 +1,165 @@
/**
* Publishing Settings Page
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Checkbox from '../../components/form/input/Checkbox';
import Label from '../../components/form/Label';
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
export default function Publishing() {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
const [publishingRules, setPublishingRules] = useState<PublishingRule[]>([]);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
// TODO: Load from backend API when endpoint is available
// For now, use defaults
setDefaultDestinations(['sites']);
setAutoPublishEnabled(false);
setPublishingRules([]);
} catch (error: any) {
toast.error(`Failed to load settings: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
// TODO: Save to backend API when endpoint is available
toast.success('Publishing settings saved successfully');
} catch (error: any) {
toast.error(`Failed to save settings: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleToggleDestination = (destination: string) => {
setDefaultDestinations((prev) =>
prev.includes(destination)
? prev.filter((d) => d !== destination)
: [...prev, destination]
);
};
const DESTINATIONS = [
{ value: 'sites', label: 'IGNY8 Sites' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
];
if (loading) {
return (
<div className="p-6">
<PageMeta title="Publishing Settings" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Publishing Settings - IGNY8" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Publishing Settings
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Configure default publishing destinations and rules
</p>
</div>
<div className="space-y-6">
{/* Default Destinations */}
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
Default Publishing Destinations
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Select default platforms where content will be published
</p>
</div>
<div className="space-y-2">
{DESTINATIONS.map((dest) => (
<div key={dest.value} className="flex items-center gap-3">
<Checkbox
checked={defaultDestinations.includes(dest.value)}
onChange={() => handleToggleDestination(dest.value)}
label={dest.label}
/>
</div>
))}
</div>
</div>
</Card>
{/* Auto-Publish Settings */}
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
Auto-Publish Settings
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Automatically publish content when it's ready
</p>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={autoPublishEnabled}
onChange={(e) => setAutoPublishEnabled(e.target.checked)}
label="Enable auto-publish"
/>
</div>
{autoPublishEnabled && (
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
When enabled, content will be automatically published to selected destinations
when generation is complete.
</p>
</div>
)}
</div>
</Card>
{/* Publishing Rules */}
<Card className="p-6">
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
/**
* Site Content Editor
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface Page {
id: number;
slug: string;
title: string;
type: string;
status: string;
blocks: any[];
}
export default function SiteContentEditor() {
const { siteId } = useParams<{ siteId: string }>();
const navigate = useNavigate();
const toast = useToast();
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
useEffect(() => {
if (siteId) {
loadPages();
}
}, [siteId]);
const loadPages = async () => {
try {
setLoading(true);
// TODO: Load pages from SiteBlueprint API
// For now, placeholder
setPages([]);
} catch (error: any) {
toast.error(`Failed to load pages: ${error.message}`);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Site Content Editor" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading pages...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Site Content Editor - IGNY8" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Content Editor
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Edit content for site pages
</p>
</div>
<Card className="p-6">
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">
Content editor will be implemented in Phase 7
</p>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,202 @@
/**
* Site Management Dashboard
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { PlusIcon, EditIcon, SettingsIcon, EyeIcon, TrashIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface Site {
id: number;
name: string;
slug: string;
site_type: string;
hosting_type: string;
status: string;
is_active: boolean;
created_at: string;
updated_at: string;
page_count?: number;
integration_count?: number;
}
export default function SiteManagement() {
const navigate = useNavigate();
const toast = useToast();
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSites();
}, []);
const loadSites = async () => {
try {
setLoading(true);
const data = await fetchAPI('/v1/auth/sites/');
if (data && Array.isArray(data)) {
setSites(data);
}
} catch (error: any) {
toast.error(`Failed to load sites: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleCreateSite = () => {
navigate('/site-builder');
};
const handleEdit = (siteId: number) => {
navigate(`/sites/${siteId}/edit`);
};
const handleSettings = (siteId: number) => {
navigate(`/sites/${siteId}/settings`);
};
const handleView = (siteId: number) => {
navigate(`/sites/${siteId}`);
};
const handleDelete = async (siteId: number) => {
if (!confirm('Are you sure you want to delete this site?')) return;
try {
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
method: 'DELETE',
});
toast.success('Site deleted successfully');
loadSites();
} catch (error: any) {
toast.error(`Failed to delete site: ${error.message}`);
}
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Site Management" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading sites...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Site Management - IGNY8" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Management
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage your sites, pages, and content
</p>
</div>
<Button onClick={handleCreateSite} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Create New Site
</Button>
</div>
{sites.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
No sites created yet
</p>
<Button onClick={handleCreateSite} variant="primary">
Create Your First Site
</Button>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sites.map((site) => (
<Card key={site.id} className="p-4 hover:shadow-lg transition-shadow">
<div className="space-y-3">
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{site.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{site.slug}
</p>
</div>
<span
className={`px-2 py-1 text-xs rounded ${
site.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}`}
>
{site.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs">
<span className="px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded capitalize">
{site.site_type}
</span>
<span className="px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded capitalize">
{site.hosting_type}
</span>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-600 dark:text-gray-400">
{site.page_count || 0} pages
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(site.id)}
title="View"
>
<EyeIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(site.id)}
title="Edit"
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleSettings(site.id)}
title="Settings"
>
<SettingsIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(site.id)}
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,170 @@
/**
* Page Manager
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { PlusIcon, EditIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface Page {
id: number;
slug: string;
title: string;
type: string;
status: string;
order: number;
blocks: any[];
}
export default function PageManager() {
const { siteId } = useParams<{ siteId: string }>();
const toast = useToast();
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (siteId) {
loadPages();
}
}, [siteId]);
const loadPages = async () => {
try {
setLoading(true);
// TODO: Load pages from SiteBlueprint API
// For now, placeholder
setPages([]);
} catch (error: any) {
toast.error(`Failed to load pages: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleAddPage = () => {
// TODO: Navigate to page creation
toast.info('Page creation will be implemented in Phase 7');
};
const handleEditPage = (pageId: number) => {
// TODO: Navigate to page editor
toast.info('Page editor will be implemented in Phase 7');
};
const handleDeletePage = async (pageId: number) => {
if (!confirm('Are you sure you want to delete this page?')) return;
// TODO: Delete page
toast.info('Page deletion will be implemented in Phase 7');
};
const handleMovePage = async (pageId: number, direction: 'up' | 'down') => {
// TODO: Update page order
toast.info('Page reordering will be implemented in Phase 7');
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Page Manager" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading pages...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Page Manager - IGNY8" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Page Manager
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage pages for your site
</p>
</div>
<Button onClick={handleAddPage} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Add Page
</Button>
</div>
{pages.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
No pages created yet
</p>
<Button onClick={handleAddPage} variant="primary">
Add Your First Page
</Button>
</Card>
) : (
<Card className="p-6">
<div className="space-y-3">
{pages.map((page, index) => (
<div
key={page.id}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div className="flex items-center gap-4 flex-1">
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleMovePage(page.id, 'up')}
disabled={index === 0}
>
<ArrowUpIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleMovePage(page.id, 'down')}
disabled={index === pages.length - 1}
>
<ArrowDownIcon className="w-4 h-4" />
</Button>
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">
{page.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
/{page.slug} {page.type} {page.status}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditPage(page.id)}
>
<EditIcon className="w-4 h-4 mr-1" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeletePage(page.id)}
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
/**
* Site Settings
* Phase 6: Site Integration & Multi-Destination Publishing
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Label from '../../components/form/Label';
import SelectDropdown from '../../components/form/SelectDropdown';
import Checkbox from '../../components/form/input/Checkbox';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
export default function SiteSettings() {
const { siteId } = useParams<{ siteId: string }>();
const navigate = useNavigate();
const toast = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [site, setSite] = useState<any>(null);
const [formData, setFormData] = useState({
name: '',
slug: '',
site_type: 'marketing',
hosting_type: 'igny8_sites',
is_active: true,
});
useEffect(() => {
if (siteId) {
loadSite();
}
}, [siteId]);
const loadSite = async () => {
try {
setLoading(true);
const data = await fetchAPI(`/v1/auth/sites/${siteId}/`);
if (data) {
setSite(data);
setFormData({
name: data.name || '',
slug: data.slug || '',
site_type: data.site_type || 'marketing',
hosting_type: data.hosting_type || 'igny8_sites',
is_active: data.is_active !== false,
});
}
} catch (error: any) {
toast.error(`Failed to load site: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
method: 'PUT',
body: JSON.stringify(formData),
});
toast.success('Site settings saved successfully');
loadSite();
} catch (error: any) {
toast.error(`Failed to save settings: ${error.message}`);
} finally {
setSaving(false);
}
};
const SITE_TYPES = [
{ value: 'marketing', label: 'Marketing Site' },
{ value: 'ecommerce', label: 'Ecommerce Site' },
{ value: 'blog', label: 'Blog' },
{ value: 'portfolio', label: 'Portfolio' },
{ value: 'corporate', label: 'Corporate' },
];
const HOSTING_TYPES = [
{ value: 'igny8_sites', label: 'IGNY8 Sites' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'shopify', label: 'Shopify' },
{ value: 'multi', label: 'Multi-Destination' },
];
if (loading) {
return (
<div className="p-6">
<PageMeta title="Site Settings" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading site settings...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Site Settings - IGNY8" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Site Settings
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Configure site type, hosting, and other settings
</p>
</div>
<div className="space-y-6">
<Card className="p-6">
<div className="space-y-4">
<div>
<Label>Site Name</Label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
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>
<Label>Slug</Label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
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>
<Label>Site Type</Label>
<SelectDropdown
options={SITE_TYPES}
value={formData.site_type}
onChange={(e) => setFormData({ ...formData, site_type: e.target.value })}
/>
</div>
<div>
<Label>Hosting Type</Label>
<SelectDropdown
options={HOSTING_TYPES}
value={formData.hosting_type}
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value })}
/>
</div>
<div>
<Checkbox
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
label="Active"
/>
</div>
</div>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} variant="primary" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -47,6 +47,7 @@ export default function Tasks() {
const [clusterFilter, setClusterFilter] = useState('');
const [structureFilter, setStructureFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
@@ -129,8 +130,15 @@ export default function Tasks() {
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
// Build search term - combine user search with Site Builder filter if needed
let finalSearchTerm = searchTerm;
if (sourceFilter === 'site_builder') {
// If user has a search term, combine it with Site Builder prefix
finalSearchTerm = searchTerm ? `[Site Builder] ${searchTerm}` : '[Site Builder]';
}
const filters: TasksFilters = {
...(searchTerm && { search: searchTerm }),
...(finalSearchTerm && { search: finalSearchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
@@ -495,6 +503,8 @@ export default function Tasks() {
statusFilter,
setStatusFilter,
clusterFilter,
sourceFilter,
setSourceFilter,
setClusterFilter,
structureFilter,
setStructureFilter,
@@ -502,7 +512,7 @@ export default function Tasks() {
setTypeFilter,
setCurrentPage,
});
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
@@ -565,6 +575,7 @@ export default function Tasks() {
cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
source: sourceFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
@@ -578,6 +589,8 @@ export default function Tasks() {
setStructureFilter(stringValue);
} else if (key === 'content_type') {
setTypeFilter(stringValue);
} else if (key === 'source') {
setSourceFilter(stringValue);
}
setCurrentPage(1);
}}