Section 3 Completed

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-27 02:43:46 +00:00
parent add04e2ad5
commit 178b7c23ce
11 changed files with 242 additions and 755 deletions

View File

@@ -1,7 +1,7 @@
# IGNY8 Change Log
**Current Version:** 1.1.3
**Last Updated:** December 27, 2025
**Current Version:** 1.1.5
**Last Updated:** January 2, 2025
---
@@ -9,6 +9,8 @@
| Version | Date | Summary |
|---------|------|---------|
| 1.1.5 | Jan 2, 2025 | Section 3 WORKFLOW modules - Planner, Writer, Progress Modal fixes |
| 1.1.4 | Dec 27, 2025 | Section 2 SETUP modules - Add Keywords, Content Settings, Sites fixes |
| 1.1.3 | Dec 27, 2025 | Merged RULES.md into .rules |
| 1.1.2 | Dec 27, 2025 | Module status documentation, TODOS.md |
| 1.1.1 | Dec 27, 2025 | Simplified AI agent rules file |
@@ -22,6 +24,95 @@
---
## v1.1.5 - January 2, 2025
### Section 3 WORKFLOW Modules Implementation
**Planner Module:**
- **DELETED** `KeywordOpportunities.tsx` - orphaned page, Add Keywords is source of truth
- Removed route `/planner/keyword-opportunities` from App.tsx
- Updated Clusters table to show ideas count badge: "X ideas" (green) or "No ideas" (gray)
- Made cluster name clickable in Ideas table - navigates to `/planner/clusters/:id`
**Writer Module:**
- Fixed duplicate tags/categories display in ContentView template
- Removed redundant tags/categories section that appeared below the main metadata
- Tags and categories now display only once in the Topic section
**Progress Modals:**
- Fixed placeholder "X" text in image prompt progress modals:
- Changed "Mapping Content for X Image Prompts" → "Mapping content for image prompts"
- Changed "Writing X Inarticle Image Prompts" → "Writing Inarticle Image Prompts"
- Changed "Featured Image and X Inarticle..." → "Image prompts ready for generation"
- All step labels now show actual counts when available, clean fallbacks otherwise
### Files Changed
- `frontend/src/App.tsx` - Removed KeywordOpportunities import/route
- `frontend/src/config/pages/clusters.config.tsx` - Ideas count badge styling
- `frontend/src/config/pages/ideas.config.tsx` - Clickable cluster link
- `frontend/src/templates/ContentViewTemplate.tsx` - Removed duplicate tags/categories
- `frontend/src/components/common/ProgressModal.tsx` - Fixed placeholder texts
- `docs/30-FRONTEND/PAGES.md` - Removed KeywordOpportunities references, updated version
- `docs/20-API/ENDPOINTS.md` - Added Content Settings API documentation
### Files Deleted
- `frontend/src/pages/Planner/KeywordOpportunities.tsx`
---
## v1.1.4 - December 27, 2025
### Section 2 SETUP Modules Implementation
**Add Keywords Page (`/setup/add-keywords`):**
- Added "Not Yet Added Only" filter toggle to show only keywords not in workflow
- Added keyword count summary: "X keywords in your workflow • Y available to add"
- Added "Next: Plan Your Content →" CTA button (appears after adding keywords)
- Added "Keyword Research coming soon!" teaser text
- Sector requirement tooltip already existed - no changes needed
**Content Settings Page (`/account/content-settings`):**
- **NEW Backend API**: Created `/v1/system/settings/content/<pk>/` endpoints
- `GET /v1/system/settings/content/content_generation/` - Retrieve settings
- `POST /v1/system/settings/content/content_generation/save/` - Save settings
- `GET /v1/system/settings/content/publishing/` - Retrieve settings
- `POST /v1/system/settings/content/publishing/save/` - Save settings
- Content Generation tab now persists: append_to_prompt, default_tone, default_length
- Publishing tab now persists: auto_publish_enabled, auto_sync_enabled
- Fixed false "saved" confirmation - now actually saves to backend
**Sites Module:**
- **NEW Component**: Created `SiteSetupChecklist` component showing setup progress
- Displays checklist: Site created, Industry/Sectors, WordPress integration, Keywords
- Progress bar with percentage
- "Complete Setup" button linking to first incomplete item
- "Ready to create content!" message when all complete
- Updated Site Dashboard to use SiteSetupChecklist instead of mock stats
- Removed mock statistics that showed all zeros
**Cleanup:**
- Deleted `pages/Sites/Manage.tsx` (redundant duplicate of List.tsx)
- Removed empty `pages/Sites/Builder/` folder structure
- Removed `/sites/manage` route from App.tsx
- Removed SiteManage lazy import from App.tsx
### Files Changed
- `frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx`
- `frontend/src/pages/account/ContentSettingsPage.tsx`
- `frontend/src/pages/Sites/Dashboard.tsx`
- `frontend/src/App.tsx`
- `backend/igny8_core/modules/system/settings_views.py`
- `backend/igny8_core/modules/system/urls.py`
### Files Created
- `frontend/src/components/sites/SiteSetupChecklist.tsx`
### Files Deleted
- `frontend/src/pages/Sites/Manage.tsx`
- `frontend/src/pages/Sites/Builder/` (empty directory)
---
## v1.1.3 - December 27, 2025
### Changed

View File

@@ -135,6 +135,8 @@ All endpoints require authentication unless noted.
| GET | `/settings/integrations/image_generation/` | Get image settings | Current config |
| PUT | `/settings/integrations/image_generation/` | Save image settings | Update config |
| POST | `/settings/integrations/test/` | Test connection | Verify API keys |
| GET | `/settings/content/{key}/` | `ContentSettingsViewSet.retrieve` | Get content settings |
| POST | `/settings/content/{key}/save/` | `ContentSettingsViewSet.save_settings` | Save content settings |
| GET | `/prompts/` | List prompts | All prompts |
| GET | `/prompts/{type}/` | Get prompt | Specific prompt |
| PUT | `/prompts/{type}/` | Save prompt | Update prompt |
@@ -143,6 +145,10 @@ All endpoints require authentication unless noted.
| PUT | `/modules/` | Save modules | Update enabled |
| GET | `/health/` | Health check | System status |
**Content Settings Keys:**
- `content_generation` - AI writing settings (default_article_length, default_tone, include_faq, enable_internal_linking, etc.)
- `publishing` - Publishing defaults (default_publish_status, auto_schedule, schedule_frequency, schedule_times)
---
## Automation Endpoints (`/api/v1/automation/`)

View File

@@ -1,7 +1,7 @@
# Frontend Pages & Routes
**Last Verified:** December 25, 2025
**Version:** 1.1.0
**Last Verified:** January 2, 2025
**Version:** 1.1.4
**Framework:** React 19 + TypeScript + React Router 6 + Vite
---
@@ -41,7 +41,14 @@ Routes defined in `/frontend/src/App.tsx`:
| Route | File | Description |
|-------|------|-------------|
| `/setup/add-keywords` | `Setup/AddKeywords.tsx` | Browse/add seed keywords from global database |
| `/setup/add-keywords` | `Setup/IndustriesSectorsKeywords.tsx` | Browse/add seed keywords from global database |
**Features:**
- Industry → Sector → Keywords browse hierarchy
- "Show not-added only" filter toggle
- Real-time keyword count summary (added/total)
- "Next: Plan Your Content" CTA button
- "Keyword Research" coming soon teaser
### Content Settings
@@ -49,14 +56,18 @@ Routes defined in `/frontend/src/App.tsx`:
|-------|------|-------------|
| `/account/content-settings` | `account/ContentSettingsPage.tsx` | 3 tabs: Content Generation, Publishing, Image Settings |
**API Endpoints:** Settings persisted via `/api/v1/system/settings/content/{key}/`
### Sites
| Route | File | Description |
|-------|------|-------------|
| `/sites` | `Sites/List.tsx` | Site listing with filters |
| `/sites/:id/dashboard` | `Sites/SiteDashboard.tsx` | Individual site overview |
| `/sites/:id/settings` | `Sites/SiteSettings.tsx` | Site settings (General, Integrations, Content Types) |
| `/sites/:id/content` | `Sites/SiteContent.tsx` | Site content management |
| `/sites` | `Sites/List.tsx` | Site listing with setup checklist per site |
| `/sites/:id/dashboard` | `Sites/Dashboard.tsx` | Site overview with setup checklist |
| `/sites/:id/settings` | `Sites/Settings.tsx` | Site settings (General, Integrations, Content Types) |
**Components:**
- `SiteSetupChecklist` - Shows setup progress (site created, industry/sectors, WordPress, keywords)
### Thinker (Admin Only)
@@ -80,7 +91,6 @@ Routes defined in `/frontend/src/App.tsx`:
| `/planner/clusters` | `Planner/Clusters.tsx` | Cluster listing, AI clustering | Clusters |
| `/planner/clusters/:id` | `Planner/ClusterView.tsx` | Individual cluster view | - |
| `/planner/ideas` | `Planner/Ideas.tsx` | Content ideas, queue to writer | Ideas |
| `/planner/keyword-opportunities` | `Planner/KeywordOpportunities.tsx` | Seed keyword discovery (hidden) | - |
### Writer
@@ -228,18 +238,15 @@ frontend/src/pages/
│ ├── Keywords.tsx
│ ├── Clusters.tsx
│ ├── ClusterView.tsx
── Ideas.tsx
│ └── KeywordOpportunities.tsx # Not in nav
── Ideas.tsx
├── Settings/
│ └── IntegrationPage.tsx # AI Models (admin)
├── Setup/
│ └── AddKeywords.tsx
│ └── IndustriesSectorsKeywords.tsx # Add Keywords page
├── Sites/
│ ├── List.tsx
│ ├── SiteDashboard.tsx
── SiteSettings.tsx
│ ├── SiteContent.tsx
│ └── Manage.tsx # Possibly redundant
│ ├── List.tsx # Site listing
│ ├── Dashboard.tsx # Site overview + checklist
── Settings.tsx # Site configuration
├── Thinker/
│ ├── Prompts.tsx
│ ├── AuthorProfiles.tsx
@@ -303,10 +310,9 @@ Dashboard
## Known Issues (from Audit)
1. **KeywordOpportunities** not accessible from navigation
2. **Linker/Optimizer Dashboards** exist but not exposed
3. **Help sub-pages** are placeholders
4. **ContentView** is read-only (no editing capability)
5. Legacy redirects may cause confusion
1. **Linker/Optimizer Dashboards** exist but not exposed in navigation
2. **Help sub-pages** are placeholders
3. **ContentView** is read-only (no editing capability)
4. Legacy redirects may cause confusion
See `/PRE-LAUNCH-AUDIT.md` for complete issue list.

View File

@@ -25,7 +25,6 @@ const Keywords = lazy(() => import("./pages/Planner/Keywords"));
const Clusters = lazy(() => import("./pages/Planner/Clusters"));
const ClusterDetail = lazy(() => import("./pages/Planner/ClusterDetail"));
const Ideas = lazy(() => import("./pages/Planner/Ideas"));
const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportunities"));
// Writer Module - Lazy loaded
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
@@ -203,7 +202,6 @@ export default function App() {
{/* Reference Data */}
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
<Route path="/planner/keyword-opportunities" element={<KeywordOpportunities />} />
<Route path="/reference/industries" element={<ReferenceIndustries />} />
{/* Setup Pages */}

View File

@@ -127,7 +127,7 @@ const getSuccessMessage = (functionId?: string, title?: string, stepLogs?: any[]
}
// Default message
return 'Featured Image and X Inarticle Image Prompts ready for image generation';
return 'Image prompts ready for generation';
}
return 'Task completed successfully.';
};
@@ -180,9 +180,9 @@ const getStepsForFunction = (functionId?: string, title?: string): Array<{phase:
// Image prompt generation
return [
{ phase: 'INIT', label: 'Checking content and image slots' },
{ phase: 'PREP', label: 'Mapping Content for X Image Prompts' },
{ phase: 'PREP', label: 'Mapping content for image prompts' },
{ phase: 'AI_CALL', label: 'Writing Featured Image Prompts' },
{ phase: 'PARSE', label: 'Writing X Inarticle Image Prompts' },
{ phase: 'PARSE', label: 'Writing Inarticle Image Prompts' },
{ phase: 'SAVE', label: 'Assigning Prompts to Dedicated Slots' },
];
}
@@ -457,7 +457,7 @@ export default function ProgressModal({
return `Mapping Content for ${match[1]} Image Prompts`;
}
}
return 'Mapping Content for X Image Prompts';
return 'Mapping content for image prompts';
} else if (stepPhase === 'AI_CALL') {
// For AI_CALL: Show "Writing Featured Image Prompts"
return 'Writing Featured Image Prompts';
@@ -475,7 +475,7 @@ export default function ProgressModal({
return `Writing ${match[1]} Inarticle Image Prompts`;
}
}
return 'Writing X Inarticle Image Prompts';
return 'Writing Inarticle Image Prompts';
} else if (stepPhase === 'SAVE') {
// For SAVE: Extract prompt count from message
const promptCount = extractCount(/(\d+)\s+Prompts/i) || extractCount(/(\d+)\s+prompt/i);

View File

@@ -137,7 +137,17 @@ export const createClustersPageConfig = (
sortable: false, // Backend doesn't support sorting by ideas_count
sortField: 'ideas_count',
width: '120px',
render: (value: number) => value.toLocaleString(),
render: (value: number) => (
<Badge
color={value > 0 ? 'success' : 'light'}
size="xs"
variant="soft"
>
<span className="text-[11px] font-normal">
{value > 0 ? `${value.toLocaleString()} ideas` : 'No ideas'}
</span>
</Badge>
),
},
{
key: 'volume',

View File

@@ -4,6 +4,7 @@
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
titleColumn,
sectorColumn,
@@ -164,7 +165,19 @@ export const createIdeasPageConfig = (
sortable: false, // Backend doesn't support sorting by keyword_cluster_id
sortField: 'keyword_cluster_id',
width: '200px',
render: (_value: string, row: ContentIdea) => row.keyword_cluster_name || '-',
render: (_value: string, row: ContentIdea) => {
if (row.keyword_cluster_id && row.keyword_cluster_name) {
return (
<Link
to={`/planner/clusters/${row.keyword_cluster_id}`}
className="text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 hover:underline"
>
{row.keyword_cluster_name}
</Link>
);
}
return '-';
},
},
{
...statusColumn,

View File

@@ -1,680 +0,0 @@
/**
* Keyword Opportunities Page
* Shows available SeedKeywords for the active site/sectors
* Allows users to add keywords to their workflow
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchSeedKeywords,
SeedKeyword,
SeedKeywordResponse,
addSeedKeywordsToWorkflow,
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { getDifficultyLabelFromNumber, getDifficultyRange, getDifficultyNumber } from '../../utils/difficulty';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { BoltIcon, PlusIcon } from '../../icons';
import PageHeader from '../../components/common/PageHeader';
export default function KeywordOpportunities() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
const [loading, setLoading] = useState(true);
const [showContent, setShowContent] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('keyword');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [countryFilter, setCountryFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
// Load sectors for active site
useEffect(() => {
if (activeSite?.id) {
loadSectorsForSite(activeSite.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSite?.id]); // loadSectorsForSite is stable from Zustand store, no need to include it
// Load seed keywords
const loadSeedKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
setLoading(false);
return;
}
setLoading(true);
setShowContent(false);
try {
// Get already-attached keywords across ALL sectors for this site
let attachedSeedKeywordIds = new Set<number>();
try {
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
// Get all sectors for the site
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000, // Get all to check which are attached
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
// If keywords fetch fails for a sector, continue with others
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
} catch (err) {
// If sectors fetch fails, continue without filtering
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Build filters - fetch ALL results by paginating through all pages
const baseFilters: any = {
industry: activeSite.industry,
page_size: 1000, // Use reasonable page size (API might have max limit)
};
// Add sector filter if active sector is selected
// IMPORTANT: Filter by industry_sector (IndustrySector ID) which is what SeedKeyword.sector references
if (activeSector && activeSector.industry_sector) {
baseFilters.sector = activeSector.industry_sector;
}
if (searchTerm) baseFilters.search = searchTerm;
if (countryFilter) baseFilters.country = countryFilter;
// Fetch ALL pages to get complete dataset
let allResults: SeedKeyword[] = [];
let currentPageNum = 1;
let hasMore = true;
while (hasMore) {
const filters = { ...baseFilters, page: currentPageNum };
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
if (data.results && data.results.length > 0) {
allResults = [...allResults, ...data.results];
}
// Check if there are more pages
hasMore = data.next !== null && data.next !== undefined;
currentPageNum++;
// Safety limit to prevent infinite loops
if (currentPageNum > 100) {
console.warn('Reached maximum page limit (100) while fetching seed keywords');
break;
}
}
// Mark already-attached keywords instead of filtering them out
// Also check recentlyAddedRef to preserve state for keywords just added
let filteredResults = allResults.map(sk => {
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
return {
...sk,
isAdded: Boolean(isAdded) // Explicitly convert to boolean true/false
};
});
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filteredResults = filteredResults.filter(
sk => sk.difficulty >= range.min && sk.difficulty <= range.max
);
}
}
}
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume >= Number(volumeMin));
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume <= Number(volumeMax));
}
// Apply client-side sorting
if (sortBy) {
filteredResults.sort((a, b) => {
let aVal: any;
let bVal: any;
if (sortBy === 'keyword') {
aVal = a.keyword.toLowerCase();
bVal = b.keyword.toLowerCase();
} else if (sortBy === 'volume') {
aVal = a.volume;
bVal = b.volume;
} else if (sortBy === 'difficulty') {
aVal = a.difficulty;
bVal = b.difficulty;
} else if (sortBy === 'intent') {
aVal = a.intent.toLowerCase();
bVal = b.intent.toLowerCase();
} else {
return 0;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
// Calculate total count and pages from filtered results
const totalFiltered = filteredResults.length;
const pageSizeNum = pageSize || 10;
// Apply client-side pagination
const startIndex = (currentPage - 1) * pageSizeNum;
const endIndex = startIndex + pageSizeNum;
const paginatedResults = filteredResults.slice(startIndex, endIndex);
setSeedKeywords(paginatedResults);
setTotalCount(totalFiltered);
setTotalPages(Math.ceil(totalFiltered / pageSizeNum));
setShowContent(true);
} catch (error: any) {
console.error('Error loading seed keywords:', error);
toast.error(`Failed to load keyword opportunities: ${error.message}`);
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection]);
// Load data on mount and when filters change (excluding search - handled separately)
useEffect(() => {
loadSeedKeywords();
}, [loadSeedKeywords]);
// Debounced search - reset to page 1 when search term changes
useEffect(() => {
const timer = setTimeout(() => {
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]); // Only depend on searchTerm
// Handle pageSize changes - reload data when pageSize changes
// Note: loadSeedKeywords will be recreated when pageSize changes (it's in its dependencies)
// The effect that depends on loadSeedKeywords will handle the reload
// We just need to reset to page 1
useEffect(() => {
setCurrentPage(1);
}, [pageSize]); // Only depend on pageSize
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'keyword');
setSortDirection(direction);
setCurrentPage(1);
};
// Handle adding keywords to workflow
const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
// Get sector to use - use activeSector if available, otherwise get first available sector
let sectorToUse = activeSector;
if (!sectorToUse) {
try {
const { fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
if (sectors.length === 0) {
toast.error('No sectors available for this site. Please create a sector first.');
return;
}
sectorToUse = {
id: sectors[0].id,
name: sectors[0].name,
slug: sectors[0].slug,
site_id: activeSite.id,
is_active: sectors[0].is_active !== false,
industry_sector: sectors[0].industry_sector || null,
};
} catch (error: any) {
toast.error(`Failed to get sectors: ${error.message}`);
return;
}
}
try {
const result = await addSeedKeywordsToWorkflow(
seedKeywordIds,
activeSite.id,
sectorToUse.id
);
if (result.success) {
// Show success message with created count
if (result.created > 0) {
toast.success(`Successfully added ${result.created} keyword(s) to workflow`);
}
// Show skipped count if any
if (result.skipped > 0) {
toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`);
}
// Show detailed errors if any
if (result.errors && result.errors.length > 0) {
result.errors.forEach((error: string) => {
toast.error(error, { duration: 8000 });
});
}
// Only track and mark as added if actually created
if (result.created > 0) {
// Track these as recently added to preserve state during reload
seedKeywordIds.forEach(id => {
recentlyAddedRef.current.add(id);
});
// Immediately update state to mark keywords as added - this gives instant feedback
setSeedKeywords(prevKeywords =>
prevKeywords.map(kw =>
seedKeywordIds.includes(kw.id)
? { ...kw, isAdded: true }
: kw
)
);
}
// Clear selection
setSelectedIds([]);
// Don't reload immediately - the state is already updated
// The recentlyAddedRef will ensure they stay marked as added
// Only reload if user changes filters/pagination
} else {
toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`);
}
} catch (error: any) {
toast.error(`Failed to add keywords: ${error.message}`);
}
}, [activeSite, activeSector, toast]);
// Handle bulk add selected - filter out already added keywords
const handleBulkAddSelected = useCallback(async (ids: string[]) => {
if (ids.length === 0) {
toast.error('Please select at least one keyword');
return;
}
// Filter out already added keywords
const availableIds = ids.filter(id => {
const keyword = seedKeywords.find(sk => String(sk.id) === id);
return keyword && !keyword.isAdded;
});
if (availableIds.length === 0) {
toast.error('All selected keywords are already added to workflow');
return;
}
if (availableIds.length < ids.length) {
toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableIds.map(id => parseInt(id));
await handleAddToWorkflow(seedKeywordIds);
}, [handleAddToWorkflow, toast, seedKeywords]);
// Handle add all - fetch all keywords for site/sectors, not just current page
const handleAddAll = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
toast.error('Please select an active site first');
return;
}
try {
// Fetch ALL seed keywords for the site/sectors (no pagination)
const filters: any = {
industry: activeSite.industry,
page_size: 1000, // Large page size to get all
};
if (activeSector?.industry_sector) {
filters.sector = activeSector.industry_sector;
}
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
const allSeedKeywords = data.results || [];
if (allSeedKeywords.length === 0) {
toast.error('No keywords available to add');
return;
}
// Get already-added keywords to filter them out
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
let attachedSeedKeywordIds = new Set<number>();
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
// Filter out already added keywords
const availableKeywords = allSeedKeywords.filter(sk => !attachedSeedKeywordIds.has(sk.id));
if (availableKeywords.length === 0) {
toast.error('All keywords are already added to workflow');
return;
}
if (availableKeywords.length < allSeedKeywords.length) {
toast.info(`${allSeedKeywords.length - availableKeywords.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableKeywords.map(sk => sk.id);
await handleAddToWorkflow(seedKeywordIds);
} catch (error: any) {
toast.error(`Failed to load all keywords: ${error.message}`);
}
}, [activeSite, activeSector, handleAddToWorkflow, toast]);
// Page config
const pageConfig = useMemo(() => {
const showSectorColumn = !activeSector; // Show when viewing all sectors
return {
columns: [
{
key: 'keyword',
label: 'Keyword',
sortable: true,
sortField: 'keyword',
},
...(showSectorColumn ? [{
key: 'sector_name',
label: 'Sector',
sortable: false,
render: (_value: string, row: SeedKeyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'volume',
label: 'Volume',
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
key: 'difficulty',
label: 'Difficulty',
sortable: true,
sortField: 'difficulty',
align: 'center' as const,
render: (value: number) => {
const difficultyNum = getDifficultyNumber(value);
const difficultyBadgeVariant = 'light';
const difficultyBadgeColor =
typeof difficultyNum === 'number' && difficultyNum === 1
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 2
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 3
? 'warning'
: typeof difficultyNum === 'number' && difficultyNum === 4
? 'error'
: typeof difficultyNum === 'number' && difficultyNum === 5
? 'error'
: 'light';
return typeof difficultyNum === 'number' ? (
<Badge
color={difficultyBadgeColor}
variant={difficultyBadgeVariant}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
{
key: 'country',
label: 'Country',
sortable: true,
sortField: 'country',
render: (value: string) => {
const countryNames: Record<string, string> = {
'US': 'United States',
'CA': 'Canada',
'GB': 'United Kingdom',
'AE': 'United Arab Emirates',
'AU': 'Australia',
'IN': 'India',
'PK': 'Pakistan',
};
return (
<Badge
color="info"
size="sm"
variant="light"
>
{value || '-'}
</Badge>
);
},
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'country',
label: 'Country',
type: 'select',
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' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select',
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' },
],
},
],
bulkActions: !activeSector ? [] : [
{
key: 'add_selected_to_workflow',
label: 'Add Selected Keywords',
variant: 'primary' as const,
},
],
};
}, [activeSector]);
return (
<>
<PageHeader
title="Find & Add Keywords to Your Site"
badge={{ icon: <BoltIcon />, color: 'orange' }}
/>
{/* Show info banner when no sector is selected */}
{!activeSector && activeSite && (
<div className="mx-6 mt-6 mb-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-200">
Choose a Topic Area First
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
</p>
</div>
</div>
</div>
</div>
)}
<TablePageTemplate
columns={pageConfig.columns}
data={seedKeywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
country: countryFilter,
difficulty: difficultyFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'country') {
setCountryFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
}
}}
onRowAction={async (actionKey: string, row: SeedKeyword & { isAdded?: boolean }) => {
if (actionKey === 'add_to_workflow') {
// Check if sector is selected
if (!activeSector) {
toast.error('Please select a sector first');
return;
}
// Don't allow adding already-added keywords
if (row.isAdded) {
toast.info('This keyword is already added to workflow');
return;
}
await handleAddToWorkflow([row.id]);
}
}}
bulkActions={pageConfig.bulkActions}
onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') {
if (!activeSector) {
toast.error('Please select a sector first');
return;
}
await handleBulkAddSelected(ids);
}
}}
onCreate={activeSector ? handleAddAll : undefined}
createLabel="Add All to Workflow"
onCreateIcon={<PlusIcon />}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
/>
</>
);
}

View File

@@ -998,48 +998,6 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
</div>
</div>
</div>
{/* Tags and Categories from taxonomy_terms_data */}
{content.taxonomy_terms_data && content.taxonomy_terms_data.length > 0 ? (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap gap-4">
{content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (
<div className="flex items-center gap-2">
<TagIcon className="w-4 h-4 text-gray-400" />
<div className="flex flex-wrap gap-2">
{content.taxonomy_terms_data
.filter(term => term.taxonomy_type === 'tag')
.map((tag) => (
<span
key={tag.id}
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
>
{tag.name}
</span>
))}
</div>
</div>
)}
{content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Categories:</span>
<div className="flex flex-wrap gap-2">
{content.taxonomy_terms_data
.filter(term => term.taxonomy_type === 'category')
.map((category) => (
<span
key={category.id}
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
>
{category.name}
</span>
))}
</div>
</div>
)}
</div>
</div>
) : null}
</div>
{/* Action Buttons - Conditional based on status */}

View File

@@ -240,3 +240,50 @@ Site Setup Progress
| Cleanup | Remove Site Builder and content fetching APIs/models |
---
Status after implementation
## Summary of Section 2 Implementation
### 2.1 Add Keywords (IndustriesSectorsKeywords.tsx)
- ✅ Added `showNotAddedOnly` filter state with "Not Yet Added Only" filter option
- ✅ Added `addedCount` and `availableCount` state variables for keyword count tracking
- ✅ Added keyword count summary showing "X keywords in your workflow • Y available to add"
- ✅ Added "Next: Plan Your Content →" CTA button that appears when keywords are added
- ✅ Added "Looking for more keywords? Keyword Research coming soon!" teaser text
- ✅ Sector requirement tooltip already existed - no changes needed
- ✅ No visible import buttons to remove (dead code existed but was not exposed)
### 2.2 Content Settings (ContentSettingsPage.tsx)
- ✅ Created new backend API endpoint `/v1/system/settings/content/<pk>/` for content_generation and publishing
- ✅ Added `ContentSettingsViewSet` to backend with retrieve/update/save actions
- ✅ Updated frontend to load content_generation and publishing settings from API
- ✅ Updated frontend to save content_generation settings (append_to_prompt, default_tone, default_length)
- ✅ Updated frontend to save publishing settings (auto_publish_enabled, auto_sync_enabled)
- ✅ Removed TODO comments - settings now actually persist
### 2.3 Sites
- ✅ Created `SiteSetupChecklist` component showing setup progress with checklist
- ✅ Updated Dashboard.tsx to use the new checklist component
- ✅ Removed mock stats from Dashboard (were showing all zeros)
- ✅ Deleted Manage.tsx (redundant duplicate of List.tsx)
- ✅ Removed empty `Builder/` folder structure
- ✅ Removed routes to deleted pages in App.tsx
- ✅ Site Settings already defaults to "general" tab - no changes needed
### Files Modified
1. IndustriesSectorsKeywords.tsx
2. ContentSettingsPage.tsx
3. settings_views.py
4. urls.py
5. Dashboard.tsx
6. App.tsx
### Files Created
1. SiteSetupChecklist.tsx
### Files Deleted
1. Manage.tsx
2. `frontend/src/pages/Sites/Builder/` (empty folder)

View File

@@ -310,4 +310,42 @@ Not part of current phase. No issues to address.
| Error recovery | API to retry failed items |
| Notification logging | Ensure AI runs are logged for notification display |
---
---
## After Implementation
Section 3 implementation is complete. Here's a summary of all changes made:
## Section 3 WORKFLOW Modules - Implementation Complete ✅
### Completed Tasks:
1. **Delete KeywordOpportunities.tsx**
- Deleted the orphaned page file
- Removed import and route from App.tsx
- Updated PAGES.md documentation
2. **Add cluster ideas badge**
- Modified clusters.config.tsx
- Ideas column now shows badge: "X ideas" (green) or "No ideas" (gray)
3. **Fix ContentView duplicate tags/categories**
- Modified ContentViewTemplate.tsx
- Removed duplicate tags/categories section that appeared below metadata
4. **Fix progress modal placeholder texts**
- Modified ProgressModal.tsx
- Fixed "X" placeholders in step labels with cleaner fallbacks
- "Mapping Content for X Image Prompts" → "Mapping content for image prompts"
- "Writing X Inarticle Image Prompts" → "Writing Inarticle Image Prompts"
- Success message fallback cleaned up
5. **Add queued count to Ideas**
- Already implemented via headerMetrics showing New/Queued/Completed counts
6. **Clickable cluster in Ideas**
- Modified ideas.config.tsx
- Cluster name now links to `/planner/clusters/:id`
### Documentation Updated:
- CHANGELOG.md - Added v1.1.5 section
- ENDPOINTS.md - Added Content Settings API docs
- PAGES.md - Removed KeywordOpportunities, updated version