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

@@ -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 */}