diff --git a/backend/igny8_core/auth/migrations/0015_add_site_type_hosting_type.py b/backend/igny8_core/auth/migrations/0015_add_site_type_hosting_type.py new file mode 100644 index 00000000..c584cdda --- /dev/null +++ b/backend/igny8_core/auth/migrations/0015_add_site_type_hosting_type.py @@ -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'), + ), + ] + diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index 4bc11a86..da777f0a 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -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): diff --git a/backend/igny8_core/business/integration/apps.py b/backend/igny8_core/business/integration/apps.py new file mode 100644 index 00000000..00f8b937 --- /dev/null +++ b/backend/igny8_core/business/integration/apps.py @@ -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' + diff --git a/backend/igny8_core/business/integration/migrations/0001_initial.py b/backend/igny8_core/business/integration/migrations/0001_initial.py new file mode 100644 index 00000000..96dcb7ec --- /dev/null +++ b/backend/igny8_core/business/integration/migrations/0001_initial.py @@ -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')}, + ), + ] + diff --git a/backend/igny8_core/business/integration/migrations/__init__.py b/backend/igny8_core/business/integration/migrations/__init__.py new file mode 100644 index 00000000..aa01f874 --- /dev/null +++ b/backend/igny8_core/business/integration/migrations/__init__.py @@ -0,0 +1,4 @@ +""" +Integration Migrations +""" + diff --git a/backend/igny8_core/business/integration/models.py b/backend/igny8_core/business/integration/models.py new file mode 100644 index 00000000..1b3ff95b --- /dev/null +++ b/backend/igny8_core/business/integration/models.py @@ -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 + diff --git a/backend/igny8_core/business/integration/services/__init__.py b/backend/igny8_core/business/integration/services/__init__.py new file mode 100644 index 00000000..93ebae09 --- /dev/null +++ b/backend/igny8_core/business/integration/services/__init__.py @@ -0,0 +1,4 @@ +""" +Integration Services +""" + diff --git a/backend/igny8_core/business/integration/services/content_sync_service.py b/backend/igny8_core/business/integration/services/content_sync_service.py new file mode 100644 index 00000000..9f25eaed --- /dev/null +++ b/backend/igny8_core/business/integration/services/content_sync_service.py @@ -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' + } + diff --git a/backend/igny8_core/business/integration/services/integration_service.py b/backend/igny8_core/business/integration/services/integration_service.py new file mode 100644 index 00000000..942843c6 --- /dev/null +++ b/backend/igny8_core/business/integration/services/integration_service.py @@ -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': {} + } + diff --git a/backend/igny8_core/business/integration/services/sync_service.py b/backend/igny8_core/business/integration/services/sync_service.py new file mode 100644 index 00000000..b1318a09 --- /dev/null +++ b/backend/igny8_core/business/integration/services/sync_service.py @@ -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 + } + diff --git a/backend/igny8_core/business/publishing/services/adapters/base_adapter.py b/backend/igny8_core/business/publishing/services/adapters/base_adapter.py new file mode 100644 index 00000000..443ec14b --- /dev/null +++ b/backend/igny8_core/business/publishing/services/adapters/base_adapter.py @@ -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 + diff --git a/backend/igny8_core/business/publishing/services/adapters/shopify_adapter.py b/backend/igny8_core/business/publishing/services/adapters/shopify_adapter.py new file mode 100644 index 00000000..ba7f5b47 --- /dev/null +++ b/backend/igny8_core/business/publishing/services/adapters/shopify_adapter.py @@ -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 + diff --git a/backend/igny8_core/business/publishing/services/adapters/wordpress_adapter.py b/backend/igny8_core/business/publishing/services/adapters/wordpress_adapter.py new file mode 100644 index 00000000..403bb349 --- /dev/null +++ b/backend/igny8_core/business/publishing/services/adapters/wordpress_adapter.py @@ -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) + diff --git a/backend/igny8_core/business/publishing/services/publisher_service.py b/backend/igny8_core/business/publishing/services/publisher_service.py index c1cbdc4c..ad52b343 100644 --- a/backend/igny8_core/business/publishing/services/publisher_service.py +++ b/backend/igny8_core/business/publishing/services/publisher_service.py @@ -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 diff --git a/backend/igny8_core/modules/integration/__init__.py b/backend/igny8_core/modules/integration/__init__.py new file mode 100644 index 00000000..3b5711fd --- /dev/null +++ b/backend/igny8_core/modules/integration/__init__.py @@ -0,0 +1,5 @@ +""" +Integration Module +Phase 6: Site Integration & Multi-Destination Publishing +""" + diff --git a/backend/igny8_core/modules/integration/apps.py b/backend/igny8_core/modules/integration/apps.py new file mode 100644 index 00000000..482c6be2 --- /dev/null +++ b/backend/igny8_core/modules/integration/apps.py @@ -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' + diff --git a/backend/igny8_core/modules/integration/urls.py b/backend/igny8_core/modules/integration/urls.py new file mode 100644 index 00000000..2c203a0d --- /dev/null +++ b/backend/igny8_core/modules/integration/urls.py @@ -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)), +] + diff --git a/backend/igny8_core/modules/integration/views.py b/backend/igny8_core/modules/integration/views.py new file mode 100644 index 00000000..7f003e4d --- /dev/null +++ b/backend/igny8_core/modules/integration/views.py @@ -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) + diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 4d228bf3..4dae88e4 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -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 diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index cd947d21..c1cd374f 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -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'), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2a35795..6d8ac510 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + } /> diff --git a/frontend/src/components/integration/IntegrationStatus.tsx b/frontend/src/components/integration/IntegrationStatus.tsx new file mode 100644 index 00000000..fa11b88a --- /dev/null +++ b/frontend/src/components/integration/IntegrationStatus.tsx @@ -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 ; + case 'failed': + return ; + case 'syncing': + return ; + default: + return ; + } + }; + + 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 ( +
+
+ {getStatusIcon()} + {getStatusText()} +
+ + {syncEnabled && ( +
+
Last sync: {formatDate(lastSyncAt)}
+ {syncError && ( +
+ Error: {syncError} +
+ )} +
+ )} + + {!syncEnabled && ( +
+ Two-way sync is disabled +
+ )} +
+ ); +} + diff --git a/frontend/src/components/integration/PlatformSelector.tsx b/frontend/src/components/integration/PlatformSelector.tsx new file mode 100644 index 00000000..8128fc59 --- /dev/null +++ b/frontend/src/components/integration/PlatformSelector.tsx @@ -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 ( + onChange(e.target.value)} + disabled={disabled} + placeholder="Select platform" + /> + ); +} + diff --git a/frontend/src/components/integration/SiteIntegrationsSection.tsx b/frontend/src/components/integration/SiteIntegrationsSection.tsx new file mode 100644 index 00000000..9c89189c --- /dev/null +++ b/frontend/src/components/integration/SiteIntegrationsSection.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [selectedIntegration, setSelectedIntegration] = useState(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) => { + 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: ( + {}} + 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
Loading integrations...
; + } + + return ( +
+
+
+

Site Integrations

+

+ Connect your sites to external platforms (WordPress, Shopify, Custom APIs) +

+
+ +
+ + {integrations.length === 0 ? ( +
+

No integrations configured

+ +
+ ) : ( +
+ {integrations.map((integration) => ( +
+
+
+

+ {integration.platform} +

+ {integration.site_name && ( +

+ {integration.site_name} +

+ )} +
+
+ + +
+
+ + + +
+ + {integration.sync_enabled && ( + + )} +
+
+ ))} +
+ )} + + {/* Add/Edit Modal */} + { + 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} + /> +
+ ); +} + diff --git a/frontend/src/components/publishing/PublishingRules.tsx b/frontend/src/components/publishing/PublishingRules.tsx new file mode 100644 index 00000000..a98da0fd --- /dev/null +++ b/frontend/src/components/publishing/PublishingRules.tsx @@ -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(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 ( +
+
+
+

+ Publishing Rules +

+

+ Configure how and where content is published +

+
+ +
+ + {localRules.length === 0 ? ( +
+

No publishing rules configured

+ +
+ ) : ( +
+ {localRules.map((rule, index) => ( +
+
+
+
+ + + handleUpdateRule(rule.id, 'content_type', e.target.value) + } + /> +
+ +
+ + + handleUpdateRule(rule.id, 'trigger', e.target.value) + } + /> +
+ +
+ +
+ + {rule.priority} + +
+
+
+ +
+ + handleUpdateRule(rule.id, 'enabled', e.target.checked) + } + label="Enabled" + /> + +
+
+ +
+ +
+ {DESTINATIONS.map((dest) => ( + handleToggleDestinations(rule.id, dest.value)} + label={dest.label} + /> + ))} +
+
+ + {rule.trigger === 'scheduled' && ( +
+ + + 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" + /> +

+ Cron format: minute hour day month weekday +

+
+ )} +
+ ))} +
+ )} +
+ ); +} + diff --git a/frontend/src/config/pages/tasks.config.tsx b/frontend/src/config/pages/tasks.config.tsx index 51f5bbda..61011647 100644 --- a/frontend/src/config/pages/tasks.config.tsx +++ b/frontend/src/config/pages/tasks.config.tsx @@ -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 ( +
+ + {displayTitle} + + {isSiteBuilder && ( + + Site Builder + + )} +
+ ); + }, }, // 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', diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index db7f7e5a..2b4349b4 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -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" }, ], }, diff --git a/frontend/src/pages/Settings/Integration.tsx b/frontend/src/pages/Settings/Integration.tsx index 7244ab67..a33d1dd6 100644 --- a/frontend/src/pages/Settings/Integration.tsx +++ b/frontend/src/pages/Settings/Integration.tsx @@ -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() { } /> + + {/* Site Integrations Section */} + {/* Details Modal */} diff --git a/frontend/src/pages/Settings/Publishing.tsx b/frontend/src/pages/Settings/Publishing.tsx new file mode 100644 index 00000000..2f89743a --- /dev/null +++ b/frontend/src/pages/Settings/Publishing.tsx @@ -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(['sites']); + const [autoPublishEnabled, setAutoPublishEnabled] = useState(false); + const [publishingRules, setPublishingRules] = useState([]); + + 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 ( +
+ +
+
Loading...
+
+
+ ); + } + + return ( +
+ + +
+

+ Publishing Settings +

+

+ Configure default publishing destinations and rules +

+
+ +
+ {/* Default Destinations */} + +
+
+

+ Default Publishing Destinations +

+

+ Select default platforms where content will be published +

+
+ +
+ {DESTINATIONS.map((dest) => ( +
+ handleToggleDestination(dest.value)} + label={dest.label} + /> +
+ ))} +
+
+
+ + {/* Auto-Publish Settings */} + +
+
+

+ Auto-Publish Settings +

+

+ Automatically publish content when it's ready +

+
+ +
+ setAutoPublishEnabled(e.target.checked)} + label="Enable auto-publish" + /> +
+ + {autoPublishEnabled && ( +
+

+ When enabled, content will be automatically published to selected destinations + when generation is complete. +

+
+ )} +
+
+ + {/* Publishing Rules */} + + + + + {/* Save Button */} +
+ +
+
+
+ ); +} + diff --git a/frontend/src/pages/Sites/Editor.tsx b/frontend/src/pages/Sites/Editor.tsx new file mode 100644 index 00000000..927341f8 --- /dev/null +++ b/frontend/src/pages/Sites/Editor.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedPage, setSelectedPage] = useState(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 ( +
+ +
+
Loading pages...
+
+
+ ); + } + + return ( +
+ + +
+

+ Site Content Editor +

+

+ Edit content for site pages +

+
+ + +
+

+ Content editor will be implemented in Phase 7 +

+
+
+
+ ); +} + diff --git a/frontend/src/pages/Sites/Manage.tsx b/frontend/src/pages/Sites/Manage.tsx new file mode 100644 index 00000000..2488b726 --- /dev/null +++ b/frontend/src/pages/Sites/Manage.tsx @@ -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([]); + 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 ( +
+ +
+
Loading sites...
+
+
+ ); + } + + return ( +
+ + +
+
+

+ Site Management +

+

+ Manage your sites, pages, and content +

+
+ +
+ + {sites.length === 0 ? ( + +

+ No sites created yet +

+ +
+ ) : ( +
+ {sites.map((site) => ( + +
+
+
+

+ {site.name} +

+

+ {site.slug} +

+
+ + {site.is_active ? 'Active' : 'Inactive'} + +
+ +
+ + {site.site_type} + + + {site.hosting_type} + +
+ +
+
+ {site.page_count || 0} pages +
+
+ + + + +
+
+
+
+ ))} +
+ )} +
+ ); +} + diff --git a/frontend/src/pages/Sites/PageManager.tsx b/frontend/src/pages/Sites/PageManager.tsx new file mode 100644 index 00000000..75caba38 --- /dev/null +++ b/frontend/src/pages/Sites/PageManager.tsx @@ -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([]); + 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 ( +
+ +
+
Loading pages...
+
+
+ ); + } + + return ( +
+ + +
+
+

+ Page Manager +

+

+ Manage pages for your site +

+
+ +
+ + {pages.length === 0 ? ( + +

+ No pages created yet +

+ +
+ ) : ( + +
+ {pages.map((page, index) => ( +
+
+
+ + +
+
+

+ {page.title} +

+

+ /{page.slug} • {page.type} • {page.status} +

+
+
+
+ + +
+
+ ))} +
+
+ )} +
+ ); +} + diff --git a/frontend/src/pages/Sites/Settings.tsx b/frontend/src/pages/Sites/Settings.tsx new file mode 100644 index 00000000..6be89b13 --- /dev/null +++ b/frontend/src/pages/Sites/Settings.tsx @@ -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(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 ( +
+ +
+
Loading site settings...
+
+
+ ); + } + + return ( +
+ + +
+

+ Site Settings +

+

+ Configure site type, hosting, and other settings +

+
+ +
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + setFormData({ ...formData, site_type: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, hosting_type: e.target.value })} + /> +
+ +
+ setFormData({ ...formData, is_active: e.target.checked })} + label="Active" + /> +
+
+
+ +
+ +
+
+
+ ); +} + diff --git a/frontend/src/pages/Writer/Tasks.tsx b/frontend/src/pages/Writer/Tasks.tsx index 55a05b67..eb877fdc 100644 --- a/frontend/src/pages/Writer/Tasks.tsx +++ b/frontend/src/pages/Writer/Tasks.tsx @@ -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([]); // 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); }}