fixes but still nto fixed

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-15 04:13:54 +00:00
parent e02ba76451
commit 75785aa642
12 changed files with 1037 additions and 80 deletions

View File

@@ -757,20 +757,109 @@ class KeywordViewSet(SiteSectorModelViewSet):
def filter_options(self, request): def filter_options(self, request):
""" """
Get distinct filter values from current data. Get distinct filter values from current data.
Returns only countries and statuses that exist in the current site's keywords. Returns only values that exist in the filtered queryset.
Supports cascading filters - pass current filter values to get remaining options.
""" """
import logging import logging
from django.db.models import Q
from django.db.models.functions import Coalesce
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
queryset = self.get_queryset() queryset = self.get_queryset()
# Get distinct countries from seed_keyword (use set for proper deduplication) # Apply current filters to get cascading options
countries = list(set(queryset.values_list('seed_keyword__country', flat=True))) # Each filter's options are based on data that matches OTHER filters
countries = sorted([c for c in countries if c]) # Sort and filter nulls status_filter = request.query_params.get('status')
country_filter = request.query_params.get('country')
cluster_filter = request.query_params.get('cluster_id')
difficulty_min = request.query_params.get('difficulty_min')
difficulty_max = request.query_params.get('difficulty_max')
# Base queryset for each filter option calculation
# For countries: apply status, cluster, difficulty filters
countries_qs = queryset
if status_filter:
countries_qs = countries_qs.filter(status=status_filter)
if cluster_filter:
countries_qs = countries_qs.filter(cluster_id=cluster_filter)
if difficulty_min is not None:
try:
countries_qs = countries_qs.filter(
Q(difficulty_override__gte=int(difficulty_min)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
)
except (ValueError, TypeError):
pass
if difficulty_max is not None:
try:
countries_qs = countries_qs.filter(
Q(difficulty_override__lte=int(difficulty_max)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
)
except (ValueError, TypeError):
pass
# For statuses: apply country, cluster, difficulty filters
statuses_qs = queryset
if country_filter:
statuses_qs = statuses_qs.filter(seed_keyword__country=country_filter)
if cluster_filter:
statuses_qs = statuses_qs.filter(cluster_id=cluster_filter)
if difficulty_min is not None:
try:
statuses_qs = statuses_qs.filter(
Q(difficulty_override__gte=int(difficulty_min)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
)
except (ValueError, TypeError):
pass
if difficulty_max is not None:
try:
statuses_qs = statuses_qs.filter(
Q(difficulty_override__lte=int(difficulty_max)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
)
except (ValueError, TypeError):
pass
# For clusters: apply status, country, difficulty filters
clusters_qs = queryset
if status_filter:
clusters_qs = clusters_qs.filter(status=status_filter)
if country_filter:
clusters_qs = clusters_qs.filter(seed_keyword__country=country_filter)
if difficulty_min is not None:
try:
clusters_qs = clusters_qs.filter(
Q(difficulty_override__gte=int(difficulty_min)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min))
)
except (ValueError, TypeError):
pass
if difficulty_max is not None:
try:
clusters_qs = clusters_qs.filter(
Q(difficulty_override__lte=int(difficulty_max)) |
Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max))
)
except (ValueError, TypeError):
pass
# For difficulties: apply status, country, cluster filters
difficulties_qs = queryset
if status_filter:
difficulties_qs = difficulties_qs.filter(status=status_filter)
if country_filter:
difficulties_qs = difficulties_qs.filter(seed_keyword__country=country_filter)
if cluster_filter:
difficulties_qs = difficulties_qs.filter(cluster_id=cluster_filter)
# Get distinct countries
countries = list(set(countries_qs.values_list('seed_keyword__country', flat=True)))
countries = sorted([c for c in countries if c])
# Map country codes to display names
from igny8_core.auth.models import SeedKeyword from igny8_core.auth.models import SeedKeyword
country_choices = dict(SeedKeyword.COUNTRY_CHOICES) country_choices = dict(SeedKeyword.COUNTRY_CHOICES)
country_options = [ country_options = [
@@ -778,9 +867,9 @@ class KeywordViewSet(SiteSectorModelViewSet):
for c in countries for c in countries
] ]
# Get distinct statuses (use set for proper deduplication) # Get distinct statuses from filtered queryset
statuses = list(set(queryset.values_list('status', flat=True))) statuses = list(set(statuses_qs.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s]) # Sort and filter nulls statuses = sorted([s for s in statuses if s])
status_labels = { status_labels = {
'new': 'New', 'new': 'New',
'mapped': 'Mapped', 'mapped': 'Mapped',
@@ -790,9 +879,9 @@ class KeywordViewSet(SiteSectorModelViewSet):
for s in statuses for s in statuses
] ]
# Get distinct clusters (use set for proper deduplication) # Get distinct clusters from filtered queryset
cluster_ids = list(set( cluster_ids = list(set(
queryset.exclude(cluster_id__isnull=True) clusters_qs.exclude(cluster_id__isnull=True)
.values_list('cluster_id', flat=True) .values_list('cluster_id', flat=True)
)) ))
@@ -802,11 +891,48 @@ class KeywordViewSet(SiteSectorModelViewSet):
for c in clusters for c in clusters
] ]
# Get distinct difficulty levels from filtered queryset (mapped to 1-5 scale)
# Difficulty is stored as 0-100 in seed_keyword, mapped to 1-5 scale
from django.db.models import Case, When, Value, IntegerField
# Get effective difficulty (override or seed_keyword)
difficulty_values = difficulties_qs.annotate(
effective_difficulty=Coalesce('difficulty_override', 'seed_keyword__difficulty')
).exclude(effective_difficulty__isnull=True).values_list('effective_difficulty', flat=True)
# Map raw difficulty (0-100) to 1-5 scale and find unique values
difficulty_levels = set()
for d in difficulty_values:
if d is not None:
if d <= 10:
difficulty_levels.add(1)
elif d <= 30:
difficulty_levels.add(2)
elif d <= 50:
difficulty_levels.add(3)
elif d <= 70:
difficulty_levels.add(4)
else:
difficulty_levels.add(5)
difficulty_labels = {
1: '1 - Very Easy',
2: '2 - Easy',
3: '3 - Medium',
4: '4 - Hard',
5: '5 - Very Hard',
}
difficulty_options = [
{'value': str(d), 'label': difficulty_labels[d]}
for d in sorted(difficulty_levels)
]
return success_response( return success_response(
data={ data={
'countries': country_options, 'countries': country_options,
'statuses': status_options, 'statuses': status_options,
'clusters': cluster_options, 'clusters': cluster_options,
'difficulties': difficulty_options,
}, },
request=request request=request
) )
@@ -1130,6 +1256,45 @@ class ClusterViewSet(SiteSectorModelViewSet):
return success_response(data={'deleted_count': deleted_count}, request=request) return success_response(data={'deleted_count': deleted_count}, request=request)
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data.
Returns only statuses that exist in the current site's clusters.
"""
import logging
logger = logging.getLogger(__name__)
try:
queryset = self.get_queryset()
# Get distinct statuses
statuses = list(set(queryset.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'new': 'New',
'mapped': 'Mapped',
}
status_options = [
{'value': s, 'label': status_labels.get(s, s.title())}
for s in statuses
]
return success_response(
data={
'statuses': status_options,
},
request=request
)
except Exception as e:
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
return error_response(
error=f'Failed to fetch filter options: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas') @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
def auto_generate_ideas(self, request): def auto_generate_ideas(self, request):
"""Auto-generate ideas for clusters using IdeasService""" """Auto-generate ideas for clusters using IdeasService"""
@@ -1308,6 +1473,90 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
serializer.save(account=account, site=site, sector=sector) serializer.save(account=account, site=site, sector=sector)
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data.
Returns only values that exist in the current site's content ideas.
"""
import logging
logger = logging.getLogger(__name__)
try:
queryset = self.get_queryset()
# Get distinct statuses
statuses = list(set(queryset.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'new': 'New',
'queued': 'Queued',
'completed': 'Completed',
}
status_options = [
{'value': s, 'label': status_labels.get(s, s.title())}
for s in statuses
]
# Get distinct content_types
content_types = list(set(queryset.values_list('content_type', flat=True)))
content_types = sorted([t for t in content_types if t])
type_labels = {
'post': 'Post',
'page': 'Page',
'product': 'Product',
'taxonomy': 'Taxonomy',
}
content_type_options = [
{'value': t, 'label': type_labels.get(t, t.title())}
for t in content_types
]
# Get distinct content_structures
structures = list(set(queryset.values_list('content_structure', flat=True)))
structures = sorted([s for s in structures if s])
structure_labels = {
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
'review': 'Review', 'listicle': 'Listicle', 'landing_page': 'Landing Page',
'business_page': 'Business Page', 'service_page': 'Service Page',
'general': 'General', 'cluster_hub': 'Cluster Hub',
'product_page': 'Product Page', 'category_archive': 'Category Archive',
'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive',
}
content_structure_options = [
{'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())}
for s in structures
]
# Get distinct clusters with ideas
cluster_ids = list(set(
queryset.exclude(keyword_cluster_id__isnull=True)
.values_list('keyword_cluster_id', flat=True)
))
clusters = Clusters.objects.filter(id__in=cluster_ids).values('id', 'name').order_by('name')
cluster_options = [
{'value': str(c['id']), 'label': c['name']}
for c in clusters
]
return success_response(
data={
'statuses': status_options,
'content_types': content_type_options,
'content_structures': content_structure_options,
'clusters': cluster_options,
},
request=request
)
except Exception as e:
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
return error_response(
error=f'Failed to fetch filter options: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
def bulk_delete(self, request): def bulk_delete(self, request):
"""Bulk delete content ideas""" """Bulk delete content ideas"""

View File

@@ -824,6 +824,108 @@ class ContentViewSet(SiteSectorModelViewSet):
else: else:
serializer.save() serializer.save()
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
Get distinct filter values from current data.
Returns only values that exist in the current site's content.
"""
import logging
logger = logging.getLogger(__name__)
try:
queryset = self.get_queryset()
# Get distinct statuses
statuses = list(set(queryset.values_list('status', flat=True)))
statuses = sorted([s for s in statuses if s])
status_labels = {
'draft': 'Draft',
'review': 'Review',
'approved': 'Approved',
'published': 'Published',
}
status_options = [
{'value': s, 'label': status_labels.get(s, s.title())}
for s in statuses
]
# Get distinct site_status
site_statuses = list(set(queryset.values_list('site_status', flat=True)))
site_statuses = sorted([s for s in site_statuses if s])
site_status_labels = {
'not_published': 'Not Published',
'scheduled': 'Scheduled',
'publishing': 'Publishing',
'published': 'Published',
'failed': 'Failed',
}
site_status_options = [
{'value': s, 'label': site_status_labels.get(s, s.replace('_', ' ').title())}
for s in site_statuses
]
# Get distinct content_types
content_types = list(set(queryset.values_list('content_type', flat=True)))
content_types = sorted([t for t in content_types if t])
type_labels = {
'post': 'Post',
'page': 'Page',
'product': 'Product',
'taxonomy': 'Taxonomy',
}
content_type_options = [
{'value': t, 'label': type_labels.get(t, t.title())}
for t in content_types
]
# Get distinct content_structures
structures = list(set(queryset.values_list('content_structure', flat=True)))
structures = sorted([s for s in structures if s])
structure_labels = {
'article': 'Article', 'guide': 'Guide', 'comparison': 'Comparison',
'review': 'Review', 'listicle': 'Listicle', 'landing_page': 'Landing Page',
'business_page': 'Business Page', 'service_page': 'Service Page',
'general': 'General', 'cluster_hub': 'Cluster Hub',
'product_page': 'Product Page', 'category_archive': 'Category Archive',
'tag_archive': 'Tag Archive', 'attribute_archive': 'Attribute Archive',
}
content_structure_options = [
{'value': s, 'label': structure_labels.get(s, s.replace('_', ' ').title())}
for s in structures
]
# Get distinct sources
sources = list(set(queryset.values_list('source', flat=True)))
sources = sorted([s for s in sources if s])
source_labels = {
'igny8': 'IGNY8',
'wordpress': 'WordPress',
}
source_options = [
{'value': s, 'label': source_labels.get(s, s.title())}
for s in sources
]
return success_response(
data={
'statuses': status_options,
'site_statuses': site_status_options,
'content_types': content_type_options,
'content_structures': content_structure_options,
'sources': source_options,
},
request=request
)
except Exception as e:
logger.error(f"Error in filter_options: {str(e)}", exc_info=True)
return error_response(
error=f'Failed to fetch filter options: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
def bulk_delete(self, request): def bulk_delete(self, request):
"""Bulk delete content""" """Bulk delete content"""

View File

@@ -0,0 +1,351 @@
# IGNY8 Filters Implementation
**Last Updated:** January 15, 2026
**Version:** 1.0.0
> This document describes the current filter implementation across all pages that use the `TablePageTemplate` component.
---
## Architecture Overview
### Component Hierarchy
```
Page Component (e.g., Keywords.tsx)
└── createPageConfig() function
├── Returns: columns, filters, formFields, headerMetrics
└── Uses: handlers (state setters, filter values)
└── TablePageTemplate.tsx
├── Renders filters based on FilterConfig[]
├── Manages filter visibility toggle
└── Passes filterValues and onFilterChange to children
```
### Filter Flow
1. **Page Component** defines filter state (`useState`)
2. **Config Function** creates `FilterConfig[]` with options
3. **TablePageTemplate** renders filter UI components
4. **Filter Change**`onFilterChange` callback → update state → re-fetch data
---
## Filter Types
| Type | Component | Description |
|------|-----------|-------------|
| `text` | `<Input>` | Text search input |
| `select` | `<SelectDropdown>` | Single-select dropdown |
| `custom` | `customRender()` | Custom component (e.g., volume range) |
| `daterange` | (planned) | Date range picker |
| `range` | (planned) | Numeric range |
---
## Backend FilterSet Classes
### Planner Module (`/backend/igny8_core/modules/planner/views.py`)
#### KeywordsFilter
```python
class KeywordsFilter(django_filters.FilterSet):
class Meta:
model = Keywords
fields = ['status', 'cluster_id', 'seed_keyword__country', 'seed_keyword_id', 'created_at__gte', 'created_at__lte']
```
#### ClustersFilter
```python
class ClustersFilter(django_filters.FilterSet):
class Meta:
model = Clusters
fields = ['status', 'created_at__gte', 'created_at__lte']
```
#### ContentIdeasFilter
```python
class ContentIdeasFilter(django_filters.FilterSet):
class Meta:
model = ContentIdeas
fields = ['status', 'keyword_cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
```
### Writer Module (`/backend/igny8_core/modules/writer/views.py`)
#### TasksFilter
```python
class TasksFilter(django_filters.FilterSet):
class Meta:
model = Tasks
fields = ['status', 'cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
```
#### ImagesFilter
```python
class ImagesFilter(django_filters.FilterSet):
class Meta:
model = Images
fields = ['task_id', 'content_id', 'image_type', 'status', 'created_at__gte', 'created_at__lte']
```
#### ContentFilter
```python
class ContentFilter(django_filters.FilterSet):
class Meta:
model = Content
fields = ['cluster_id', 'status', 'content_type', 'content_structure', 'source', 'created_at__gte', 'created_at__lte']
```
---
## Page-by-Page Filter Implementation
### 1. Keywords Page (`/planner/keywords`)
**Config File:** `frontend/src/config/pages/keywords.config.tsx`
**Page File:** `frontend/src/pages/Planner/Keywords.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `new`, `mapped` | `status` |
| `country` | select | `US`, `CA`, `GB`, `AE`, `AU`, `IN`, `PK` | `seed_keyword__country` |
| `difficulty` | select | `1-5` (mapped labels) | Custom in `get_queryset()` |
| `cluster` | select | Dynamic (cluster list) | `cluster_id` |
| `volume` | custom | Min/Max inputs | Custom `volume_min`, `volume_max` |
**Special Handling:**
- Difficulty filter uses 1-5 scale that maps to raw score ranges (0-10, 11-30, 31-50, 51-70, 71-100)
- Volume range uses custom dropdown with min/max inputs
---
### 2. Clusters Page (`/planner/clusters`)
**Config File:** `frontend/src/config/pages/clusters.config.tsx`
**Page File:** `frontend/src/pages/Planner/Clusters.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `new`, `mapped` | `status` |
| `difficulty` | select | `1-5` (mapped labels) | Custom filtering |
| `volume` | custom | Min/Max inputs | Custom `volume_min`, `volume_max` |
---
### 3. Ideas Page (`/planner/ideas`)
**Config File:** `frontend/src/config/pages/ideas.config.tsx`
**Page File:** `frontend/src/pages/Planner/Ideas.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `new`, `queued`, `completed` | `status` |
| `content_structure` | select | 14 structure types | `content_structure` |
| `content_type` | select | `post`, `page`, `product`, `taxonomy` | `content_type` |
| `keyword_cluster_id` | select | Dynamic (cluster list) | `keyword_cluster_id` |
**Structure Options:**
- Post: `article`, `guide`, `comparison`, `review`, `listicle`
- Page: `landing_page`, `business_page`, `service_page`, `general`, `cluster_hub`
- Product: `product_page`
- Taxonomy: `category_archive`, `tag_archive`, `attribute_archive`
---
### 4. Content Page (`/writer/content`)
**Config File:** `frontend/src/config/pages/content.config.tsx`
**Page File:** `frontend/src/pages/Writer/Content.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `draft`, `published` | `status` |
| `content_type` | select | `post`, `page`, `product`, `taxonomy` | `content_type` |
| `content_structure` | select | 14 structure types | `content_structure` |
| `source` | select | `igny8`, `wordpress` | `source` |
---
### 5. Review Page (`/writer/review`)
**Config File:** `frontend/src/config/pages/review.config.tsx`
**Page File:** `frontend/src/pages/Writer/Review.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `draft`, `review`, `approved`, `published` | `status` |
| `site_status` | select | `not_published`, `scheduled`, `publishing`, `published`, `failed` | `site_status` |
| `content_type` | select | From `CONTENT_TYPE_OPTIONS` | `content_type` |
| `content_structure` | select | From `ALL_CONTENT_STRUCTURES` | `content_structure` |
---
### 6. Approved Page (`/writer/approved`)
**Config File:** `frontend/src/config/pages/approved.config.tsx`
**Page File:** `frontend/src/pages/Writer/Approved.tsx`
| Filter Key | Type | Options | Backend Field |
|------------|------|---------|---------------|
| `search` | text | - | `search` (SearchFilter) |
| `status` | select | `draft`, `review`, `approved`, `published` | `status` |
| `site_status` | select | `not_published`, `scheduled`, `publishing`, `published`, `failed` | `site_status` |
| `content_type` | select | From `CONTENT_TYPE_OPTIONS` | `content_type` |
| `content_structure` | select | From `ALL_CONTENT_STRUCTURES` | `content_structure` |
---
## Shared Constants
**File:** `frontend/src/config/structureMapping.ts`
```typescript
export const CONTENT_TYPE_OPTIONS = [
{ value: 'post', label: 'Post' },
{ value: 'page', label: 'Page' },
{ value: 'product', label: 'Product' },
{ value: 'taxonomy', label: 'Taxonomy' },
];
export const ALL_CONTENT_STRUCTURES = [
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'comparison', label: 'Comparison' },
{ value: 'review', label: 'Review' },
{ value: 'listicle', label: 'Listicle' },
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'business_page', label: 'Business Page' },
{ value: 'service_page', label: 'Service Page' },
{ value: 'general', label: 'General' },
{ value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'product_page', label: 'Product Page' },
{ value: 'category_archive', label: 'Category Archive' },
{ value: 'tag_archive', label: 'Tag Archive' },
{ value: 'attribute_archive', label: 'Attribute Archive' },
];
```
---
## Difficulty Mapping
**File:** `frontend/src/utils/difficulty.ts`
The difficulty filter uses a 1-5 scale with human-readable labels:
| Value | Label | Raw Score Range |
|-------|-------|-----------------|
| 1 | Very Easy | 0-10 |
| 2 | Easy | 11-30 |
| 3 | Medium | 31-50 |
| 4 | Hard | 51-70 |
| 5 | Very Hard | 71-100 |
**Important:** The database stores raw SEO difficulty scores (0-100), but the UI displays and filters using the 1-5 scale. The mapping must be consistent between frontend display and backend filtering.
---
## Filter State Management Pattern
Each page follows this pattern:
```tsx
// 1. Define filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
// 2. Pass to config function
const config = createPageConfig({
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
// ...handlers
});
// 3. Config returns filters array
filters: [
{ key: 'search', type: 'text', placeholder: '...' },
{ key: 'status', type: 'select', options: [...] },
]
// 4. TablePageTemplate renders filters
// 5. onFilterChange triggers state update
// 6. useEffect with dependencies re-fetches data
```
---
## API Query Parameters
When filters are applied, the frontend constructs API calls like:
```
GET /api/v1/planner/keywords/?status=new&cluster_id=123&search=term&page=1&page_size=50
GET /api/v1/planner/clusters/?status=mapped&difficulty=3&volume_min=100
GET /api/v1/planner/ideas/?content_structure=guide&content_type=post
GET /api/v1/writer/content/?status=draft&source=igny8
```
---
## TablePageTemplate Filter Rendering
The template handles filter rendering in `renderFiltersRow()`:
```tsx
{filters.map((filter) => {
if (filter.type === 'text') {
return <Input ... />;
}
if (filter.type === 'select') {
return <SelectDropdown ... />;
}
if (filter.type === 'custom' && filter.customRender) {
return filter.customRender();
}
})}
```
---
## Current Limitations
1. **Static Options**: Filter options are hardcoded in config files, not dynamically loaded from backend
2. **No Cascading**: Changing one filter doesn't update available options in other filters
3. **Difficulty Mapping**: Backend filters by exact match, not by mapped ranges
4. **No Filter Persistence**: Filters reset on page navigation
---
## Planned Improvements
1. **Dynamic Filter Options API**: Backend endpoint to return available options based on current data
2. **Cascading Filters**: When status is selected, only show clusters/types that exist with that status
3. **URL State**: Persist filter state in URL query parameters
4. **Filter Presets**: Save commonly used filter combinations
---
## File References
| Component | Path |
|-----------|------|
| TablePageTemplate | `frontend/src/templates/TablePageTemplate.tsx` |
| Keywords Config | `frontend/src/config/pages/keywords.config.tsx` |
| Clusters Config | `frontend/src/config/pages/clusters.config.tsx` |
| Ideas Config | `frontend/src/config/pages/ideas.config.tsx` |
| Content Config | `frontend/src/config/pages/content.config.tsx` |
| Review Config | `frontend/src/config/pages/review.config.tsx` |
| Approved Config | `frontend/src/config/pages/approved.config.tsx` |
| Structure Mapping | `frontend/src/config/structureMapping.ts` |
| Difficulty Utils | `frontend/src/utils/difficulty.ts` |
| Planner Views | `backend/igny8_core/modules/planner/views.py` |
| Writer Views | `backend/igny8_core/modules/writer/views.py` |

View File

@@ -94,7 +94,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
}; };
return ( return (
<div className={`relative ${className}`}> <div className={`relative flex-shrink-0 ${className}`}>
{/* Trigger Button - styled like igny8-select-styled */} {/* Trigger Button - styled like igny8-select-styled */}
<button <button
ref={buttonRef} ref={buttonRef}
@@ -102,7 +102,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
onClick={() => !disabled && setIsOpen(!isOpen)} onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`igny8-select-styled w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${ className={`igny8-select-styled w-auto min-w-[120px] max-w-[360px] appearance-none rounded-lg border border-gray-300 bg-transparent px-3 pr-10 shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm' className.includes('text-base') ? 'h-11 py-2.5 text-base' : 'h-9 py-2 text-sm'
} ${ } ${
isPlaceholder isPlaceholder
@@ -124,7 +124,7 @@ const SelectDropdown: React.FC<SelectDropdownProps> = ({
{isOpen && ( {isOpen && (
<div <div
ref={dropdownRef} ref={dropdownRef}
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden max-h-60 overflow-y-auto" className="absolute z-50 left-0 mt-1 min-w-full rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden max-h-60 overflow-y-auto"
> >
<div className="py-1"> <div className="py-1">
{options.map((option) => { {options.map((option) => {

View File

@@ -86,6 +86,11 @@ export const createIdeasPageConfig = (
typeFilter: string; typeFilter: string;
setTypeFilter: (value: string) => void; setTypeFilter: (value: string) => void;
setCurrentPage: (page: number) => void; setCurrentPage: (page: number) => void;
// Dynamic filter options
statusOptions?: Array<{ value: string; label: string }>;
contentTypeOptions?: Array<{ value: string; label: string }>;
contentStructureOptions?: Array<{ value: string; label: string }>;
clusterOptions?: Array<{ value: string; label: string }>;
} }
): IdeasPageConfig => { ): IdeasPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
@@ -233,9 +238,14 @@ export const createIdeasPageConfig = (
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Status' }, { value: '', label: 'All Status' },
{ value: 'new', label: 'New' }, ...(handlers.statusOptions && handlers.statusOptions.length > 0
{ value: 'queued', label: 'Queued' }, ? handlers.statusOptions
{ value: 'completed', label: 'Completed' }, : [
{ value: 'new', label: 'New' },
{ value: 'queued', label: 'Queued' },
{ value: 'completed', label: 'Completed' },
]
),
], ],
}, },
{ {
@@ -244,24 +254,25 @@ export const createIdeasPageConfig = (
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Structures' }, { value: '', label: 'All Structures' },
// Post ...(handlers.contentStructureOptions && handlers.contentStructureOptions.length > 0
{ value: 'article', label: 'Article' }, ? handlers.contentStructureOptions
{ value: 'guide', label: 'Guide' }, : [
{ value: 'comparison', label: 'Comparison' }, { value: 'article', label: 'Article' },
{ value: 'review', label: 'Review' }, { value: 'guide', label: 'Guide' },
{ value: 'listicle', label: 'Listicle' }, { value: 'comparison', label: 'Comparison' },
// Page { value: 'review', label: 'Review' },
{ value: 'landing_page', label: 'Landing Page' }, { value: 'listicle', label: 'Listicle' },
{ value: 'business_page', label: 'Business Page' }, { value: 'landing_page', label: 'Landing Page' },
{ value: 'service_page', label: 'Service Page' }, { value: 'business_page', label: 'Business Page' },
{ value: 'general', label: 'General' }, { value: 'service_page', label: 'Service Page' },
{ value: 'cluster_hub', label: 'Cluster Hub' }, { value: 'general', label: 'General' },
// Product { value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'product_page', label: 'Product Page' }, { value: 'product_page', label: 'Product Page' },
// Taxonomy { value: 'category_archive', label: 'Category Archive' },
{ value: 'category_archive', label: 'Category Archive' }, { value: 'tag_archive', label: 'Tag Archive' },
{ value: 'tag_archive', label: 'Tag Archive' }, { value: 'attribute_archive', label: 'Attribute Archive' },
{ value: 'attribute_archive', label: 'Attribute Archive' }, ]
),
], ],
}, },
{ {
@@ -270,23 +281,29 @@ export const createIdeasPageConfig = (
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Types' }, { value: '', label: 'All Types' },
{ value: 'post', label: 'Post' }, ...(handlers.contentTypeOptions && handlers.contentTypeOptions.length > 0
{ value: 'page', label: 'Page' }, ? handlers.contentTypeOptions
{ value: 'product', label: 'Product' }, : [
{ value: 'taxonomy', label: 'Taxonomy' }, { value: 'post', label: 'Post' },
{ value: 'page', label: 'Page' },
{ value: 'product', label: 'Product' },
{ value: 'taxonomy', label: 'Taxonomy' },
]
),
], ],
}, },
{ {
key: 'keyword_cluster_id', key: 'keyword_cluster_id',
label: 'Cluster', label: 'Cluster',
type: 'select', type: 'select',
options: (() => {
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
dynamicOptions: 'clusters', dynamicOptions: 'clusters',
options: [
{ value: '', label: 'All Clusters' },
...(handlers.clusterOptions && handlers.clusterOptions.length > 0
? handlers.clusterOptions
: handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name }))
),
],
}, },
], ],
formFields: (clusters: Array<{ id: number; name: string }>) => [ formFields: (clusters: Array<{ id: number; name: string }>) => [

View File

@@ -140,6 +140,7 @@ export const createKeywordsPageConfig = (
countryOptions?: Array<{ value: string; label: string }>; countryOptions?: Array<{ value: string; label: string }>;
statusOptions?: Array<{ value: string; label: string }>; statusOptions?: Array<{ value: string; label: string }>;
clusterOptions?: Array<{ value: string; label: string }>; clusterOptions?: Array<{ value: string; label: string }>;
difficultyOptions?: Array<{ value: string; label: string }>;
} }
): KeywordsPageConfig => { ): KeywordsPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
@@ -308,11 +309,16 @@ export const createKeywordsPageConfig = (
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Difficulty' }, { value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' }, ...(handlers.difficultyOptions && handlers.difficultyOptions.length > 0
{ value: '2', label: '2 - Easy' }, ? handlers.difficultyOptions
{ value: '3', label: '3 - Medium' }, : [
{ value: '4', label: '4 - Hard' }, { value: '1', label: '1 - Very Easy' },
{ value: '5', label: '5 - Very Hard' }, { value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
]
),
], ],
}, },
{ {
@@ -448,19 +454,6 @@ export const createKeywordsPageConfig = (
</div> </div>
), ),
}, },
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
options: (() => {
// Dynamically generate options from current clusters
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
className: 'w-40',
},
], ],
headerMetrics: [ headerMetrics: [
{ {

View File

@@ -1,8 +1,87 @@
/** /**
* Structure mapping configuration * Structure mapping configuration
* Maps content types to their valid structures and provides label mappings * Maps content types to their valid structures and provides label mappings
* Also contains shared filter options for reuse across pages
*/ */
// ============================================================================
// SHARED FILTER OPTIONS - Use these in page configs to avoid duplication
// ============================================================================
/** Status options for Keywords page */
export const KEYWORD_STATUS_OPTIONS = [
{ value: '', label: 'All Status' },
{ value: 'new', label: 'New' },
{ value: 'mapped', label: 'Mapped' },
];
/** Status options for Clusters page */
export const CLUSTER_STATUS_OPTIONS = [
{ value: '', label: 'All Status' },
{ value: 'new', label: 'New' },
{ value: 'mapped', label: 'Mapped' },
];
/** Status options for Ideas page */
export const IDEAS_STATUS_OPTIONS = [
{ value: '', label: 'All Status' },
{ value: 'new', label: 'New' },
{ value: 'queued', label: 'Queued' },
{ value: 'completed', label: 'Completed' },
];
/** Status options for Content/Review/Approved pages */
export const CONTENT_STATUS_OPTIONS = [
{ value: '', label: 'All Status' },
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'approved', label: 'Approved' },
{ value: 'published', label: 'Published' },
];
/** Site status options for content publishing */
export const SITE_STATUS_OPTIONS = [
{ value: '', label: 'All Site Status' },
{ value: 'not_published', label: 'Not Published' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'publishing', label: 'Publishing' },
{ value: 'published', label: 'Published' },
{ value: 'failed', label: 'Failed' },
];
/** Source options for content */
export const SOURCE_OPTIONS = [
{ value: '', label: 'All Sources' },
{ value: 'igny8', label: 'IGNY8' },
{ value: 'wordpress', label: 'WordPress' },
];
/** Country options - used for keywords */
export const COUNTRY_OPTIONS = [
{ value: '', label: 'All Countries' },
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'AU', label: 'Australia' },
{ value: 'IN', label: 'India' },
{ value: 'PK', label: 'Pakistan' },
];
/** Difficulty options (1-5 scale) */
export const DIFFICULTY_OPTIONS = [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
];
// ============================================================================
// CONTENT TYPE AND STRUCTURE OPTIONS
// ============================================================================
export const CONTENT_TYPE_OPTIONS = [ export const CONTENT_TYPE_OPTIONS = [
{ value: 'post', label: 'Post' }, { value: 'post', label: 'Post' },
{ value: 'page', label: 'Page' }, { value: 'page', label: 'Page' },
@@ -10,6 +89,35 @@ export const CONTENT_TYPE_OPTIONS = [
{ value: 'taxonomy', label: 'Taxonomy' }, { value: 'taxonomy', label: 'Taxonomy' },
]; ];
/** Content type filter options (with "All" option) */
export const CONTENT_TYPE_FILTER_OPTIONS = [
{ value: '', label: 'All Types' },
...CONTENT_TYPE_OPTIONS,
];
/** Content structure filter options (with "All" option) */
export const CONTENT_STRUCTURE_FILTER_OPTIONS = [
{ value: '', label: 'All Structures' },
// Post structures
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'comparison', label: 'Comparison' },
{ value: 'review', label: 'Review' },
{ value: 'listicle', label: 'Listicle' },
// Page structures
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'business_page', label: 'Business Page' },
{ value: 'service_page', label: 'Service Page' },
{ value: 'general', label: 'General' },
{ value: 'cluster_hub', label: 'Cluster Hub' },
// Product structures
{ value: 'product_page', label: 'Product Page' },
// Taxonomy structures
{ value: 'category_archive', label: 'Category Archive' },
{ value: 'tag_archive', label: 'Tag Archive' },
{ value: 'attribute_archive', label: 'Attribute Archive' },
];
export const CONTENT_STRUCTURE_BY_TYPE: Record<string, Array<{ value: string; label: string }>> = { export const CONTENT_STRUCTURE_BY_TYPE: Record<string, Array<{ value: string; label: string }>> = {
post: [ post: [
{ value: 'article', label: 'Article' }, { value: 'article', label: 'Article' },

View File

@@ -19,6 +19,8 @@ import {
ContentIdeaCreateData, ContentIdeaCreateData,
fetchClusters, fetchClusters,
Cluster, Cluster,
fetchPlannerIdeasFilterOptions,
FilterOption,
} from '../../services/api'; } from '../../services/api';
import FormModal from '../../components/common/FormModal'; import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal'; import ProgressModal from '../../components/common/ProgressModal';
@@ -49,6 +51,12 @@ export default function Ideas() {
const [totalPending, setTotalPending] = useState(0); const [totalPending, setTotalPending] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0);
// Dynamic filter options
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
const [contentTypeOptions, setContentTypeOptions] = useState<FilterOption[]>([]);
const [contentStructureOptions, setContentStructureOptions] = useState<FilterOption[]>([]);
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
// Filter state // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -85,7 +93,7 @@ export default function Ideas() {
// Progress modal for AI functions // Progress modal for AI functions
const progressModal = useProgressModal(); const progressModal = useProgressModal();
// Load clusters for filter dropdown // Load clusters for form dropdown (all clusters)
useEffect(() => { useEffect(() => {
const loadClusters = async () => { const loadClusters = async () => {
try { try {
@@ -98,6 +106,26 @@ export default function Ideas() {
loadClusters(); loadClusters();
}, []); }, []);
// Load dynamic filter options based on current site's data
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const options = await fetchPlannerIdeasFilterOptions(activeSite.id);
setStatusOptions(options.statuses || []);
setContentTypeOptions(options.content_types || []);
setContentStructureOptions(options.content_structures || []);
setClusterOptions(options.clusters || []);
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite]);
// Load filter options when site changes
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load total metrics for footer widget (site-wide totals, no sector filter) // Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => { const loadTotalMetrics = useCallback(async () => {
try { try {
@@ -302,8 +330,13 @@ export default function Ideas() {
typeFilter, typeFilter,
setTypeFilter, setTypeFilter,
setCurrentPage, setCurrentPage,
// Dynamic filter options
statusOptions,
contentTypeOptions,
contentStructureOptions,
clusterOptions,
}); });
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]); }, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, statusOptions, contentTypeOptions, contentStructureOptions, clusterOptions]);
// Calculate header metrics - use totalInTasks/totalPending from API calls (not page data) // Calculate header metrics - use totalInTasks/totalPending from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page // This ensures metrics show correct totals across all pages, not just current page

View File

@@ -60,6 +60,7 @@ export default function Keywords() {
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]); const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]); const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]); const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[]>([]);
// Filter state - match Keywords.tsx // Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -123,24 +124,62 @@ export default function Keywords() {
loadClusters(); loadClusters();
}, []); }, []);
// Load dynamic filter options based on current site's data // Load dynamic filter options based on current site's data and applied filters
const loadFilterOptions = useCallback(async () => { // This implements cascading filters - each filter's options reflect what's available
// given the other currently applied filters
const loadFilterOptions = useCallback(async (currentFilters?: {
status?: string;
country?: string;
cluster_id?: string;
difficulty_min?: number;
difficulty_max?: number;
}) => {
if (!activeSite) return; if (!activeSite) return;
try { try {
const options = await fetchPlannerKeywordFilterOptions(activeSite.id); const options = await fetchPlannerKeywordFilterOptions(activeSite.id, currentFilters);
setCountryOptions(options.countries || []); setCountryOptions(options.countries || []);
setStatusOptions(options.statuses || []); setStatusOptions(options.statuses || []);
setClusterOptions(options.clusters || []); setClusterOptions(options.clusters || []);
setDifficultyOptions(options.difficulties || []);
} catch (error) { } catch (error) {
console.error('Error loading filter options:', error); console.error('Error loading filter options:', error);
} }
}, [activeSite]); }, [activeSite]);
// Load filter options when site changes // Parse difficulty filter to min/max values
// Backend uses: 1=0-10, 2=11-30, 3=31-50, 4=51-70, 5=71-100
const getDifficultyRange = useCallback((filter: string): { min?: number; max?: number } => {
if (!filter) return {};
const level = parseInt(filter, 10);
if (isNaN(level)) return {};
// Map difficulty level to raw difficulty range matching backend logic
const ranges: Record<number, { min: number; max: number }> = {
1: { min: 0, max: 10 },
2: { min: 11, max: 30 },
3: { min: 31, max: 50 },
4: { min: 51, max: 70 },
5: { min: 71, max: 100 },
};
return ranges[level] || {};
}, []);
// Load filter options when site changes (initial load with no filters)
useEffect(() => { useEffect(() => {
loadFilterOptions(); loadFilterOptions();
}, [loadFilterOptions]); }, [activeSite]);
// Reload filter options when any filter changes (cascading filters)
useEffect(() => {
const { min: difficultyMin, max: difficultyMax } = getDifficultyRange(difficultyFilter);
loadFilterOptions({
status: statusFilter || undefined,
country: countryFilter || undefined,
cluster_id: clusterFilter || undefined,
difficulty_min: difficultyMin,
difficulty_max: difficultyMax,
});
}, [statusFilter, countryFilter, clusterFilter, difficultyFilter, loadFilterOptions, getDifficultyRange]);
// Load total metrics for footer widget (site-wide totals, no sector filter) // Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => { const loadTotalMetrics = useCallback(async () => {
@@ -553,6 +592,7 @@ export default function Keywords() {
countryOptions, countryOptions,
statusOptions, statusOptions,
clusterOptions, clusterOptions,
difficultyOptions,
}); });
}, [ }, [
clusters, clusters,
@@ -573,6 +613,7 @@ export default function Keywords() {
countryOptions, countryOptions,
statusOptions, statusOptions,
clusterOptions, clusterOptions,
difficultyOptions,
]); ]);
// Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data) // Calculate header metrics - use totalClustered/totalUnmapped from API calls (not page data)
@@ -715,7 +756,7 @@ export default function Keywords() {
status: statusFilter, status: statusFilter,
country: countryFilter, country: countryFilter,
difficulty: difficultyFilter, difficulty: difficultyFilter,
cluster_id: clusterFilter, cluster: clusterFilter,
volumeMin: volumeMin, volumeMin: volumeMin,
volumeMax: volumeMax, volumeMax: volumeMax,
}} }}
@@ -741,7 +782,7 @@ export default function Keywords() {
} else if (key === 'difficulty') { } else if (key === 'difficulty') {
setDifficultyFilter(stringValue); setDifficultyFilter(stringValue);
setCurrentPage(1); setCurrentPage(1);
} else if (key === 'cluster_id') { } else if (key === 'cluster') {
setClusterFilter(stringValue); setClusterFilter(stringValue);
setCurrentPage(1); setCurrentPage(1);
} }

View File

@@ -751,11 +751,70 @@ export interface PlannerKeywordFilterOptions {
countries: FilterOption[]; countries: FilterOption[];
statuses: FilterOption[]; statuses: FilterOption[];
clusters: FilterOption[]; clusters: FilterOption[];
difficulties: FilterOption[];
} }
export async function fetchPlannerKeywordFilterOptions(siteId?: number): Promise<PlannerKeywordFilterOptions> { // Filter options request with current filter state for cascading
export interface KeywordFilterOptionsRequest {
site_id?: number;
status?: string;
country?: string;
difficulty_min?: number;
difficulty_max?: number;
cluster_id?: string;
}
export async function fetchPlannerKeywordFilterOptions(
siteId?: number,
filters?: KeywordFilterOptionsRequest
): Promise<PlannerKeywordFilterOptions> {
const params = new URLSearchParams();
if (siteId) params.append('site_id', siteId.toString());
if (filters?.status) params.append('status', filters.status);
if (filters?.country) params.append('country', filters.country);
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
if (filters?.cluster_id) params.append('cluster_id', filters.cluster_id);
const queryString = params.toString();
return fetchAPI(`/v1/planner/keywords/filter_options/${queryString ? `?${queryString}` : ''}`);
}
// Clusters filter options
export interface PlannerClusterFilterOptions {
statuses: FilterOption[];
}
export async function fetchPlannerClusterFilterOptions(siteId?: number): Promise<PlannerClusterFilterOptions> {
const queryParams = siteId ? `?site_id=${siteId}` : ''; const queryParams = siteId ? `?site_id=${siteId}` : '';
return fetchAPI(`/v1/planner/keywords/filter_options/${queryParams}`); return fetchAPI(`/v1/planner/clusters/filter_options/${queryParams}`);
}
// Ideas filter options
export interface PlannerIdeasFilterOptions {
statuses: FilterOption[];
content_types: FilterOption[];
content_structures: FilterOption[];
clusters: FilterOption[];
}
export async function fetchPlannerIdeasFilterOptions(siteId?: number): Promise<PlannerIdeasFilterOptions> {
const queryParams = siteId ? `?site_id=${siteId}` : '';
return fetchAPI(`/v1/planner/ideas/filter_options/${queryParams}`);
}
// Content filter options (Writer module)
export interface WriterContentFilterOptions {
statuses: FilterOption[];
site_statuses: FilterOption[];
content_types: FilterOption[];
content_structures: FilterOption[];
sources: FilterOption[];
}
export async function fetchWriterContentFilterOptions(siteId?: number): Promise<WriterContentFilterOptions> {
const queryParams = siteId ? `?site_id=${siteId}` : '';
return fetchAPI(`/v1/writer/content/filter_options/${queryParams}`);
} }
// Clusters-specific API functions // Clusters-specific API functions

View File

@@ -517,18 +517,23 @@
SECTION 6: FORM ELEMENT STYLES SECTION 6: FORM ELEMENT STYLES
=================================================================== */ =================================================================== */
/* Styled Select/Dropdown with custom chevron */ /* Styled Select/Dropdown with custom chevron - for native <select> elements */
.igny8-select-styled { select.igny8-select-styled {
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important; background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%23647085' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
background-position: right 12px center !important; background-position: right 12px center !important;
padding-right: 36px !important; padding-right: 36px !important;
} }
.dark .igny8-select-styled { .dark select.igny8-select-styled {
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important; background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%2398A2B3' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important;
} }
/* For button-based selects (SelectDropdown), icon is rendered by component */
button.igny8-select-styled {
/* No background-image - ChevronDownIcon is rendered inside the component */
}
/* Checkbox styling */ /* Checkbox styling */
.tableCheckbox:checked ~ span span { @apply opacity-100; } .tableCheckbox:checked ~ span span { @apply opacity-100; }
.tableCheckbox:checked ~ span { @apply border-brand-500 bg-brand-500; } .tableCheckbox:checked ~ span { @apply border-brand-500 bg-brand-500; }

View File

@@ -736,8 +736,8 @@ export default function TablePageTemplate({
{/* Filters Row - Below action buttons, left aligned with shadow */} {/* Filters Row - Below action buttons, left aligned with shadow */}
{showFilters && (renderFilters || filters.length > 0) && ( {showFilters && (renderFilters || filters.length > 0) && (
<div className="flex justify-start py-1.5 mb-2.5"> <div className="flex justify-start py-1.5 mb-2.5">
<div className="bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md"> <div className="inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md">
<div className="flex gap-3 items-center flex-wrap"> <div className="flex gap-2 items-center flex-wrap">
{renderFilters ? ( {renderFilters ? (
renderFilters renderFilters
) : ( ) : (
@@ -757,7 +757,7 @@ export default function TablePageTemplate({
onChange={(e) => { onChange={(e) => {
onFilterChange?.(filter.key, e.target.value); onFilterChange?.(filter.key, e.target.value);
}} }}
className="w-full sm:flex-1 h-8" className="w-48 h-8"
/> />
); );
} else if (filter.type === 'select') { } else if (filter.type === 'select') {
@@ -772,7 +772,6 @@ export default function TablePageTemplate({
const newValue = value === null || value === undefined ? '' : String(value); const newValue = value === null || value === undefined ? '' : String(value);
onFilterChange?.(filter.key, newValue); onFilterChange?.(filter.key, newValue);
}} }}
className={filter.className || "w-full sm:flex-1"}
/> />
); );
} }