diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py
index bd8671ba..94b3c453 100644
--- a/backend/igny8_core/business/billing/models.py
+++ b/backend/igny8_core/business/billing/models.py
@@ -816,6 +816,27 @@ class AIModelConfig(models.Model):
help_text="basic / quality / premium - for image models"
)
+ # Image Size Configuration (for image models)
+ landscape_size = models.CharField(
+ max_length=20,
+ null=True,
+ blank=True,
+ help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')"
+ )
+
+ square_size = models.CharField(
+ max_length=20,
+ default='1024x1024',
+ blank=True,
+ help_text="Square image size for this model (e.g., '1024x1024')"
+ )
+
+ valid_sizes = models.JSONField(
+ default=list,
+ blank=True,
+ help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])"
+ )
+
# Model Limits
max_tokens = models.IntegerField(
null=True,
@@ -884,6 +905,21 @@ class AIModelConfig(models.Model):
model_type='image',
is_active=True
).order_by('quality_tier', 'model_name')
+
+ def validate_size(self, size: str) -> bool:
+ """Validate that the given size is valid for this image model"""
+ if not self.valid_sizes:
+ # If no valid_sizes defined, accept common sizes
+ return True
+ return size in self.valid_sizes
+
+ def get_landscape_size(self) -> str:
+ """Get the landscape size for this model"""
+ return self.landscape_size or '1792x1024'
+
+ def get_square_size(self) -> str:
+ """Get the square size for this model"""
+ return self.square_size or '1024x1024'
class WebhookEvent(models.Model):
diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py
index 49602230..78a88d75 100644
--- a/backend/igny8_core/modules/billing/admin.py
+++ b/backend/igny8_core/modules/billing/admin.py
@@ -800,6 +800,11 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'description': 'For IMAGE models only',
'classes': ('collapse',)
}),
+ ('Image Model Sizes', {
+ 'fields': ('landscape_size', 'square_size', 'valid_sizes'),
+ 'description': 'For IMAGE models: specify supported image dimensions',
+ 'classes': ('collapse',)
+ }),
('Capabilities', {
'fields': ('capabilities',),
'description': 'JSON: vision, function_calling, json_mode, etc.',
diff --git a/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py b/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py
new file mode 100644
index 00000000..5213fcb4
--- /dev/null
+++ b/backend/igny8_core/modules/billing/migrations/0027_add_aimodel_image_sizes.py
@@ -0,0 +1,78 @@
+# Generated migration for adding image size fields to AIModelConfig
+from django.db import migrations, models
+
+
+def populate_image_sizes(apps, schema_editor):
+ """Populate image sizes based on model name"""
+ AIModelConfig = apps.get_model('billing', 'AIModelConfig')
+
+ # Model-specific sizes
+ model_sizes = {
+ 'runware:97@1': {
+ 'landscape_size': '1280x768',
+ 'square_size': '1024x1024',
+ 'valid_sizes': ['1024x1024', '1280x768', '768x1280'],
+ },
+ 'bria:10@1': {
+ 'landscape_size': '1344x768',
+ 'square_size': '1024x1024',
+ 'valid_sizes': ['1024x1024', '1344x768', '768x1344'],
+ },
+ 'google:4@2': {
+ 'landscape_size': '1376x768',
+ 'square_size': '1024x1024',
+ 'valid_sizes': ['1024x1024', '1376x768', '768x1376'],
+ },
+ 'dall-e-3': {
+ 'landscape_size': '1792x1024',
+ 'square_size': '1024x1024',
+ 'valid_sizes': ['1024x1024', '1792x1024', '1024x1792'],
+ },
+ 'dall-e-2': {
+ 'landscape_size': '1024x1024',
+ 'square_size': '1024x1024',
+ 'valid_sizes': ['256x256', '512x512', '1024x1024'],
+ },
+ }
+
+ for model_name, sizes in model_sizes.items():
+ AIModelConfig.objects.filter(
+ model_name=model_name,
+ model_type='image'
+ ).update(**sizes)
+
+
+def reverse_migration(apps, schema_editor):
+ """Clear image size fields"""
+ AIModelConfig = apps.get_model('billing', 'AIModelConfig')
+ AIModelConfig.objects.filter(model_type='image').update(
+ landscape_size=None,
+ square_size='1024x1024',
+ valid_sizes=[],
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0026_populate_aimodel_credits'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aimodelconfig',
+ name='landscape_size',
+ field=models.CharField(blank=True, help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')", max_length=20, null=True),
+ ),
+ migrations.AddField(
+ model_name='aimodelconfig',
+ name='square_size',
+ field=models.CharField(blank=True, default='1024x1024', help_text="Square image size for this model (e.g., '1024x1024')", max_length=20),
+ ),
+ migrations.AddField(
+ model_name='aimodelconfig',
+ name='valid_sizes',
+ field=models.JSONField(blank=True, default=list, help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])"),
+ ),
+ migrations.RunPython(populate_image_sizes, reverse_migration),
+ ]
diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py
index 400c5973..90d1f94c 100644
--- a/backend/igny8_core/modules/system/settings_views.py
+++ b/backend/igny8_core/modules/system/settings_views.py
@@ -183,8 +183,8 @@ class ContentSettingsViewSet(viewsets.ViewSet):
setting = AccountSettings.objects.get(account=account, key=pk)
return success_response(data={
'key': setting.key,
- 'config': setting.config,
- 'is_active': setting.is_active,
+ 'config': setting.value, # Model uses 'value', frontend expects 'config'
+ 'is_active': getattr(setting, 'is_active', True),
}, request=request)
except AccountSettings.DoesNotExist:
# Return default settings if not yet saved
@@ -234,17 +234,17 @@ class ContentSettingsViewSet(viewsets.ViewSet):
# Filter to only valid fields
filtered_config = {k: v for k, v in config.items() if k in valid_fields}
- # Get or create setting
+ # Get or create setting - model uses 'value' field
setting, created = AccountSettings.objects.update_or_create(
account=account,
key=pk,
- defaults={'config': filtered_config, 'is_active': True}
+ defaults={'value': filtered_config}
)
return success_response(data={
'key': setting.key,
- 'config': setting.config,
- 'is_active': setting.is_active,
+ 'config': setting.value, # Model uses 'value', frontend expects 'config'
+ 'is_active': getattr(setting, 'is_active', True),
'message': 'Settings saved successfully',
}, request=request)
@@ -607,8 +607,28 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
account=account,
key='ai.image_quality_tier'
).first()
- if tier_setting and tier_setting.config:
- selected_tier = tier_setting.config.get('value', 'quality')
+ if tier_setting and tier_setting.value: # Model uses 'value' field
+ selected_tier = tier_setting.value.get('value', 'quality')
+
+ # Get default image model (or model for selected tier)
+ default_image_model = AIModelConfig.get_default_image_model()
+
+ # Try to find model matching selected tier
+ selected_model = AIModelConfig.objects.filter(
+ model_type='image',
+ quality_tier=selected_tier,
+ is_active=True
+ ).first() or default_image_model
+
+ # Get image sizes from the selected model
+ featured_image_size = '1792x1024' # Default
+ landscape_image_size = '1792x1024' # Default
+ square_image_size = '1024x1024' # Default
+
+ if selected_model:
+ landscape_image_size = selected_model.landscape_size or '1792x1024'
+ square_image_size = selected_model.square_size or '1024x1024'
+ featured_image_size = landscape_image_size # Featured uses landscape
response_data = {
'content_generation': {
@@ -622,6 +642,12 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
'selected_style': image_style,
'max_images': max_images,
'max_allowed': 8,
+ # Image sizes based on selected model
+ 'featured_image_size': featured_image_size,
+ 'landscape_image_size': landscape_image_size,
+ 'square_image_size': square_image_size,
+ 'model_name': selected_model.model_name if selected_model else None,
+ 'model_display_name': selected_model.display_name if selected_model else None,
}
}
@@ -640,7 +666,12 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
PUT/POST /api/v1/accounts/settings/ai/
Save account-specific overrides to AccountSettings.
- Request body per the plan:
+ Accepts nested structure:
+ {
+ "content_generation": { "temperature": 0.8, "max_tokens": 4096 },
+ "image_generation": { "quality_tier": "premium", "image_style": "illustration", "max_images_per_article": 6 }
+ }
+ Or flat structure:
{
"temperature": 0.8,
"max_tokens": 4096,
@@ -662,6 +693,19 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
data = request.data
saved_keys = []
+ # Handle nested structure from frontend
+ content_gen = data.get('content_generation', {})
+ image_gen = data.get('image_generation', {})
+
+ # Flatten nested structure or use flat keys
+ flat_data = {
+ 'temperature': content_gen.get('temperature') if content_gen else data.get('temperature'),
+ 'max_tokens': content_gen.get('max_tokens') if content_gen else data.get('max_tokens'),
+ 'image_quality_tier': image_gen.get('quality_tier') if image_gen else data.get('image_quality_tier'),
+ 'image_style': image_gen.get('image_style') if image_gen else data.get('image_style'),
+ 'max_images': image_gen.get('max_images_per_article') if image_gen else data.get('max_images'),
+ }
+
# Map request fields to AccountSettings keys
key_mappings = {
'temperature': 'ai.temperature',
@@ -672,11 +716,12 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
}
for field, account_key in key_mappings.items():
- if field in data:
+ value = flat_data.get(field)
+ if value is not None:
AccountSettings.objects.update_or_create(
account=account,
key=account_key,
- defaults={'config': {'value': data[field]}}
+ defaults={'value': {'value': value}} # Model uses 'value' field
)
saved_keys.append(account_key)
diff --git a/docs/plans/phase3-content-template-redesign.md b/docs/plans/phase3-content-template-redesign.md
new file mode 100644
index 00000000..1608dc0d
--- /dev/null
+++ b/docs/plans/phase3-content-template-redesign.md
@@ -0,0 +1,815 @@
+---
+
+## 🎨 DESIGN ANALYSIS & PLAN
+
+### Current State Analysis
+
+**WordPress Single Post Template:**
+- Uses emojis (📅, 📝, ✍️, 📁, 🏷️) in header metadata
+- Shows all metadata including internal/debug data (Content ID, Task ID, Sector ID, Cluster ID, etc.)
+- SEO section shows Meta Title & Meta Description in header (should be hidden from users)
+- "Section Spotlight" label is hardcoded
+- Images not optimally distributed - one per section sequentially
+- Container max-width: 1200px
+
+**App ContentViewTemplate:**
+- Uses icons properly via component imports
+- Similar "Section Spotlight" label issue
+- Better image handling with aspect ratio detection
+- Shows extensive metadata in header (Meta Title, Meta Description, Primary/Secondary Keywords)
+- Container max-width: 1440px
+
+---
+
+## 📐 DESIGN PLAN
+
+### 1. CSS Container Width Update
+
+```
+.igny8-content-container {
+ max-width: 1280px; /* Default for screens <= 1600px */
+}
+
+@media (min-width: 1600px) {
+ .igny8-content-container {
+ max-width: 1530px; /* For screens > 1600px */
+ }
+}
+```
+
+---
+
+### 2. WordPress Header Redesign
+
+**USER-FACING FIELDS (Keep in Header):**
+| Field | Display | Icon | Notes |
+|-------|---------|------|-------|
+| Title | H1 | - | Post title |
+| Status Badge | Published/Draft/etc | - | Post status |
+| Posted Date | Formatted date | Calendar SVG | Publication date |
+| Word Count | Formatted number | Document SVG | Content word count |
+| Author | Author name | User SVG | Post author |
+| Topic | Cluster name (clickable)| Compass SVG | Display cluster_name as "Topic" |
+| Categories | Badge list (Parent > Child clicakble) | Folder SVG | WP Categories |
+| Tags | Badge list (clickable)| Tag SVG | WP Tags |
+
+**NON-USER FIELDS (Move to Metadata Section - Editor+ only):**
+- Content ID, Task ID
+- Content Type, Structure
+- Cluster ID (keep cluster_name as Topic in header)
+- Sector ID, Sector Name
+- Primary Keyword, Secondary Keywords
+- Meta Title, Meta Description
+- Source, Last Synced
+
+---
+
+### 3. Section Label Redesign
+
+**Current:** "Section Spotlight" (generic text)
+
+**New Approach - Keyword/Tag Matching Algorithm:**
+
+1. **Source Data:**
+ - Get all WordPress tags assigned to the post
+ - Get all WordPress categories assigned to the post
+ - Get primary keyword from post meta
+ - Get secondary keywords from post meta (if available)
+
+2. **Matching Logic:**
+ - For each section heading (H2), perform case-insensitive partial matching
+ - Check if any tag name appears in the heading text
+ - Check if any category name appears in the heading text
+ - Check if primary/secondary keywords appear in the heading text
+ - Prioritize: Primary Keyword > Tags > Categories > Secondary Keywords
+
+3. **Display Rules:**
+ - If matches found: Display up to 2 matched keywords/tags as badges
+ - If no matches: Display topic (cluster_name) or leave section without label badges
+ - Never display generic "Section Spotlight" text
+
+4. **Badge Styling:**
+ ```
+ [Primary Match] [Secondary Match] ← styled badges replacing "Section Spotlight"
+ ```
+
+**Colors:**
+- Primary badge: `theme-color @ 15% opacity` background, `theme-color` text
+- Secondary badge: `theme-color @ 8% opacity` background, `theme-color @ 80%` text
+
+**Implementation Function (Pseudo-code):**
+```php
+function igny8_get_section_badges($heading, $post_id) {
+ $badges = [];
+ $heading_lower = strtolower($heading);
+
+ // Get post taxonomies and keywords
+ $tags = get_the_tags($post_id);
+ $categories = get_the_category($post_id);
+ $primary_kw = get_post_meta($post_id, '_igny8_primary_keyword', true);
+ $secondary_kws = get_post_meta($post_id, '_igny8_secondary_keywords', true);
+
+ // Priority 1: Primary keyword
+ if ($primary_kw && stripos($heading_lower, strtolower($primary_kw)) !== false) {
+ $badges[] = ['text' => $primary_kw, 'type' => 'primary'];
+ }
+
+ // Priority 2: Tags
+ if ($tags && count($badges) < 2) {
+ foreach ($tags as $tag) {
+ if (stripos($heading_lower, strtolower($tag->name)) !== false) {
+ $badges[] = ['text' => $tag->name, 'type' => 'tag'];
+ if (count($badges) >= 2) break;
+ }
+ }
+ }
+
+ // Priority 3: Categories
+ if ($categories && count($badges) < 2) {
+ foreach ($categories as $cat) {
+ if (stripos($heading_lower, strtolower($cat->name)) !== false) {
+ $badges[] = ['text' => $cat->name, 'type' => 'category'];
+ if (count($badges) >= 2) break;
+ }
+ }
+ }
+
+ // Priority 4: Secondary keywords
+ if ($secondary_kws && count($badges) < 2) {
+ $kw_array = is_array($secondary_kws) ? $secondary_kws : explode(',', $secondary_kws);
+ foreach ($kw_array as $kw) {
+ $kw = trim($kw);
+ if (stripos($heading_lower, strtolower($kw)) !== false) {
+ $badges[] = ['text' => $kw, 'type' => 'keyword'];
+ if (count($badges) >= 2) break;
+ }
+ }
+ }
+
+ return $badges;
+}
+```
+
+---
+
+### 4. Image Distribution Strategy
+
+**Available Images (4 total):**
+- Position 0: Square (1024×1024)
+- Position 1: Landscape (1536×1024 or 1920×1080)
+- Position 2: Square (1024×1024)
+- Position 3: Landscape (1536×1024 or 1920×1080)
+
+**Distribution Plan - First 4 Sections (with descriptions):**
+
+| Section | Image Position | Type | Width | Alignment | Description |
+|---------|---------------|------|-------|-----------|-------------|
+| **Featured** | Position 1 | Landscape | 100% max 1024px | Center | Show prompt on first use |
+| **Section 1** | Position 0 | Square | 50% | Right | With description + widget placeholder below |
+| **Section 2** | Position 3 | Landscape | 100% max 1024px | Full width | With description |
+| **Section 3** | Position 2 | Square | 50% | Left | With description + widget placeholder below |
+| **Section 4** | Position 1 (reuse) | Landscape | 100% max 1024px | Full width | With description |
+
+**Distribution Plan - Sections 5-7+ (reuse without descriptions):**
+
+| Section | Reuse Image | Type | Width | Alignment | Description |
+|---------|-------------|------|-------|-----------|-------------|
+| **Section 5** | Featured (pos 1) | Landscape | 100% max 1024px | Full width | NO description |
+| **Section 6** | Position 0 | Square | 50% | Right | NO description + widget placeholder |
+| **Section 7** | Position 3 | Landscape | 100% max 1024px | Full width | NO description |
+| **Section 8+** | Cycle through all 4 | Based on type | Based on type | Based on type | NO description |
+
+**Special Case - Tables:**
+- When section contains `
` element, always place full-width landscape image BEFORE table
+- Use next available landscape image (Position 1 or 3)
+- Max width: 1024px, centered
+- Spacing: `margin-bottom: 2rem` before table
+- Override normal section pattern when table detected
+
+**Image Reuse Rules:**
+- Images 1-4 used in first 4 sections WITH descriptions/prompts
+- Sections 5+ reuse same images WITHOUT descriptions/prompts
+- Use CSS classes: `.igny8-image-first-use` vs `.igny8-image-reuse`
+- Maintain same layout pattern (square = 50%, landscape = 100%)
+
+**Widget Placeholders:**
+- Show only below square images (left/right aligned)
+- Empty div with class `.igny8-widget-placeholder`
+- Space reserved for future widget insertion
+- Controlled via plugin settings (future implementation)
+
+**Implementation Notes:**
+```php
+// Check for table in section content
+function igny8_section_has_table($section_html) {
+ return (stripos($section_html, '