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, '`: +- Place landscape image ABOVE the table +- Full width (max 800px) +- Proper spacing: `margin-bottom: 2rem` +- Table should not wrap around image + +--- + +### 6. Responsive Image Width Rules + +```css +/* Landscape images */ +.igny8-image-landscape { + max-width: 1024px; /* Updated from 800px */ + width: 100%; + margin: 0 auto; + display: block; +} + +.igny8-image-landscape.igny8-image-reuse { + /* No description shown on reuse */ +} + +/* Single square image - Right aligned */ +.igny8-image-square-right { + max-width: 50%; + margin-left: auto; + float: right; + margin-left: 2rem; + margin-bottom: 2rem; +} + +/* Single square image - Left aligned */ +.igny8-image-square-left { + max-width: 50%; + margin-right: auto; + float: left; + margin-right: 2rem; + margin-bottom: 2rem; +} + +/* Widget placeholder below square images */ +.igny8-widget-placeholder { + clear: both; + min-height: 200px; + padding: 1.5rem; + margin-top: 1rem; + background: rgba(0, 0, 0, 0.02); + border: 1px dashed rgba(0, 0, 0, 0.1); + border-radius: 12px; + display: none; /* Hidden by default, shown when widgets enabled */ +} + +.igny8-widget-placeholder.igny8-widgets-enabled { + display: block; +} + +/* Table-specific image positioning */ +.igny8-image-before-table { + max-width: 1024px; + width: 100%; + margin: 0 auto 2rem; + display: block; +} +``` + +--- + +### 7. Role-Based Visibility + +**Metadata Section (Bottom):** +```php + + + +``` + +**Visible only to:** +- Editor +- Administrator +- Author (for their own posts) + +--- + +### 8. Header Icon Set (Replace Emojis) + +Create inline SVG icons matching theme color: + +```php +// Calendar Icon + + + + +// Document Icon (Word Count) + + + + +// User Icon (Author) + + + + +// Compass Icon (Topic/Cluster) + + + + +// Folder Icon (Categories) + + + + +// Tag Icon (Tags) + + + +``` + +Icon styling: +```css +.igny8-icon { + width: 1rem; + height: 1rem; + color: var(--igny8-theme-color, currentColor); + opacity: 0.8; + display: inline-block; + vertical-align: middle; +} +``` + +--- + +### 9. Table of Contents + +**Position:** Below featured image, before intro section + +**Content:** List all H2 headings from content + +**Features:** +- Clickable links with smooth scroll to sections +- Collapsible/expandable (optional) +- Numbered list matching section numbers +- Sticky positioning option (future setting) + +**Implementation:** +```php +function igny8_generate_table_of_contents($content) { + $toc_items = []; + + // Parse content for H2 headings + preg_match_all('/]*>(.*?)<\/h2>/i', $content, $matches); + + if (!empty($matches[1])) { + foreach ($matches[1] as $index => $heading) { + $heading_text = strip_tags($heading); + $slug = sanitize_title($heading_text); + $toc_items[] = [ + 'number' => $index + 1, + 'text' => $heading_text, + 'id' => $slug + ]; + } + } + + return $toc_items; +} +``` + +**HTML Structure:** +```html + +``` + +**CSS:** +```css +.igny8-table-of-contents { + background: var(--wp--preset--color--base, #ffffff); + border: 2px solid rgba(0, 0, 0, 0.12); + border-radius: 16px; + padding: 1.5rem 2rem; + margin-bottom: 2rem; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.igny8-toc-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.igny8-toc-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.igny8-toc-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.igny8-toc-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + text-decoration: none; + color: inherit; + border-radius: 8px; + transition: background-color 0.2s ease; +} + +.igny8-toc-link:hover { + background: rgba(0, 0, 0, 0.04); +} + +.igny8-toc-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + background: rgba(59, 130, 246, 0.1); + color: rgba(59, 130, 246, 1); + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.igny8-toc-text { + flex: 1; + font-size: 0.9375rem; +} +``` + +**Settings (Future Implementation):** +```php +// Plugin settings for TOC +$igny8_toc_settings = [ + 'enabled' => true, + 'show_numbers' => true, + 'collapsible' => false, + 'sticky' => false, + 'min_headings' => 3, // Only show if 3+ H2 headings +]; +``` + +--- + +### 10. Widget System + +**Widget Placeholders:** + +Widgets appear below square images (left/right aligned) where there's natural space. + +**Placeholder Function:** +```php +function igny8_render_widget_placeholder($position, $section_index) { + // Check if widgets are enabled in settings + $widgets_enabled = get_option('igny8_widgets_enabled', false); + + if (!$widgets_enabled) { + return ''; + } + + $placeholder_class = 'igny8-widget-placeholder igny8-widgets-enabled'; + $placeholder_class .= ' igny8-widget-' . $position; // left or right + $placeholder_class .= ' igny8-widget-section-' . $section_index; + + ?> +
+ + +
+ false, + 'sections' => [ + 'section_1' => [ + 'position' => 'right', + 'widget_type' => 'related_posts', // or 'custom_html', 'ad_unit', etc. + 'content' => '', + ], + 'section_3' => [ + 'position' => 'left', + 'widget_type' => 'newsletter_signup', + 'content' => '', + ], + ], +]; +``` + +**Widget Types (Future):** +- Related Posts +- Newsletter Signup +- Ad Units +- Custom HTML +- Social Share Buttons +- Author Bio +- Call-to-Action Boxes + +--- + +### 11. Updated Structure Overview + +**WordPress Single Post:** +``` +┌─────────────────────────────────────────────┐ +│ HEADER │ +│ ← Back to Posts │ +│ │ +│ [H1 Title] [Status Badge] │ +│ │ +│ 📅 Posted: Date 📄 Words 👤 Author │ +│ 🧭 Topic: Cluster Name │ +│ 📁 [Category Badges] 🏷️ [Tag Badges] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ FEATURED IMAGE (Landscape, max 1024px) │ +│ │ +│ [Image Prompt - first use only] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ TABLE OF CONTENTS │ +│ 📑 Table of Contents │ +│ 1. Section Heading One │ +│ 2. Section Heading Two │ +│ 3. Section Heading Three │ +│ ... (clickable, smooth scroll) │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ INTRO SECTION │ +│ Opening Narrative │ +│ [Content...] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 1 │ +│ [Keyword Badge] [Tag Badge] │ +│ 1 [H2 Heading] │ +│ │ +│ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ │ │ │ │ +│ │ Content │ │ Square Image (50%) │ │ +│ │ │ │ Right Aligned │ │ +│ │ │ │ [Image Description] │ │ +│ └──────────────┘ └──────────────────────┘ │ +│ [Widget Placeholder] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 2 │ +│ [Keyword Badge] │ +│ 2 [H2 Heading] │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ Landscape Image (100% max 1024px) │ │ +│ │ [Image Description] │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ [Content...] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 3 │ +│ [Keyword Badge] [Tag Badge] │ +│ 3 [H2 Heading] │ +│ │ +│ ┌──────────────────────┐ ┌──────────────┐ │ +│ │ │ │ │ │ +│ │ Square Image (50%) │ │ Content │ │ +│ │ Left Aligned │ │ │ │ +│ │ [Image Description] │ │ │ │ +│ └──────────────────────┘ └──────────────┘ │ +│ [Widget Placeholder] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 4 (with table example) │ +│ [Keyword Badge] │ +│ 4 [H2 Heading] │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ Landscape Image (100% max 1024px) │ │ +│ │ [Image Description] │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ [Content before table...] │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ TABLE │ │ +│ │ [Data rows and columns] │ │ +│ └───────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 5 (reuse - no description) │ +│ [Keyword Badge] │ +│ 5 [H2 Heading] │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ Featured Image REUSED (no caption) │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ [Content...] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SECTION 6 (reuse - no description) │ +│ [Tag Badge] │ +│ 6 [H2 Heading] │ +│ │ +│ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ Content │ │ Square Image REUSED │ │ +│ │ │ │ (no caption) │ │ +│ └──────────────┘ └──────────────────────┘ │ +│ [Widget Placeholder] │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ METADATA FOOTER (Editor+ only) │ +│ ▸ View IGNY8 Metadata │ +│ - Content ID: 123 │ +│ - Task ID: 456 │ +│ - Meta Title: ... │ +│ - Meta Description: ... │ +│ - Primary Keyword: ... │ +│ - Secondary Keywords: [list] │ +│ - Cluster ID: 789 │ +│ - Sector: Industry Name │ +│ - Source: AI Generated │ +│ - Last Synced: Date/Time │ +└─────────────────────────────────────────────┘ +``` + +--- + +### 12. App ContentViewTemplate Updates + +**Changes to body section only (not header):** + +1. **Remove "Section Spotlight" label** - Replace with keyword badge matching system +2. **Add Table of Contents** below featured image (matching WordPress implementation) +3. **Match image layout rules** from WordPress template: + - Section 1: Square right-aligned 50% (with description) + - Section 2: Landscape full width max 1024px (with description) + - Section 3: Square left-aligned 50% (with description) + - Section 4: Landscape full width max 1024px (with description) + - Sections 5+: Reuse images without descriptions +4. **Featured image** max 1024px centered +5. **Widget placeholders** below square images (empty for now) +6. **Table detection** - full-width image before tables + +**Implementation Priority:** +- Phase 1: Update image sizing (1024px max) +- Phase 2: Implement keyword badge matching +- Phase 3: Add table of contents component +- Phase 4: Add widget placeholder divs + +--- + +## Summary of Files to Update + +| File | Changes | Priority | +|------|---------|----------| +| `igny8-content-template.css` | Container width breakpoints, image sizing classes, TOC styles, widget placeholder styles | 🔴 High | +| `igny8-header.php` | Remove emojis, add SVG icons, add Topic field, remove SEO/internal metadata | 🔴 High | +| `igny8-metadata.php` | Add role check (`current_user_can('edit_posts')`), include all moved metadata fields | 🔴 High | +| `igny8-content-sections.php` | Keyword badge matching logic, smart image distribution (Section 1-4 pattern), widget placeholders | 🔴 High | +| `igny8-featured-image.php` | Max 1024px, landscape priority | 🟡 Medium | +| `includes/template-functions.php` | Add helper functions: `igny8_get_section_badges()`, `igny8_section_has_table()`, `igny8_show_image_description()`, `igny8_generate_table_of_contents()` | 🔴 High | +| `ContentViewTemplate.tsx` | Match section labels, image layouts, add TOC component, widget placeholders | 🟡 Medium | +| **New File**: `parts/igny8-table-of-contents.php` | Table of contents component | 🟡 Medium | +| **New File**: `admin/settings-page.php` | Widget settings, TOC settings (future) | 🟢 Low | + +--- + +## Configuration Settings (Future Implementation) + +```php +// Plugin settings structure +$igny8_plugin_settings = [ + 'table_of_contents' => [ + 'enabled' => true, + 'show_numbers' => true, + 'collapsible' => false, + 'sticky' => false, + 'min_headings' => 3, + 'position' => 'after_featured_image', // or 'before_content', 'floating' + ], + 'widgets' => [ + 'enabled' => false, + 'sections' => [ + 'section_1' => [ + 'position' => 'right', + 'widget_type' => 'none', // 'related_posts', 'custom_html', 'ad_unit', etc. + 'content' => '', + ], + 'section_3' => [ + 'position' => 'left', + 'widget_type' => 'none', + 'content' => '', + ], + ], + ], + 'images' => [ + 'featured_max_width' => 1024, + 'landscape_max_width' => 1024, + 'square_width_percentage' => 50, + 'show_descriptions_sections' => 4, // Show descriptions in first N sections + ], + 'badges' => [ + 'show_section_badges' => true, + 'max_badges_per_section' => 2, + 'badge_sources' => ['primary_keyword', 'tags', 'categories', 'secondary_keywords'], // Priority order + ], +]; +``` + +--- + +## Implementation Phases + +### Phase 1: Core Template Updates (Week 1) +- ✅ Update CSS container widths and image sizing +- ✅ Replace emojis with SVG icons in header +- ✅ Add Topic field to header +- ✅ Move metadata to bottom with role check +- ✅ Implement keyword badge matching logic + +### Phase 2: Advanced Features (Week 2) +- ⏳ Table of contents component +- ⏳ Widget placeholder system +- ⏳ Table detection and image positioning +- ⏳ Image reuse logic (sections 5+) + +### Phase 3: App Sync (Week 3) +- ⏳ Update ContentViewTemplate.tsx to match WordPress +- ⏳ Add TOC component to React app +- ⏳ Sync image layouts and sizing + +### Phase 4: Settings & Configuration (Week 4) +- ⏳ Plugin settings page +- ⏳ TOC configuration options +- ⏳ Widget management interface +- ⏳ Badge display preferences + +--- + +**Last Updated:** January 10, 2026 +**Document Version:** 2.0 +**Status:** Design Complete - Ready for Implementation \ No newline at end of file diff --git a/frontend/src/config/pages/images.config.tsx b/frontend/src/config/pages/images.config.tsx index d5db4e5b..2d66bb23 100644 --- a/frontend/src/config/pages/images.config.tsx +++ b/frontend/src/config/pages/images.config.tsx @@ -111,14 +111,17 @@ export const createImagesPageConfig = ( ]; // Add in-article image columns dynamically - for (let i = 1; i <= maxImages; i++) { + // Backend uses 0-indexed positions (0, 1, 2, 3) + // Display uses 1-indexed labels (In-Article 1, 2, 3, 4) + for (let i = 0; i < maxImages; i++) { + const displayIndex = i + 1; // 1-indexed for display columns.push({ - key: `in_article_${i}`, - label: `In-Article ${i}`, + key: `in_article_${displayIndex}`, + label: `In-Article ${displayIndex}`, sortable: false, width: '150px', render: (_value: any, row: ContentImagesGroup) => { - const image = row.in_article_images.find(img => img.position === i); + const image = row.in_article_images.find(img => img.position === i); // 0-indexed position return ( ([]); const [selectedIndustry, setSelectedIndustry] = useState(''); @@ -364,6 +369,11 @@ export default function SiteSettings() { setSelectedStyle(response.image_generation.selected_style || 'photorealistic'); setMaxImages(response.image_generation.max_images ?? 4); setMaxAllowed(response.image_generation.max_allowed ?? 4); + + // Set image sizes from model config + setFeaturedImageSize(response.image_generation.featured_image_size || '1792x1024'); + setLandscapeImageSize(response.image_generation.landscape_image_size || '1792x1024'); + setSquareImageSize(response.image_generation.square_image_size || '1024x1024'); } } catch (error: any) { console.error('Error loading AI settings:', error); @@ -986,6 +996,22 @@ export default function SiteSettings() { className="w-full" /> + + {/* Image Sizes Display */} +
+
+

Featured Image

+

{featuredImageSize}

+
+
+

Landscape

+

{landscapeImageSize}

+
+
+

Square

+

{squareImageSize}

+
+
)} diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index 21db3e7f..fd00b009 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -455,7 +455,8 @@ export default function Images() { if (imageType === 'featured' && contentGroup.featured_image) { image = contentGroup.featured_image; - } else if (imageType === 'in_article' && position) { + } else if (imageType === 'in_article' && position !== undefined) { + // Position is 0-indexed, so check for undefined instead of falsy image = contentGroup.in_article_images.find(img => img.position === position) || null; } diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx index 376a952f..4fcabe0d 100644 --- a/frontend/src/templates/ContentViewTemplate.tsx +++ b/frontend/src/templates/ContentViewTemplate.tsx @@ -295,10 +295,12 @@ const SectionImageBlock = ({ image, loading, heading, + showPrompt = true, }: { image: ImageRecord | null; loading: boolean; heading: string; + showPrompt?: boolean; }) => { if (!image && !loading) return null; @@ -323,7 +325,7 @@ const SectionImageBlock = ({ - {image?.caption && ( + {showPrompt && image?.caption && (

Image Caption @@ -391,12 +393,14 @@ const splitAtFirstH3 = (html: string): { beforeH3: string; h3AndAfter: string } }; /** - * ContentSectionBlock - Renders a content section with image layout based on aspect ratio + * ContentSectionBlock - Renders a content section with image layout based on distribution pattern * - * Layout rules: - * - Single landscape image: 100% width (full width) - * - Single square image: 50% width (centered) - * - Two square images (paired): Side by side (50% each) + * Layout rules (first 4 sections): + * - Section 1: Square image right-aligned (50%) with description + * - Section 2: Landscape image full-width (1024px) with description + * - Section 3: Square image left-aligned (50%) with description + * - Section 4: Landscape image full-width (1024px) with description + * - Sections 5+: Reuse images without descriptions */ const ContentSectionBlock = ({ section, @@ -404,32 +408,33 @@ const ContentSectionBlock = ({ loading, index, aspectRatio = 'square', - pairedSquareImage = null, + imageAlign = 'full', + showDescription = true, }: { section: ArticleSection; image: ImageRecord | null; loading: boolean; index: number; aspectRatio?: 'square' | 'landscape'; - pairedSquareImage?: ImageRecord | null; + imageAlign?: 'left' | 'right' | 'full'; + showDescription?: boolean; }) => { const hasImage = Boolean(image); - const hasPairedImage = Boolean(pairedSquareImage); const headingLabel = section.heading || `Section ${index + 1}`; const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml); - // Determine image container width class based on aspect ratio and pairing + // Determine image container width class based on aspect ratio and alignment const getImageContainerClass = () => { - if (hasPairedImage) { - // Two squares side by side - return 'w-full'; - } if (aspectRatio === 'landscape') { - // Landscape: 100% width - return 'w-full'; + return 'w-full max-w-[1024px] mx-auto'; } - // Single square: 50% width centered - return 'w-full max-w-[50%]'; + if (imageAlign === 'left') { + return 'w-full max-w-[50%] mr-auto'; + } + if (imageAlign === 'right') { + return 'w-full max-w-[50%] ml-auto'; + } + return 'w-full max-w-[50%] mx-auto'; }; return ( @@ -460,25 +465,12 @@ const ContentSectionBlock = ({ )} - {/* Image section - layout depends on aspect ratio */} + {/* Image section - layout depends on aspect ratio and alignment */} {hasImage && (

- {hasPairedImage ? ( - // Two squares side by side (50% each) -
-
- -
-
- -
-
- ) : ( - // Single image with width based on aspect ratio -
- -
- )} +
+ +
)} @@ -513,21 +505,22 @@ interface ArticleBodyProps { const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => { const hasStructuredSections = sections.length > 0; - // Determine image aspect ratio from record or fallback to position-based calculation - // Position 0, 2 = square (1024x1024), Position 1, 3 = landscape (model-specific) - const getImageAspectRatio = (image: ImageRecord | null, index: number): 'square' | 'landscape' => { - if (image?.aspect_ratio) return image.aspect_ratio; - // Fallback: even positions (0, 2) are square, odd positions (1, 3) are landscape - return index % 2 === 0 ? 'square' : 'landscape'; - }; + // Image distribution mapping for first 4 sections (matches WordPress template) + const imageDistribution = [ + { position: 0, type: 'square' as const, align: 'right' as const }, // Section 1 + { position: 3, type: 'landscape' as const, align: 'full' as const }, // Section 2 + { position: 2, type: 'square' as const, align: 'left' as const }, // Section 3 + { position: 1, type: 'landscape' as const, align: 'full' as const }, // Section 4 + ]; - // Check if two consecutive images are both squares (for side-by-side layout) - const getNextSquareImage = (currentIndex: number): ImageRecord | null => { - const nextImage = sectionImages[currentIndex + 1]; - if (nextImage && getImageAspectRatio(nextImage, currentIndex + 1) === 'square') { - return nextImage; - } - return null; + // Reuse pattern for sections 5+ (without descriptions) + const reusePattern = [1, 0, 3, 2]; + + // Get image aspect ratio from record or fallback to position-based calculation + const getImageAspectRatio = (image: ImageRecord | null, position: number): 'square' | 'landscape' => { + if (image?.aspect_ratio) return image.aspect_ratio; + // Fallback: positions 0, 2 are square, positions 1, 3 are landscape + return position === 0 || position === 2 ? 'square' : 'landscape'; }; if (!hasStructuredSections && !introHtml && rawHtml) { @@ -540,42 +533,46 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm ); } - // Get the first in-article image (position 0) - const firstImage = sectionImages.length > 0 ? sectionImages[0] : null; - - // Track which images have been rendered as pairs (to skip the second in the pair) - const renderedPairIndices = new Set(); - return (
{introHtml && } - {sections.map((section, index) => { - // Skip if this image was already rendered as part of a pair - if (renderedPairIndices.has(index)) { - return null; - } + {sections.map((section, sectionIndex) => { + let image: ImageRecord | null = null; + let aspectRatio: 'square' | 'landscape' = 'landscape'; + let imageAlign: 'left' | 'right' | 'full' = 'full'; + let showDescription = true; - const currentImage = sectionImages[index] ?? null; - const currentAspectRatio = getImageAspectRatio(currentImage, index); - - // Check if current is square and next is also square for side-by-side layout - let pairedSquareImage: ImageRecord | null = null; - if (currentAspectRatio === 'square') { - pairedSquareImage = getNextSquareImage(index); - if (pairedSquareImage) { - renderedPairIndices.add(index + 1); // Mark next as rendered + // First 4 sections: use distribution pattern + if (sectionIndex < 4) { + const dist = imageDistribution[sectionIndex]; + const imgPosition = dist.position; + image = sectionImages[imgPosition] ?? null; + aspectRatio = dist.type; + imageAlign = dist.align; + showDescription = true; + } + // Sections 5+: reuse images without descriptions + else { + const reuseIndex = (sectionIndex - 4) % reusePattern.length; + const imgPosition = reusePattern[reuseIndex]; + image = sectionImages[imgPosition] ?? null; + if (image) { + aspectRatio = getImageAspectRatio(image, imgPosition); + imageAlign = (aspectRatio === 'square') ? (reuseIndex % 2 === 0 ? 'right' : 'left') : 'full'; } + showDescription = false; } return ( ); })} @@ -749,7 +746,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten if (loading) { return (
-
+
@@ -766,7 +763,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten if (!content) { return (
-
+

Content Not Found

@@ -834,7 +831,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten return (
-
+
{/* Back Button */} {onBack && (
+ + + + + + @@ -65,6 +72,24 @@ if (!$content_id) { + + + + + + + + + + + + + + + + + + diff --git a/plugins/wordpress/source/igny8-wp-bridge/templates/parts/igny8-table-of-contents.php b/plugins/wordpress/source/igny8-wp-bridge/templates/parts/igny8-table-of-contents.php new file mode 100644 index 00000000..c4113900 --- /dev/null +++ b/plugins/wordpress/source/igny8-wp-bridge/templates/parts/igny8-table-of-contents.php @@ -0,0 +1,69 @@ + + + + + diff --git a/plugins/wordpress/source/igny8-wp-bridge/templates/single-igny8-content.php b/plugins/wordpress/source/igny8-wp-bridge/templates/single-igny8-content.php index 956538c6..9c86df36 100644 --- a/plugins/wordpress/source/igny8-wp-bridge/templates/single-igny8-content.php +++ b/plugins/wordpress/source/igny8-wp-bridge/templates/single-igny8-content.php @@ -80,6 +80,9 @@ if (defined('WP_DEBUG') && WP_DEBUG) { include plugin_dir_path(__FILE__) . 'parts/igny8-featured-image.php'; } + // Table of Contents + include plugin_dir_path(__FILE__) . 'parts/igny8-table-of-contents.php'; + // Content sections include plugin_dir_path(__FILE__) . 'parts/igny8-content-sections.php';
Cluster Name:
Cluster ID:
Meta Title:
Meta Description:
Primary Keyword:
Secondary Keywords: