Image genartiona dn temaplte design redesign

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-10 03:58:02 +00:00
parent ce66dadc00
commit 0c693dc1cc
18 changed files with 1717 additions and 214 deletions

View File

@@ -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,
@@ -885,6 +906,21 @@ class AIModelConfig(models.Model):
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):
"""

View File

@@ -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.',

View File

@@ -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),
]

View File

@@ -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)

View File

@@ -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 `<table>` 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, '<table') !== false);
}
// Get image aspect ratio from position
function igny8_get_image_aspect($position) {
// Even positions (0, 2) = square
// Odd positions (1, 3) = landscape
return ($position % 2 === 0) ? 'square' : 'landscape';
}
// Determine if image should show description
function igny8_show_image_description($section_index) {
// First 4 sections (0-3) show descriptions
return ($section_index < 4);
}
```
---
### 5. Image Alignment with Tables
When section contains a `<table>`:
- 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
<?php if (current_user_can('edit_posts')): ?>
<div class="igny8-metadata-footer igny8-editor-only">
<!-- All internal metadata here -->
</div>
<?php endif; ?>
```
**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
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"/>
</svg>
// Document Icon (Word Count)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"/>
</svg>
// User Icon (Author)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/>
</svg>
// Compass Icon (Topic/Cluster)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"/>
</svg>
// Folder Icon (Categories)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>
// Tag Icon (Tags)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"/>
</svg>
```
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[^>]*>(.*?)<\/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
<nav class="igny8-table-of-contents">
<div class="igny8-toc-header">
<span class="igny8-toc-icon">📑</span>
<h3>Table of Contents</h3>
</div>
<ol class="igny8-toc-list">
<?php foreach ($toc_items as $item): ?>
<li class="igny8-toc-item">
<a href="#<?php echo esc_attr($item['id']); ?>" class="igny8-toc-link">
<span class="igny8-toc-number"><?php echo $item['number']; ?>.</span>
<span class="igny8-toc-text"><?php echo esc_html($item['text']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ol>
</nav>
```
**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;
?>
<div class="<?php echo esc_attr($placeholder_class); ?>"
data-widget-position="<?php echo esc_attr($position); ?>"
data-section-index="<?php echo esc_attr($section_index); ?>">
<!-- Widget content will be inserted here via settings -->
<?php do_action('igny8_widget_placeholder', $position, $section_index); ?>
</div>
<?php
}
```
**Widget Settings (Future Implementation):**
```php
// Plugin settings for widgets
$igny8_widget_settings = [
'enabled' => 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

View File

@@ -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 (
<ContentImageCell
image={image || null}

View File

@@ -94,6 +94,11 @@ export default function SiteSettings() {
const [aiSettingsLoading, setAiSettingsLoading] = useState(false);
const [aiSettingsSaving, setAiSettingsSaving] = useState(false);
// Image size information (from model config)
const [featuredImageSize, setFeaturedImageSize] = useState('1792x1024');
const [landscapeImageSize, setLandscapeImageSize] = useState('1792x1024');
const [squareImageSize, setSquareImageSize] = useState('1024x1024');
// Sectors selection state
const [industries, setIndustries] = useState<Industry[]>([]);
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
@@ -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"
/>
</div>
{/* Image Sizes Display */}
<div className="grid grid-cols-3 gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Featured Image</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{featuredImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Landscape</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{landscapeImageSize}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Square</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{squareImageSize}</p>
</div>
</div>
</div>
)}
</Card>

View File

@@ -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;
}

View File

@@ -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 = ({
<ImageStatusPill status={image?.status} />
</div>
</div>
{image?.caption && (
{showPrompt && image?.caption && (
<figcaption className="space-y-3 px-6 py-5 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
<p className="font-semibold uppercase tracking-[0.25em] text-gray-400 dark:text-gray-500">
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 = ({
</div>
)}
{/* Image section - layout depends on aspect ratio */}
{/* Image section - layout depends on aspect ratio and alignment */}
{hasImage && (
<div className="flex justify-center">
{hasPairedImage ? (
// Two squares side by side (50% each)
<div className="grid w-full grid-cols-2 gap-6">
<div className="w-full">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
<div className="w-full">
<SectionImageBlock image={pairedSquareImage} loading={loading} heading={`${headingLabel} (2)`} />
</div>
</div>
) : (
// Single image with width based on aspect ratio
<div className={getImageContainerClass()}>
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
<SectionImageBlock image={image} loading={loading} heading={headingLabel} showPrompt={showDescription} />
</div>
)}
</div>
)}
@@ -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<number>();
return (
<div className="space-y-12">
{introHtml && <IntroBlock html={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 (
<ContentSectionBlock
key={section.id || `section-${index}`}
key={section.id || `section-${sectionIndex}`}
section={section}
image={currentImage}
image={image}
loading={imagesLoading}
index={index}
aspectRatio={currentAspectRatio}
pairedSquareImage={pairedSquareImage}
index={sectionIndex}
aspectRatio={aspectRatio}
imageAlign={imageAlign}
showDescription={showDescription}
/>
);
})}
@@ -749,7 +746,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1280px] 2xl:max-w-[1530px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
@@ -766,7 +763,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
if (!content) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1280px] 2xl:max-w-[1530px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<XCircleIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
@@ -834,7 +831,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="max-w-[1280px] 2xl:max-w-[1530px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Button */}
{onBack && (
<Button
@@ -1103,7 +1100,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
{/* Featured Image */}
{shouldShowFeaturedBlock && (
<div className="mb-12 max-w-[800px] mx-auto">
<div className="mb-12 max-w-[1024px] mx-auto">
<FeaturedImageBlock image={resolvedFeaturedImage} loading={imagesLoading} />
</div>
)}

View File

@@ -3,7 +3,7 @@
* Plugin Name: IGNY8 WordPress Bridge
* Plugin URI: https://igny8.com/igny8-wp-bridge
* Description: Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.
* Version: 1.2.5
* Version: 1.2.6
* Author: IGNY8
* Author URI: https://igny8.com/
* License: GPL v2 or later
@@ -22,7 +22,7 @@ if (!defined('ABSPATH')) {
}
// Define plugin constants
define('IGNY8_BRIDGE_VERSION', '1.2.5');
define('IGNY8_BRIDGE_VERSION', '1.2.6');
define('IGNY8_BRIDGE_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('IGNY8_BRIDGE_PLUGIN_URL', plugin_dir_url(__FILE__));
define('IGNY8_BRIDGE_PLUGIN_FILE', __FILE__);

View File

@@ -199,3 +199,156 @@ function igny8_parse_keywords($keywords) {
// Remove empty values
return array_filter($keywords_array);
}
/**
* Get section badges based on keyword/tag matching
*
* @param string $heading Section heading text
* @param int $post_id Post ID
* @return array Array of badges with 'text' and 'type' keys
*/
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 && !is_wp_error($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 && !is_wp_error($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 (!empty($kw) && stripos($heading_lower, strtolower($kw)) !== false) {
$badges[] = ['text' => $kw, 'type' => 'keyword'];
if (count($badges) >= 2) break;
}
}
}
return $badges;
}
/**
* Check if section content contains a table
*
* @param string $section_html Section HTML content
* @return bool True if contains table
*/
function igny8_section_has_table($section_html) {
return (stripos($section_html, '<table') !== false);
}
/**
* Get image aspect ratio from position
*
* @param int $position Image position (0-3)
* @return string 'square' or 'landscape'
*/
function igny8_get_image_aspect($position) {
// Even positions (0, 2) = square
// Odd positions (1, 3) = landscape
return ($position % 2 === 0) ? 'square' : 'landscape';
}
/**
* Determine if image description should be shown
*
* @param int $section_index Section index (0-based)
* @return bool True if should show description
*/
function igny8_show_image_description($section_index) {
// First 4 sections (0-3) show descriptions
return ($section_index < 4);
}
/**
* Generate table of contents from content
*
* @param string $content HTML content
* @return array Array of TOC items with 'number', 'text', and 'id' keys
*/
function igny8_generate_table_of_contents($content) {
$toc_items = [];
// Parse content for H2 headings
preg_match_all('/<h2[^>]*>(.*?)<\/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;
}
/**
* Render widget placeholder
*
* @param string $position 'left' or 'right'
* @param int $section_index Section index
* @return void
*/
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;
$placeholder_class .= ' igny8-widget-section-' . $section_index;
?>
<div class="<?php echo esc_attr($placeholder_class); ?>"
data-widget-position="<?php echo esc_attr($position); ?>"
data-section-index="<?php echo esc_attr($section_index); ?>">
<?php
/**
* Action hook for widget placeholder content
*
* @param string $position Widget position (left/right)
* @param int $section_index Section index
*/
do_action('igny8_widget_placeholder', $position, $section_index);
?>
</div>
<?php
}

View File

@@ -11,12 +11,19 @@
/* === CSS Variables === */
:root {
--igny8-max-width: 1200px;
--igny8-max-width: 1280px;
--igny8-spacing: 2rem;
--igny8-border-radius: 24px;
--igny8-border-radius-md: 16px;
--igny8-border-radius-sm: 12px;
--igny8-border-radius-xs: 8px;
--igny8-theme-color: rgba(59, 130, 246, 1);
}
@media (min-width: 1600px) {
:root {
--igny8-max-width: 1530px;
}
}
/* === Main Wrapper === */
@@ -255,8 +262,10 @@
.igny8-featured-image {
width: 100%;
max-width: 1024px;
height: auto;
display: block;
margin: 0 auto;
}
.igny8-image-prompt {
@@ -498,6 +507,59 @@
display: block;
}
/* Landscape images */
.igny8-image-landscape {
max-width: 1024px;
width: 100%;
margin: 0 auto 2rem;
display: block;
}
.igny8-image-landscape.igny8-image-reuse .igny8-image-caption {
display: none;
}
/* Square image - Right aligned */
.igny8-image-square-right {
max-width: 50%;
float: right;
margin-left: 2rem;
margin-bottom: 2rem;
}
/* Square image - Left aligned */
.igny8-image-square-left {
max-width: 50%;
float: left;
margin-right: 2rem;
margin-bottom: 2rem;
}
/* Table-specific image positioning */
.igny8-image-before-table {
max-width: 1024px;
width: 100%;
margin: 0 auto 2rem;
display: block;
clear: both;
}
/* Widget placeholder */
.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: var(--igny8-border-radius-sm);
display: none;
}
.igny8-widget-placeholder.igny8-widgets-enabled {
display: block;
}
.igny8-image-caption {
padding: 1.25rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
@@ -604,6 +666,120 @@
line-height: 1.4;
}
/* === Table of Contents === */
.igny8-table-of-contents {
background: var(--wp--preset--color--base, #ffffff);
border: 2px solid rgba(0, 0, 0, 0.12);
border-radius: var(--igny8-border-radius-md);
padding: 1.5rem 2rem;
margin-bottom: var(--igny8-spacing);
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-icon {
width: 1.25rem;
height: 1.25rem;
color: var(--igny8-theme-color);
opacity: 0.8;
}
.igny8-toc-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: inherit;
}
.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: var(--igny8-border-radius-xs);
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;
}
/* === Section Badges === */
.igny8-section-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.igny8-section-badge {
display: inline-block;
padding: 0.375rem 0.875rem;
border-radius: var(--igny8-border-radius-xs);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.igny8-section-badge-primary {
background: rgba(59, 130, 246, 0.15);
color: rgba(59, 130, 246, 1);
}
.igny8-section-badge-secondary {
background: rgba(59, 130, 246, 0.08);
color: rgba(59, 130, 246, 0.8);
}
/* === SVG Icon Styles === */
.igny8-icon {
width: 1rem;
height: 1rem;
color: var(--igny8-theme-color, currentColor);
opacity: 0.8;
display: inline-block;
vertical-align: middle;
}
/* === Responsive Styles === */
@media (max-width: 768px) {
:root {

View File

@@ -1,7 +1,14 @@
<?php
/**
* IGNY8 Content Sections
* Parses content HTML and displays sections with in-article images
* Parses content HTML and displays sections with smart image distribution
*
* Pattern for 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
*
* @package Igny8Bridge
*/
@@ -10,6 +17,17 @@
if (!defined('ABSPATH')) {
exit;
}
// Image distribution mapping
$image_distribution = [
0 => ['position' => 0, 'type' => 'square', 'align' => 'right'], // Section 1
1 => ['position' => 3, 'type' => 'landscape', 'align' => 'full'], // Section 2
2 => ['position' => 2, 'type' => 'square', 'align' => 'left'], // Section 3
3 => ['position' => 1, 'type' => 'landscape', 'align' => 'full'], // Section 4
];
// Reuse pattern for sections 5+
$reuse_pattern = [1, 0, 3, 2]; // Featured, Square1, Landscape2, Square2
?>
<div class="igny8-content-body">
@@ -33,23 +51,40 @@ if (!defined('ABSPATH')) {
<div class="igny8-section-header">
<span class="igny8-section-number"><?php echo $index + 1; ?></span>
<div class="igny8-section-heading-wrapper">
<span class="igny8-section-label">Section Spotlight</span>
<?php
// Get section badges based on keyword/tag matching
$badges = igny8_get_section_badges($section['heading'], get_the_ID());
if (!empty($badges)): ?>
<div class="igny8-section-badges">
<?php foreach ($badges as $badge_index => $badge): ?>
<span class="igny8-section-badge <?php echo $badge_index === 0 ? 'igny8-section-badge-primary' : 'igny8-section-badge-secondary'; ?>">
<?php echo esc_html($badge['text']); ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<h2 class="igny8-section-heading"><?php echo esc_html($section['heading']); ?></h2>
</div>
</div>
<?php
// Get image for this section (position = section index + 1)
$section_position = $index + 1;
// Try multiple sources for in-article images
// Determine which image to use
$img_data = null;
$img_url = null;
$img_prompt = '';
$img_align = 'full';
$img_type = 'landscape';
$show_description = igny8_show_image_description($index);
// Source 1: From $in_article_images array
if (isset($in_article_images[$section_position])) {
$img_data = $in_article_images[$section_position];
// First 4 sections: use distribution pattern
if ($index < 4 && isset($image_distribution[$index])) {
$dist = $image_distribution[$index];
$img_position = $dist['position'];
$img_type = $dist['type'];
$img_align = $dist['align'];
if (isset($in_article_images[$img_position])) {
$img_data = $in_article_images[$img_position];
if (isset($img_data['attachment_id'])) {
$img_url = wp_get_attachment_image_url($img_data['attachment_id'], 'large');
$img_prompt = isset($img_data['prompt']) ? $img_data['prompt'] : '';
@@ -58,47 +93,126 @@ if (!defined('ABSPATH')) {
$img_prompt = isset($img_data['prompt']) ? $img_data['prompt'] : '';
}
}
// Source 2: Check gallery images meta
if (!$img_url) {
$gallery = get_post_meta(get_the_ID(), '_igny8_gallery_images', true);
if ($gallery && is_array($gallery) && isset($gallery[$index])) {
$img_url = wp_get_attachment_image_url($gallery[$index], 'large');
}
}
// Sections 5+: reuse images without descriptions
elseif ($index >= 4) {
$reuse_index = ($index - 4) % count($reuse_pattern);
$img_position = $reuse_pattern[$reuse_index];
$has_image = !empty($img_url);
?>
<div class="igny8-section-content<?php echo $has_image ? ' igny8-has-image' : ''; ?>">
<div class="igny8-section-text">
<div class="igny8-prose">
<?php echo $section['content']; ?>
</div>
</div>
<?php if ($has_image):
$img_alt = '';
// Position 1 is featured image, others are in-article
if ($img_position === 1 && $featured_image_id) {
$img_url = wp_get_attachment_image_url($featured_image_id, 'large');
$img_type = 'landscape';
$img_align = 'full';
} elseif (isset($in_article_images[$img_position])) {
$img_data = $in_article_images[$img_position];
if (isset($img_data['attachment_id'])) {
$img_alt = get_post_meta($img_data['attachment_id'], '_wp_attachment_image_alt', true);
$img_url = wp_get_attachment_image_url($img_data['attachment_id'], 'large');
} elseif (isset($img_data['url'])) {
$img_url = $img_data['url'];
}
$img_type = igny8_get_image_aspect($img_position);
$img_align = ($img_type === 'square') ? (($reuse_index % 2 === 0) ? 'right' : 'left') : 'full';
}
}
// Check if section has table
$has_table = igny8_section_has_table($section['content']);
if ($has_table && $img_url) {
// Place full-width image before table
$img_type = 'landscape';
$img_align = 'full';
$img_class = 'igny8-image-landscape igny8-image-before-table';
if (!$show_description) {
$img_class .= ' igny8-image-reuse';
}
?>
<div class="igny8-section-image">
<div class="igny8-section-content">
<figure class="igny8-image-figure">
<img src="<?php echo esc_url($img_url); ?>"
alt="<?php echo esc_attr($img_alt ?: $section['heading']); ?>"
class="igny8-in-article-image"
alt="<?php echo esc_attr($section['heading']); ?>"
class="<?php echo esc_attr($img_class); ?>"
loading="lazy">
<?php if ($img_prompt): ?>
<?php if ($show_description && $img_prompt): ?>
<figcaption class="igny8-image-caption">
<p class="igny8-caption-label">Visual Direction</p>
<p class="igny8-caption-text"><?php echo esc_html($img_prompt); ?></p>
</figcaption>
<?php endif; ?>
</figure>
<div class="igny8-prose">
<?php echo $section['content']; ?>
</div>
</div>
<?php
}
// Square image (left or right aligned)
elseif ($img_url && $img_type === 'square') {
$img_class = 'igny8-image-square-' . $img_align;
if (!$show_description) {
$img_class .= ' igny8-image-reuse';
}
?>
<div class="igny8-section-content">
<figure class="igny8-image-figure <?php echo esc_attr($img_class); ?>">
<img src="<?php echo esc_url($img_url); ?>"
alt="<?php echo esc_attr($section['heading']); ?>"
class="igny8-in-article-image"
loading="lazy">
<?php if ($show_description && $img_prompt): ?>
<figcaption class="igny8-image-caption">
<p class="igny8-caption-label">Visual Direction</p>
<p class="igny8-caption-text"><?php echo esc_html($img_prompt); ?></p>
</figcaption>
<?php endif; ?>
</figure>
<div class="igny8-prose">
<?php echo $section['content']; ?>
</div>
<?php
// Widget placeholder below square images
igny8_render_widget_placeholder($img_align, $index);
?>
</div>
<?php
}
// Landscape image (full width)
elseif ($img_url && $img_type === 'landscape') {
$img_class = 'igny8-image-landscape';
if (!$show_description) {
$img_class .= ' igny8-image-reuse';
}
?>
<div class="igny8-section-content">
<figure class="igny8-image-figure">
<img src="<?php echo esc_url($img_url); ?>"
alt="<?php echo esc_attr($section['heading']); ?>"
class="<?php echo esc_attr($img_class); ?>"
loading="lazy">
<?php if ($show_description && $img_prompt): ?>
<figcaption class="igny8-image-caption">
<p class="igny8-caption-label">Visual Direction</p>
<p class="igny8-caption-text"><?php echo esc_html($img_prompt); ?></p>
</figcaption>
<?php endif; ?>
</figure>
<div class="igny8-prose">
<?php echo $section['content']; ?>
</div>
</div>
<?php
}
// No image
else {
?>
<div class="igny8-section-content">
<div class="igny8-prose">
<?php echo $section['content']; ?>
</div>
</div>
<?php
}
?>
</div>
</section>

View File

@@ -27,7 +27,8 @@ if (!$image_url) {
<div class="igny8-featured-image-wrapper">
<img src="<?php echo esc_url($image_url); ?>"
alt="<?php echo esc_attr($image_alt ?: get_the_title()); ?>"
class="igny8-featured-image"
class="igny8-featured-image igny8-image-landscape"
style="max-width: 1024px;"
loading="lazy">
</div>

View File

@@ -36,7 +36,9 @@ $status_class = igny8_get_status_class($status);
<div class="igny8-metadata-row">
<!-- Created Date -->
<div class="igny8-meta-item">
<span class="igny8-meta-icon">📅</span>
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"/>
</svg>
<span class="igny8-meta-label">Posted:</span>
<span class="igny8-meta-value"><?php echo get_the_date(); ?></span>
</div>
@@ -44,7 +46,9 @@ $status_class = igny8_get_status_class($status);
<!-- Word Count -->
<?php if ($word_count > 0): ?>
<div class="igny8-meta-item">
<span class="igny8-meta-icon">📝</span>
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"/>
</svg>
<span class="igny8-meta-label">Words:</span>
<span class="igny8-meta-value"><?php echo number_format($word_count); ?></span>
</div>
@@ -52,15 +56,30 @@ $status_class = igny8_get_status_class($status);
<!-- Author -->
<div class="igny8-meta-item">
<span class="igny8-meta-icon">✍️</span>
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/>
</svg>
<span class="igny8-meta-label">Author:</span>
<span class="igny8-meta-value"><?php the_author(); ?></span>
</div>
<!-- Topic (Cluster Name) -->
<?php if ($cluster_name): ?>
<div class="igny8-meta-item">
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"/>
</svg>
<span class="igny8-meta-label">Topic:</span>
<span class="igny8-meta-value"><?php echo esc_html($cluster_name); ?></span>
</div>
<?php endif; ?>
<!-- Categories -->
<?php if ($categories && !is_wp_error($categories)): ?>
<div class="igny8-meta-item">
<span class="igny8-meta-icon">📁</span>
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>
<span class="igny8-meta-label">Categories:</span>
<div class="igny8-meta-badges">
<?php foreach ($categories as $cat): ?>
@@ -73,7 +92,9 @@ $status_class = igny8_get_status_class($status);
<!-- Tags -->
<?php if ($tags && !is_wp_error($tags)): ?>
<div class="igny8-meta-item">
<span class="igny8-meta-icon">🏷️</span>
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"/>
</svg>
<span class="igny8-meta-label">Tags:</span>
<div class="igny8-meta-badges">
<?php foreach ($tags as $tag): ?>
@@ -83,69 +104,4 @@ $status_class = igny8_get_status_class($status);
</div>
<?php endif; ?>
</div>
<!-- SEO Metadata Section -->
<?php if (($meta_title && $meta_title !== get_the_title()) || $meta_description): ?>
<div class="igny8-seo-section">
<div class="igny8-seo-header">SEO Metadata</div>
<?php if ($meta_title && $meta_title !== get_the_title()): ?>
<div class="igny8-seo-item">
<label class="igny8-seo-label">SEO Title:</label>
<div class="igny8-seo-value"><?php echo esc_html($meta_title); ?></div>
</div>
<?php endif; ?>
<?php if ($meta_description): ?>
<div class="igny8-seo-item">
<label class="igny8-seo-label">Meta Description:</label>
<div class="igny8-seo-value"><?php echo esc_html($meta_description); ?></div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- IGNY8 Content Info -->
<?php if ($cluster_name || $primary_keyword || $content_type): ?>
<div class="igny8-info-section">
<div class="igny8-info-header">Content Information</div>
<div class="igny8-info-grid">
<?php if ($content_type): ?>
<div class="igny8-info-item">
<label>Type:</label>
<span><?php echo esc_html(ucfirst($content_type)); ?></span>
</div>
<?php endif; ?>
<?php if ($structure): ?>
<div class="igny8-info-item">
<label>Structure:</label>
<span><?php echo esc_html(ucfirst($structure)); ?></span>
</div>
<?php endif; ?>
<?php if ($cluster_name): ?>
<div class="igny8-info-item">
<label>Cluster:</label>
<span><?php echo esc_html($cluster_name); ?></span>
</div>
<?php endif; ?>
<?php if ($primary_keyword): ?>
<div class="igny8-info-item">
<label>Primary Keyword:</label>
<span><?php echo esc_html($primary_keyword); ?></span>
</div>
<?php endif; ?>
<?php if ($source): ?>
<div class="igny8-info-item">
<label>Source:</label>
<span><?php echo esc_html(ucfirst($source)); ?></span>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -2,6 +2,7 @@
/**
* IGNY8 Metadata Footer
* Shows IGNY8-specific metadata in collapsible format
* Visible only to users with edit_posts capability (Editor, Administrator, Author for own posts)
*
* @package Igny8Bridge
*/
@@ -11,8 +12,8 @@ if (!defined('ABSPATH')) {
exit;
}
// Only show if we have IGNY8 content ID
if (!$content_id) {
// Only show if we have IGNY8 content ID and user has edit permissions
if (!$content_id || !current_user_can('edit_posts')) {
return;
}
?>
@@ -53,6 +54,12 @@ if (!$content_id) {
<td><?php echo esc_html(ucfirst($source)); ?></td>
</tr>
<?php endif; ?>
<?php if ($cluster_name): ?>
<tr>
<th>Cluster Name:</th>
<td><?php echo esc_html($cluster_name); ?></td>
</tr>
<?php endif; ?>
<?php if ($cluster_id): ?>
<tr>
<th>Cluster ID:</th>
@@ -65,6 +72,24 @@ if (!$content_id) {
<td><?php echo esc_html($sector_id); ?></td>
</tr>
<?php endif; ?>
<?php if ($meta_title): ?>
<tr>
<th>Meta Title:</th>
<td><?php echo esc_html($meta_title); ?></td>
</tr>
<?php endif; ?>
<?php if ($meta_description): ?>
<tr>
<th>Meta Description:</th>
<td><?php echo esc_html($meta_description); ?></td>
</tr>
<?php endif; ?>
<?php if ($primary_keyword): ?>
<tr>
<th>Primary Keyword:</th>
<td><?php echo esc_html($primary_keyword); ?></td>
</tr>
<?php endif; ?>
<?php if ($keywords_array): ?>
<tr>
<th>Secondary Keywords:</th>

View File

@@ -0,0 +1,69 @@
<?php
/**
* IGNY8 Table of Contents
* Displays clickable list of H2 headings with smooth scroll
*
* @package Igny8Bridge
*/
// Exit if accessed directly
if (!defined('ABSPATH')) {
exit;
}
// Generate TOC from content
$toc_items = igny8_generate_table_of_contents($content);
// Only show if we have at least 3 headings (configurable via settings)
$min_headings = get_option('igny8_toc_min_headings', 3);
if (count($toc_items) < $min_headings) {
return;
}
?>
<nav class="igny8-table-of-contents">
<div class="igny8-toc-header">
<svg class="igny8-toc-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/>
</svg>
<h3>Table of Contents</h3>
</div>
<ol class="igny8-toc-list">
<?php foreach ($toc_items as $item): ?>
<li class="igny8-toc-item">
<a href="#<?php echo esc_attr($item['id']); ?>" class="igny8-toc-link">
<span class="igny8-toc-number"><?php echo $item['number']; ?></span>
<span class="igny8-toc-text"><?php echo esc_html($item['text']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ol>
</nav>
<script>
// Smooth scroll for TOC links
document.addEventListener('DOMContentLoaded', function() {
const tocLinks = document.querySelectorAll('.igny8-toc-link');
tocLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// Update URL without jumping
if (history.pushState) {
history.pushState(null, null, '#' + targetId);
}
}
});
});
});
</script>

View File

@@ -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';