123
This commit is contained in:
@@ -94,14 +94,10 @@ const SiteDashboard = lazy(() => import("./pages/Sites/Dashboard"));
|
|||||||
const SiteContent = lazy(() => import("./pages/Sites/Content"));
|
const SiteContent = lazy(() => import("./pages/Sites/Content"));
|
||||||
const PageManager = lazy(() => import("./pages/Sites/PageManager"));
|
const PageManager = lazy(() => import("./pages/Sites/PageManager"));
|
||||||
const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
|
const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
|
||||||
const SitePreview = lazy(() => import("./pages/Sites/Preview"));
|
|
||||||
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
|
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
|
||||||
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
|
const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard"));
|
||||||
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel"));
|
||||||
|
|
||||||
// Content Manager Module - Lazy loaded
|
|
||||||
const ContentManagerDashboard = lazy(() => import("./pages/ContentManager/Dashboard"));
|
|
||||||
|
|
||||||
// Help - Lazy loaded
|
// Help - Lazy loaded
|
||||||
const Help = lazy(() => import("./pages/Help/Help"));
|
const Help = lazy(() => import("./pages/Help/Help"));
|
||||||
const Docs = lazy(() => import("./pages/Help/Docs"));
|
const Docs = lazy(() => import("./pages/Help/Docs"));
|
||||||
@@ -210,8 +206,8 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Writer Module - Redirect dashboard to content */}
|
{/* Writer Module - Redirect dashboard to tasks */}
|
||||||
<Route path="/writer" element={<Navigate to="/writer/content" replace />} />
|
<Route path="/writer" element={<Navigate to="/writer/tasks" replace />} />
|
||||||
<Route path="/writer/tasks" element={
|
<Route path="/writer/tasks" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ModuleGuard module="writer">
|
<ModuleGuard module="writer">
|
||||||
@@ -258,37 +254,6 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Content Manager Module Routes */}
|
|
||||||
<Route path="/content-manager" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ContentManagerDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/content-manager/posts" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ContentManagerDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/content-manager/pages" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ContentManagerDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/content-manager/new" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PostEditor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/content-manager/:id" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PostEditor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/content-manager/:id/edit" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PostEditor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Linker Module - Redirect dashboard to content */}
|
{/* Linker Module - Redirect dashboard to content */}
|
||||||
<Route path="/linker" element={<Navigate to="/linker/content" replace />} />
|
<Route path="/linker" element={<Navigate to="/linker/content" replace />} />
|
||||||
@@ -532,11 +497,6 @@ export default function App() {
|
|||||||
<SiteContent />
|
<SiteContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/sites/:id/preview" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SitePreview />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/settings" element={
|
<Route path="/sites/:id/settings" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SiteSettings />
|
<SiteSettings />
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ export interface ContentImageData {
|
|||||||
interface ContentImageCellProps {
|
interface ContentImageCellProps {
|
||||||
image: ContentImageData | null;
|
image: ContentImageData | null;
|
||||||
maxPromptLength?: number;
|
maxPromptLength?: number;
|
||||||
|
showPrompt?: boolean; // New prop to control prompt visibility
|
||||||
|
onImageClick?: () => void; // Optional click handler
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContentImageCell({ image, maxPromptLength = 100 }: ContentImageCellProps) {
|
export default function ContentImageCell({ image, maxPromptLength = 100, showPrompt = false, onImageClick }: ContentImageCellProps) {
|
||||||
const [showFullPrompt, setShowFullPrompt] = useState(false);
|
const [showFullPrompt, setShowFullPrompt] = useState(false);
|
||||||
|
|
||||||
// Check if image_path is a valid local file path (not a URL)
|
// Check if image_path is a valid local file path (not a URL)
|
||||||
@@ -65,8 +67,8 @@ export default function ContentImageCell({ image, maxPromptLength = 100 }: Conte
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Prompt Text */}
|
{/* Prompt Text - Only show if showPrompt is true */}
|
||||||
{prompt && (
|
{showPrompt && prompt && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="text-gray-700 dark:text-gray-300">
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
{displayPrompt}
|
{displayPrompt}
|
||||||
@@ -83,7 +85,10 @@ export default function ContentImageCell({ image, maxPromptLength = 100 }: Conte
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Image Display */}
|
{/* Image Display */}
|
||||||
<div className="relative">
|
<div
|
||||||
|
className={`relative ${onImageClick ? 'cursor-pointer' : ''}`}
|
||||||
|
onClick={onImageClick}
|
||||||
|
>
|
||||||
{image.status === 'pending' && (
|
{image.status === 'pending' && (
|
||||||
<div className="w-full h-24 bg-gray-200 dark:bg-gray-700 rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center">
|
<div className="w-full h-24 bg-gray-200 dark:bg-gray-700 rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ export const createClustersPageConfig = (
|
|||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
...sectorColumn,
|
...sectorColumn,
|
||||||
render: (value: string, row: Cluster) => (
|
render: (value: string, row: Cluster) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
@@ -156,7 +156,6 @@ export const createClustersPageConfig = (
|
|||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (value: number) => {
|
render: (value: number) => {
|
||||||
const difficultyNum = getDifficultyNumber(value);
|
const difficultyNum = getDifficultyNumber(value);
|
||||||
const difficultyBadgeVariant = 'light';
|
|
||||||
const difficultyBadgeColor =
|
const difficultyBadgeColor =
|
||||||
typeof difficultyNum === 'number' && difficultyNum === 1
|
typeof difficultyNum === 'number' && difficultyNum === 1
|
||||||
? 'success'
|
? 'success'
|
||||||
@@ -170,12 +169,8 @@ export const createClustersPageConfig = (
|
|||||||
? 'error'
|
? 'error'
|
||||||
: 'light';
|
: 'light';
|
||||||
return typeof difficultyNum === 'number' ? (
|
return typeof difficultyNum === 'number' ? (
|
||||||
<Badge
|
<Badge color={difficultyBadgeColor} variant="soft" size="xs">
|
||||||
color={difficultyBadgeColor}
|
<span className="text-[11px] font-normal">{difficultyNum}</span>
|
||||||
variant={difficultyBadgeVariant}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{difficultyNum}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
difficultyNum
|
difficultyNum
|
||||||
@@ -194,14 +189,14 @@ export const createClustersPageConfig = (
|
|||||||
...statusColumn,
|
...statusColumn,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
color={value === 'active' ? 'success' : 'warning'}
|
return (
|
||||||
size="sm"
|
<Badge color={value === 'active' ? 'success' : 'warning'} size="xs" variant="soft">
|
||||||
>
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
{value}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...createdColumn,
|
...createdColumn,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const createContentPageConfig = (
|
|||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
setStatusFilter: (value: string) => void;
|
setStatusFilter: (value: string) => void;
|
||||||
setCurrentPage: (page: number) => void;
|
setCurrentPage: (page: number) => void;
|
||||||
onViewContent?: (row: Content) => void;
|
onRowClick?: (row: Content) => void;
|
||||||
}
|
}
|
||||||
): ContentPageConfig => {
|
): ContentPageConfig => {
|
||||||
const showSectorColumn = !handlers.activeSector;
|
const showSectorColumn = !handlers.activeSector;
|
||||||
@@ -99,14 +99,11 @@ export const createContentPageConfig = (
|
|||||||
...titleColumn,
|
...titleColumn,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'title',
|
sortField: 'title',
|
||||||
toggleable: true,
|
|
||||||
toggleContentKey: 'content_html',
|
|
||||||
toggleContentLabel: 'Generated Content',
|
|
||||||
render: (value: string, row: Content) => (
|
render: (value: string, row: Content) => (
|
||||||
<div>
|
<div>
|
||||||
{handlers.onViewContent ? (
|
{handlers.onRowClick ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handlers.onViewContent!(row)}
|
onClick={() => handlers.onRowClick!(row)}
|
||||||
className="font-medium text-blue-500 hover:text-blue-600 hover:underline text-left transition-colors"
|
className="font-medium text-blue-500 hover:text-blue-600 hover:underline text-left transition-colors"
|
||||||
>
|
>
|
||||||
{row.title || `Content #${row.id}`}
|
{row.title || `Content #${row.id}`}
|
||||||
@@ -122,69 +119,84 @@ export const createContentPageConfig = (
|
|||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
...sectorColumn,
|
...sectorColumn,
|
||||||
render: (value: string, row: Content) => (
|
render: (value: string, row: Content) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
{
|
{
|
||||||
key: 'content_type',
|
key: 'content_type',
|
||||||
label: 'Content Type',
|
label: 'Type',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_type',
|
sortField: 'content_type',
|
||||||
width: '120px',
|
width: '110px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="primary" size="sm" variant="light">
|
const label = TYPE_LABELS[value] || value || '-';
|
||||||
{TYPE_LABELS[value] || value || '-'}
|
// Proper case: capitalize first letter only
|
||||||
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge color="blue" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_structure',
|
key: 'content_structure',
|
||||||
label: 'Structure',
|
label: 'Structure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_structure',
|
sortField: 'content_structure',
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = STRUCTURE_LABELS[value] || value || '-';
|
||||||
{STRUCTURE_LABELS[value] || value || '-'}
|
// Proper case: capitalize first letter of each word
|
||||||
|
const properCase = label.split(/[_\s]+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
return (
|
||||||
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cluster_name',
|
key: 'cluster_name',
|
||||||
label: 'Cluster',
|
label: 'Cluster',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (_value: any, row: Content) => {
|
render: (_value: any, row: Content) => {
|
||||||
const clusterName = row.cluster_name;
|
const clusterName = row.cluster_name;
|
||||||
if (!clusterName) {
|
if (!clusterName) {
|
||||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge color="primary" size="sm" variant="light">
|
<Badge color="indigo" size="xs" variant="soft">
|
||||||
{clusterName}
|
<span className="text-[11px] font-normal">{clusterName}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'taxonomy_terms',
|
key: 'taxonomy_terms',
|
||||||
label: 'Taxonomy',
|
label: 'Tags',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
width: '180px',
|
width: '150px',
|
||||||
render: (_value: any, row: Content) => {
|
render: (_value: any, row: Content) => {
|
||||||
const taxonomyTerms = row.taxonomy_terms;
|
const taxonomyTerms = row.taxonomy_terms;
|
||||||
if (!taxonomyTerms || taxonomyTerms.length === 0) {
|
if (!taxonomyTerms || taxonomyTerms.length === 0) {
|
||||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{taxonomyTerms.map((term) => (
|
{taxonomyTerms.slice(0, 2).map((term) => (
|
||||||
<Badge key={term.id} color="purple" size="sm" variant="light">
|
<Badge key={term.id} color="pink" size="xs" variant="soft">
|
||||||
{term.name}
|
<span className="text-[11px] font-normal">{term.name}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{taxonomyTerms.length > 2 && (
|
||||||
|
<span className="text-[11px] text-gray-500">+{taxonomyTerms.length - 2}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -194,15 +206,16 @@ export const createContentPageConfig = (
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const statusColors: Record<string, 'warning' | 'success'> = {
|
const statusColors: Record<string, 'success' | 'amber'> = {
|
||||||
draft: 'warning',
|
draft: 'amber',
|
||||||
published: 'success',
|
published: 'success',
|
||||||
};
|
};
|
||||||
const color = statusColors[value] || 'warning';
|
const color = statusColors[value] || 'amber';
|
||||||
const label = value === 'published' ? 'Published' : 'Draft';
|
// Proper case
|
||||||
|
const label = value ? value.charAt(0).toUpperCase() + value.slice(1) : 'Draft';
|
||||||
return (
|
return (
|
||||||
<Badge color={color} size="sm" variant="light">
|
<Badge color={color} size="xs" variant="soft">
|
||||||
{label}
|
<span className="text-[11px] font-normal">{label}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -213,20 +226,20 @@ export const createContentPageConfig = (
|
|||||||
label: 'Source',
|
label: 'Source',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'source',
|
sortField: 'source',
|
||||||
width: '120px',
|
width: '90px',
|
||||||
render: (value: any, row: Content) => {
|
render: (value: any, row: Content) => {
|
||||||
const source = value || row.source || 'igny8';
|
const source = value || row.source || 'igny8';
|
||||||
const sourceColors: Record<string, 'primary' | 'info'> = {
|
const sourceColors: Record<string, 'teal' | 'cyan'> = {
|
||||||
igny8: 'primary',
|
igny8: 'teal',
|
||||||
wordpress: 'info',
|
wordpress: 'cyan',
|
||||||
};
|
};
|
||||||
const sourceLabels: Record<string, string> = {
|
const sourceLabels: Record<string, string> = {
|
||||||
igny8: 'IGNY8',
|
igny8: 'Igny8',
|
||||||
wordpress: 'WordPress',
|
wordpress: 'Wp',
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Badge color={sourceColors[source] || 'primary'} size="sm" variant="light">
|
<Badge color={sourceColors[source] || 'teal'} size="xs" variant="soft">
|
||||||
{sourceLabels[source] || source}
|
<span className="text-[11px] font-normal">{sourceLabels[source] || source}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ export const createIdeasPageConfig = (
|
|||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
...sectorColumn,
|
...sectorColumn,
|
||||||
render: (value: string, row: ContentIdea) => (
|
render: (value: string, row: ContentIdea) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
@@ -118,24 +118,34 @@ export const createIdeasPageConfig = (
|
|||||||
label: 'Structure',
|
label: 'Structure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_structure',
|
sortField: 'content_structure',
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = value?.replace('_', ' ') || '-';
|
||||||
{value?.replace('_', ' ') || '-'}
|
const properCase = label.split(/[_\s]+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
return (
|
||||||
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_type',
|
key: 'content_type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_type',
|
sortField: 'content_type',
|
||||||
width: '120px',
|
width: '110px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = value?.replace('_', ' ') || '-';
|
||||||
{value?.replace('_', ' ') || '-'}
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge color="blue" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'target_keywords',
|
key: 'target_keywords',
|
||||||
@@ -161,17 +171,15 @@ export const createIdeasPageConfig = (
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const statusColors: Record<string, 'success' | 'warning' | 'error'> = {
|
const statusColors: Record<string, 'success' | 'amber' | 'info'> = {
|
||||||
'new': 'warning',
|
'new': 'amber',
|
||||||
'scheduled': 'info',
|
'scheduled': 'info',
|
||||||
'published': 'success',
|
'published': 'success',
|
||||||
};
|
};
|
||||||
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge color={statusColors[value] || 'amber'} size="xs" variant="soft">
|
||||||
color={statusColors[value] || 'warning'}
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,10 +83,11 @@ export const createImagesPageConfig = (
|
|||||||
key: 'featured_image',
|
key: 'featured_image',
|
||||||
label: 'Featured Image',
|
label: 'Featured Image',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
width: '200px',
|
width: '150px',
|
||||||
render: (_value: any, row: ContentImagesGroup) => (
|
render: (_value: any, row: ContentImagesGroup) => (
|
||||||
<ContentImageCell
|
<ContentImageCell
|
||||||
image={row.featured_image}
|
image={row.featured_image}
|
||||||
|
showPrompt={false}
|
||||||
onImageClick={handlers.onImageClick ? () => handlers.onImageClick!(row.content_id, 'featured') : undefined}
|
onImageClick={handlers.onImageClick ? () => handlers.onImageClick!(row.content_id, 'featured') : undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -99,12 +100,13 @@ export const createImagesPageConfig = (
|
|||||||
key: `in_article_${i}`,
|
key: `in_article_${i}`,
|
||||||
label: `In-Article ${i}`,
|
label: `In-Article ${i}`,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
width: '200px',
|
width: '150px',
|
||||||
render: (_value: any, row: ContentImagesGroup) => {
|
render: (_value: any, row: ContentImagesGroup) => {
|
||||||
const image = row.in_article_images.find(img => img.position === i);
|
const image = row.in_article_images.find(img => img.position === i);
|
||||||
return (
|
return (
|
||||||
<ContentImageCell
|
<ContentImageCell
|
||||||
image={image || null}
|
image={image || null}
|
||||||
|
showPrompt={false}
|
||||||
onImageClick={handlers.onImageClick && image ? () => handlers.onImageClick!(row.content_id, 'in_article', i) : undefined}
|
onImageClick={handlers.onImageClick && image ? () => handlers.onImageClick!(row.content_id, 'in_article', i) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -112,13 +114,13 @@ export const createImagesPageConfig = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add overall status column with Generate Images button
|
// Add status column (separate from generate button)
|
||||||
columns.push({
|
columns.push({
|
||||||
key: 'overall_status',
|
key: 'overall_status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'overall_status',
|
sortField: 'overall_status',
|
||||||
width: '180px',
|
width: '120px',
|
||||||
render: (value: string, row: ContentImagesGroup) => {
|
render: (value: string, row: ContentImagesGroup) => {
|
||||||
const statusColors: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
|
const statusColors: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
'complete': 'success',
|
'complete': 'success',
|
||||||
@@ -133,33 +135,44 @@ export const createImagesPageConfig = (
|
|||||||
'failed': 'Failed',
|
'failed': 'Failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge color={statusColors[value] || 'amber'} size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{labels[value] || value}</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add generate button column (separate from status)
|
||||||
|
columns.push({
|
||||||
|
key: 'generate_action',
|
||||||
|
label: 'Actions',
|
||||||
|
sortable: false,
|
||||||
|
width: '120px',
|
||||||
|
render: (_value: any, row: ContentImagesGroup) => {
|
||||||
// Check if there are any pending images with prompts
|
// Check if there are any pending images with prompts
|
||||||
const hasPendingImages =
|
const hasPendingImages =
|
||||||
(row.featured_image?.status === 'pending' && row.featured_image?.prompt) ||
|
(row.featured_image?.status === 'pending' && row.featured_image?.prompt) ||
|
||||||
row.in_article_images.some(img => img.status === 'pending' && img.prompt);
|
row.in_article_images.some(img => img.status === 'pending' && img.prompt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Badge
|
{hasPendingImages && handlers.onGenerateImages ? (
|
||||||
color={statusColors[value] || 'warning'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{labels[value] || value}
|
|
||||||
</Badge>
|
|
||||||
{hasPendingImages && handlers.onGenerateImages && (
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlers.onGenerateImages!(row.content_id);
|
handlers.onGenerateImages!(row.content_id);
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-white bg-brand-500 hover:bg-brand-600 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 rounded transition-colors"
|
||||||
title="Generate Images"
|
title="Generate Images"
|
||||||
>
|
>
|
||||||
<BoltIcon className="w-3 h-3" />
|
<BoltIcon className="w-4 h-4" />
|
||||||
Generate
|
Generate
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500 text-sm">-</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ export const createKeywordsPageConfig = (
|
|||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
...sectorColumn,
|
...sectorColumn,
|
||||||
render: (value: string, row: Keyword) => (
|
render: (value: string, row: Keyword) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
@@ -175,26 +175,21 @@ export const createKeywordsPageConfig = (
|
|||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (value: number) => {
|
render: (value: number) => {
|
||||||
const difficultyNum = getDifficultyNumber(value);
|
const difficultyNum = getDifficultyNumber(value);
|
||||||
const difficultyBadgeVariant = 'light';
|
|
||||||
const difficultyBadgeColor =
|
const difficultyBadgeColor =
|
||||||
typeof difficultyNum === 'number' && difficultyNum === 1
|
typeof difficultyNum === 'number' && difficultyNum === 1
|
||||||
? 'success'
|
? 'success'
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 2
|
: typeof difficultyNum === 'number' && difficultyNum === 2
|
||||||
? 'success'
|
? 'success'
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 3
|
: typeof difficultyNum === 'number' && difficultyNum === 3
|
||||||
? 'warning'
|
? 'amber'
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 4
|
: typeof difficultyNum === 'number' && difficultyNum === 4
|
||||||
? 'error'
|
? 'error'
|
||||||
: typeof difficultyNum === 'number' && difficultyNum === 5
|
: typeof difficultyNum === 'number' && difficultyNum === 5
|
||||||
? 'error'
|
? 'error'
|
||||||
: 'light';
|
: 'gray';
|
||||||
return typeof difficultyNum === 'number' ? (
|
return typeof difficultyNum === 'number' ? (
|
||||||
<Badge
|
<Badge color={difficultyBadgeColor} variant="soft" size="xs">
|
||||||
color={difficultyBadgeColor}
|
<span className="text-[11px] font-normal">{difficultyNum}</span>
|
||||||
variant={difficultyBadgeVariant}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{difficultyNum}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
difficultyNum
|
difficultyNum
|
||||||
@@ -220,13 +215,10 @@ export const createKeywordsPageConfig = (
|
|||||||
return 'info'; // Blue for informational or default
|
return 'info'; // Blue for informational or default
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge color={getIntentColor(value)} size="xs" variant="soft">
|
||||||
color={getIntentColor(value)}
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
size="sm"
|
|
||||||
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -236,18 +228,20 @@ export const createKeywordsPageConfig = (
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={
|
color={
|
||||||
value === 'active'
|
value === 'active'
|
||||||
? 'success'
|
? 'success'
|
||||||
: value === 'pending'
|
: value === 'pending'
|
||||||
? 'warning'
|
? 'amber'
|
||||||
: 'error'
|
: 'error'
|
||||||
}
|
}
|
||||||
size="sm"
|
size="xs"
|
||||||
|
variant="soft"
|
||||||
>
|
>
|
||||||
{value}
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ export function createPublishedPageConfig(params: {
|
|||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
width: '120px',
|
width: '120px',
|
||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const statusConfig: Record<string, { color: 'warning' | 'success'; label: string }> = {
|
const statusConfig: Record<string, { color: 'amber' | 'success'; label: string }> = {
|
||||||
draft: { color: 'warning', label: 'Draft' },
|
draft: { color: 'amber', label: 'Draft' },
|
||||||
published: { color: 'success', label: 'Published' },
|
published: { color: 'success', label: 'Published' },
|
||||||
};
|
};
|
||||||
const config = statusConfig[value] || { color: 'warning' as const, label: value };
|
const config = statusConfig[value] || { color: 'amber' as const, label: value };
|
||||||
return (
|
return (
|
||||||
<Badge color={config.color} size="sm" variant="light">
|
<Badge color={config.color} size="xs" variant="soft">
|
||||||
{config.label}
|
<span className="text-[11px] font-normal">{config.label}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -112,15 +112,15 @@ export function createPublishedPageConfig(params: {
|
|||||||
render: (_value: any, row: Content) => {
|
render: (_value: any, row: Content) => {
|
||||||
if (row.external_id && row.external_url) {
|
if (row.external_id && row.external_url) {
|
||||||
return (
|
return (
|
||||||
<Badge color="success" size="sm" variant="light">
|
<Badge color="success" size="xs" variant="soft">
|
||||||
<CheckCircleIcon className="w-3 h-3 mr-1" />
|
<CheckCircleIcon className="w-3 h-3 mr-1" />
|
||||||
Published
|
<span className="text-[11px] font-normal">Published</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge color="warning" size="sm" variant="light">
|
<Badge color="amber" size="xs" variant="soft">
|
||||||
Not Published
|
<span className="text-[11px] font-normal">Not Published</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -130,24 +130,34 @@ export function createPublishedPageConfig(params: {
|
|||||||
label: 'Type',
|
label: 'Type',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_type',
|
sortField: 'content_type',
|
||||||
width: '120px',
|
width: '110px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="primary" size="sm" variant="light">
|
const label = TYPE_LABELS[value] || value || '-';
|
||||||
{TYPE_LABELS[value] || value || '-'}
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge color="blue" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_structure',
|
key: 'content_structure',
|
||||||
label: 'Structure',
|
label: 'Structure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_structure',
|
sortField: 'content_structure',
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = STRUCTURE_LABELS[value] || value || '-';
|
||||||
{STRUCTURE_LABELS[value] || value || '-'}
|
const properCase = label.split(/[_\s]+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
return (
|
||||||
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'word_count',
|
key: 'word_count',
|
||||||
|
|||||||
@@ -52,10 +52,33 @@ export function createReviewPageConfig(params: {
|
|||||||
setStatusFilter: (value: string) => void;
|
setStatusFilter: (value: string) => void;
|
||||||
setCurrentPage: (page: number) => void;
|
setCurrentPage: (page: number) => void;
|
||||||
activeSector: { id: number; name: string } | null;
|
activeSector: { id: number; name: string } | null;
|
||||||
|
onRowClick?: (row: Content) => void;
|
||||||
}): ReviewPageConfig {
|
}): ReviewPageConfig {
|
||||||
const showSectorColumn = !params.activeSector;
|
const showSectorColumn = !params.activeSector;
|
||||||
|
|
||||||
const columns: ColumnConfig[] = [
|
const columns: ColumnConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'title',
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{params.onRowClick ? (
|
||||||
|
<button
|
||||||
|
onClick={() => params.onRowClick!(row)}
|
||||||
|
className="font-medium text-blue-500 hover:text-blue-600 hover:underline text-left transition-colors"
|
||||||
|
>
|
||||||
|
{value || `Content #${row.id}`}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{value || `Content #${row.id}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'categories',
|
key: 'categories',
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
@@ -73,7 +96,7 @@ export function createReviewPageConfig(params: {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
toggleable: true,
|
toggleable: true,
|
||||||
defaultVisible: false,
|
defaultVisible: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tags',
|
key: 'tags',
|
||||||
@@ -92,47 +115,41 @@ export function createReviewPageConfig(params: {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
toggleable: true,
|
toggleable: true,
|
||||||
defaultVisible: false,
|
defaultVisible: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'title',
|
|
||||||
label: 'Title',
|
|
||||||
sortable: true,
|
|
||||||
sortField: 'title',
|
|
||||||
toggleable: true,
|
|
||||||
toggleContentKey: 'content_html',
|
|
||||||
toggleContentLabel: 'Generated Content',
|
|
||||||
render: (value: string, row: Content) => (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{value || `Content #${row.id}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_type',
|
key: 'content_type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_type',
|
sortField: 'content_type',
|
||||||
width: '120px',
|
width: '110px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="primary" size="sm" variant="light">
|
const label = TYPE_LABELS[value] || value || '-';
|
||||||
{TYPE_LABELS[value] || value || '-'}
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge color="blue" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_structure',
|
key: 'content_structure',
|
||||||
label: 'Structure',
|
label: 'Structure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_structure',
|
sortField: 'content_structure',
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = STRUCTURE_LABELS[value] || value || '-';
|
||||||
{STRUCTURE_LABELS[value] || value || '-'}
|
const properCase = label.split(/[_\s]+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
return (
|
||||||
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cluster_name',
|
key: 'cluster_name',
|
||||||
@@ -145,8 +162,8 @@ export function createReviewPageConfig(params: {
|
|||||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge color="primary" size="sm" variant="light">
|
<Badge color="indigo" size="xs" variant="soft">
|
||||||
{clusterName}
|
<span className="text-[11px] font-normal">{clusterName}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -186,8 +203,8 @@ export function createReviewPageConfig(params: {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
width: '120px',
|
width: '120px',
|
||||||
render: (value: string, row: Content) => (
|
render: (value: string, row: Content) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export const createTasksPageConfig = (
|
|||||||
{displayTitle}
|
{displayTitle}
|
||||||
</span>
|
</span>
|
||||||
{isSiteBuilder && (
|
{isSiteBuilder && (
|
||||||
<Badge color="purple" size="sm" variant="light">
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
Site Builder
|
<span className="text-[11px] font-normal">Site Builder</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,8 +128,8 @@ export const createTasksPageConfig = (
|
|||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
...sectorColumn,
|
...sectorColumn,
|
||||||
render: (value: string, row: Task) => (
|
render: (value: string, row: Task) => (
|
||||||
<Badge color="info" size="sm" variant="light">
|
<Badge color="info" size="xs" variant="soft">
|
||||||
{row.sector_name || '-'}
|
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
@@ -153,53 +153,60 @@ export const createTasksPageConfig = (
|
|||||||
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge color="purple" size="sm" variant="light">
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
{taxonomyName}
|
<span className="text-[11px] font-normal">{taxonomyName}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_type',
|
key: 'content_type',
|
||||||
label: 'Content Type',
|
label: 'Type',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_type',
|
sortField: 'content_type',
|
||||||
width: '120px',
|
width: '110px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="primary" size="sm" variant="light">
|
const label = TYPE_LABELS[value] || value || '-';
|
||||||
{TYPE_LABELS[value] || value || '-'}
|
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
return (
|
||||||
|
<Badge color="blue" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'content_structure',
|
key: 'content_structure',
|
||||||
label: 'Structure',
|
label: 'Structure',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'content_structure',
|
sortField: 'content_structure',
|
||||||
width: '150px',
|
width: '130px',
|
||||||
render: (value: string) => (
|
render: (value: string) => {
|
||||||
<Badge color="info" size="sm" variant="light">
|
const label = STRUCTURE_LABELS[value] || value || '-';
|
||||||
{STRUCTURE_LABELS[value] || value || '-'}
|
const properCase = label.split(/[_\s]+/).map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
return (
|
||||||
|
<Badge color="purple" size="xs" variant="soft">
|
||||||
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...statusColumn,
|
...statusColumn,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'status',
|
sortField: 'status',
|
||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const statusColors: Record<string, 'success' | 'warning'> = {
|
const statusColors: Record<string, 'success' | 'amber'> = {
|
||||||
queued: 'warning',
|
queued: 'amber',
|
||||||
completed: 'success',
|
completed: 'success',
|
||||||
};
|
};
|
||||||
const label = value ? value.replace('_', ' ') : '';
|
const label = value ? value.replace('_', ' ') : '';
|
||||||
const formatted = label ? label.charAt(0).toUpperCase() + label.slice(1) : '';
|
const formatted = label ? label.charAt(0).toUpperCase() + label.slice(1) : '';
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge color={statusColors[value] || 'amber'} size="xs" variant="soft">
|
||||||
color={statusColors[value] || 'warning'}
|
<span className="text-[11px] font-normal">{formatted}</span>
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{formatted}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
71
frontend/src/hooks/useColumnPreferences.ts
Normal file
71
frontend/src/hooks/useColumnPreferences.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* useColumnPreferences Hook
|
||||||
|
* Manages column visibility persistence in localStorage per page
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UseColumnPreferencesOptions {
|
||||||
|
storageKey: string; // Unique key for each page (e.g., 'writer_tasks_columns')
|
||||||
|
defaultColumns: string[]; // Default visible column keys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useColumnPreferences({ storageKey, defaultColumns }: UseColumnPreferencesOptions) {
|
||||||
|
// Initialize visible columns from localStorage or defaults
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load column preferences from localStorage:', error);
|
||||||
|
}
|
||||||
|
return defaultColumns;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save to localStorage whenever visibleColumns changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(visibleColumns));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save column preferences to localStorage:', error);
|
||||||
|
}
|
||||||
|
}, [storageKey, visibleColumns]);
|
||||||
|
|
||||||
|
// Toggle column visibility
|
||||||
|
const toggleColumn = useCallback((columnKey: string) => {
|
||||||
|
setVisibleColumns((prev) => {
|
||||||
|
if (prev.includes(columnKey)) {
|
||||||
|
// Don't allow hiding the last column
|
||||||
|
if (prev.length === 1) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev.filter((key) => key !== columnKey);
|
||||||
|
} else {
|
||||||
|
return [...prev, columnKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if a column is visible
|
||||||
|
const isColumnVisible = useCallback(
|
||||||
|
(columnKey: string) => visibleColumns.includes(columnKey),
|
||||||
|
[visibleColumns]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset to defaults
|
||||||
|
const resetToDefaults = useCallback(() => {
|
||||||
|
setVisibleColumns(defaultColumns);
|
||||||
|
}, [defaultColumns]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleColumns,
|
||||||
|
toggleColumn,
|
||||||
|
isColumnVisible,
|
||||||
|
resetToDefaults,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -130,17 +130,10 @@ const AppSidebar: React.FC = () => {
|
|||||||
workflowItems.push({
|
workflowItems.push({
|
||||||
icon: <TaskIcon />,
|
icon: <TaskIcon />,
|
||||||
name: "Writer",
|
name: "Writer",
|
||||||
path: "/writer/content", // Default to content, submenus shown as in-page navigation
|
path: "/writer/tasks", // Default to tasks, submenus shown as in-page navigation
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Content Manager (always enabled - single item, no dropdown)
|
|
||||||
workflowItems.push({
|
|
||||||
icon: <FileIcon />,
|
|
||||||
name: "Content Manager",
|
|
||||||
path: "/content-manager", // Default to all content, submenus shown as in-page navigation
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Linker if enabled (single item, no dropdown)
|
// Add Linker if enabled (single item, no dropdown)
|
||||||
if (moduleEnabled('linker')) {
|
if (moduleEnabled('linker')) {
|
||||||
workflowItems.push({
|
workflowItems.push({
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
/**
|
|
||||||
* Content Manager Module - Main Dashboard
|
|
||||||
* Full-featured CMS with site selector, standard filtering, and WYSIWYG editing
|
|
||||||
*/
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
|
||||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
import Button from '../../components/ui/button/Button';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { fetchAPI } from '../../services/api';
|
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
|
||||||
import {
|
|
||||||
PencilIcon,
|
|
||||||
EyeIcon,
|
|
||||||
TrashBinIcon,
|
|
||||||
PlusIcon,
|
|
||||||
FileIcon
|
|
||||||
} from '../../icons';
|
|
||||||
import { Search, Filter } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ContentItem {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
updated_at: string;
|
|
||||||
source: string;
|
|
||||||
content_type?: string;
|
|
||||||
content_structure?: string;
|
|
||||||
cluster_name?: string;
|
|
||||||
external_url?: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: '', label: 'All Statuses' },
|
|
||||||
{ value: 'draft', label: 'Draft' },
|
|
||||||
{ value: 'published', label: 'Published' },
|
|
||||||
{ value: 'scheduled', label: 'Scheduled' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SOURCE_OPTIONS = [
|
|
||||||
{ value: '', label: 'All Sources' },
|
|
||||||
{ value: 'igny8', label: 'IGNY8 Generated' },
|
|
||||||
{ value: 'wordpress', label: 'WordPress' },
|
|
||||||
{ value: 'shopify', label: 'Shopify' },
|
|
||||||
{ value: 'custom', label: 'Custom API' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CONTENT_TYPE_OPTIONS = [
|
|
||||||
{ value: '', label: 'All Types' },
|
|
||||||
{ value: 'post', label: 'Blog Post' },
|
|
||||||
{ value: 'page', label: 'Page' },
|
|
||||||
{ value: 'product', label: 'Product' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Content type icon and color mapping
|
|
||||||
const getContentTypeStyle = (contentType?: string) => {
|
|
||||||
switch (contentType?.toLowerCase()) {
|
|
||||||
case 'post':
|
|
||||||
return {
|
|
||||||
icon: '📝',
|
|
||||||
color: 'text-blue-600 dark:text-blue-400',
|
|
||||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
|
||||||
};
|
|
||||||
case 'page':
|
|
||||||
return {
|
|
||||||
icon: '📄',
|
|
||||||
color: 'text-green-600 dark:text-green-400',
|
|
||||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
|
||||||
};
|
|
||||||
case 'product':
|
|
||||||
return {
|
|
||||||
icon: '🛍️',
|
|
||||||
color: 'text-purple-600 dark:text-purple-400',
|
|
||||||
bgColor: 'bg-purple-100 dark:bg-purple-900/30',
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
icon: '📋',
|
|
||||||
color: 'text-gray-600 dark:text-gray-400',
|
|
||||||
bgColor: 'bg-gray-100 dark:bg-gray-900/30',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Status badge styling
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
switch (status?.toLowerCase()) {
|
|
||||||
case 'published':
|
|
||||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
|
||||||
case 'draft':
|
|
||||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
|
||||||
case 'scheduled':
|
|
||||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ContentManagerDashboard() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const toast = useToast();
|
|
||||||
const { activeSite } = useSiteStore();
|
|
||||||
const { activeSector } = useSectorStore();
|
|
||||||
|
|
||||||
const [content, setContent] = useState<ContentItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [sourceFilter, setSourceFilter] = useState('');
|
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
|
||||||
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'title'>('created_at');
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
const pageSize = 20;
|
|
||||||
|
|
||||||
// Navigation tabs for Content Manager module
|
|
||||||
const navigationTabs = [
|
|
||||||
{ id: 'content', label: 'All Content', path: '/content-manager' },
|
|
||||||
{ id: 'posts', label: 'Posts', path: '/content-manager/posts' },
|
|
||||||
{ id: 'pages', label: 'Pages', path: '/content-manager/pages' },
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeSite?.id) {
|
|
||||||
loadContent();
|
|
||||||
}
|
|
||||||
}, [activeSite, currentPage, statusFilter, sourceFilter, contentTypeFilter, searchTerm, sortBy, sortDirection]);
|
|
||||||
|
|
||||||
const loadContent = async () => {
|
|
||||||
if (!activeSite?.id) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
site_id: activeSite.id.toString(),
|
|
||||||
page: currentPage.toString(),
|
|
||||||
page_size: pageSize.toString(),
|
|
||||||
ordering: sortDirection === 'desc' ? `-${sortBy}` : sortBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
params.append('search', searchTerm);
|
|
||||||
}
|
|
||||||
if (statusFilter) {
|
|
||||||
params.append('status', statusFilter);
|
|
||||||
}
|
|
||||||
if (sourceFilter) {
|
|
||||||
params.append('source', sourceFilter);
|
|
||||||
}
|
|
||||||
if (contentTypeFilter) {
|
|
||||||
params.append('content_type', contentTypeFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchAPI(`/v1/writer/content/?${params.toString()}`);
|
|
||||||
const contentList = Array.isArray(data?.results) ? data.results : Array.isArray(data) ? data : [];
|
|
||||||
setContent(contentList);
|
|
||||||
setTotalCount(data?.count || contentList.length);
|
|
||||||
setTotalPages(data?.total_pages || Math.ceil((data?.count || contentList.length) / pageSize));
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to load content: ${error.message}`);
|
|
||||||
setContent([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this content?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetchAPI(`/v1/writer/content/${id}/`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
toast.success('Content deleted successfully');
|
|
||||||
loadContent();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to delete content: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
|
||||||
setSearchTerm('');
|
|
||||||
setStatusFilter('');
|
|
||||||
setSourceFilter('');
|
|
||||||
setContentTypeFilter('');
|
|
||||||
setSortBy('created_at');
|
|
||||||
setSortDirection('desc');
|
|
||||||
setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveFilters = searchTerm || statusFilter || sourceFilter || contentTypeFilter;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<PageMeta title="Content Manager - IGNY8" description="Manage all your content in one place" />
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
title="Content Manager"
|
|
||||||
badge={{ icon: <FileIcon />, color: 'purple' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModuleNavigationTabs tabs={navigationTabs} />
|
|
||||||
|
|
||||||
{/* Action Bar */}
|
|
||||||
<div className="mb-6 flex justify-between items-center">
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{totalCount} total items
|
|
||||||
{activeSite && <span className="ml-2">• {activeSite.name}</span>}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => activeSite ? navigate(`/sites/${activeSite.id}/posts/new`) : toast.error('Please select a site first')}
|
|
||||||
variant="primary"
|
|
||||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
|
||||||
disabled={!activeSite}
|
|
||||||
>
|
|
||||||
New Content
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Standard Filter Bar */}
|
|
||||||
<Card className="p-4 mb-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Search and Primary Filters */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search content..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchTerm(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatusFilter(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Content Type Filter */}
|
|
||||||
<select
|
|
||||||
value={contentTypeFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setContentTypeFilter(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Source Filter */}
|
|
||||||
<select
|
|
||||||
value={sourceFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSourceFilter(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
{SOURCE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Sort */}
|
|
||||||
<select
|
|
||||||
value={`${sortBy}-${sortDirection}`}
|
|
||||||
onChange={(e) => {
|
|
||||||
const [field, direction] = e.target.value.split('-');
|
|
||||||
setSortBy(field as typeof sortBy);
|
|
||||||
setSortDirection(direction as 'asc' | 'desc');
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg dark:bg-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="created_at-desc">Newest First</option>
|
|
||||||
<option value="created_at-asc">Oldest First</option>
|
|
||||||
<option value="updated_at-desc">Recently Updated</option>
|
|
||||||
<option value="title-asc">Title A-Z</option>
|
|
||||||
<option value="title-desc">Title Z-A</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
|
||||||
<Filter className="w-4 h-4 mr-2" />
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* No Site Selected Warning */}
|
|
||||||
{!activeSite && (
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<div className="text-gray-500 dark:text-gray-400">
|
|
||||||
<FileIcon className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
|
||||||
<p className="text-lg font-medium mb-2">No Site Selected</p>
|
|
||||||
<p className="text-sm">Please select a site from the dropdown above to manage content.</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content List */}
|
|
||||||
{activeSite && (
|
|
||||||
<>
|
|
||||||
{loading ? (
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<div className="text-gray-500">Loading content...</div>
|
|
||||||
</Card>
|
|
||||||
) : content.length === 0 ? (
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
{hasActiveFilters ? 'No content matches your filters' : 'No content found'}
|
|
||||||
</p>
|
|
||||||
{!hasActiveFilters && (
|
|
||||||
<Button onClick={() => activeSite ? navigate(`/sites/${activeSite.id}/posts/new`) : toast.error('Please select a site first')} variant="primary" disabled={!activeSite}>
|
|
||||||
Create Your First Content
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{content.map((item) => {
|
|
||||||
const typeStyle = getContentTypeStyle(item.content_type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
{/* Content Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
{/* Content Type Icon */}
|
|
||||||
<span className={`text-2xl ${typeStyle.bgColor} px-2 py-1 rounded`}>
|
|
||||||
{typeStyle.icon}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
|
||||||
{item.title || `Content #${item.id}`}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getStatusBadge(item.status)}`}>
|
|
||||||
{item.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Meta Information */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{item.content_type && (
|
|
||||||
<span className={`${typeStyle.color} font-medium`}>
|
|
||||||
{item.content_type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.content_structure && (
|
|
||||||
<span>{item.content_structure}</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-gray-400"></span>
|
|
||||||
{item.source}
|
|
||||||
</span>
|
|
||||||
{item.cluster_name && (
|
|
||||||
<span>Cluster: {item.cluster_name}</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
Updated {new Date(item.updated_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-2 flex-shrink-0">
|
|
||||||
{/* View - Opens content detail view in Writer */}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/writer/content/${item.id}`)}
|
|
||||||
className="p-2 rounded-lg text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/20 transition-colors"
|
|
||||||
aria-label="View content"
|
|
||||||
>
|
|
||||||
<EyeIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{/* Edit - Opens post editor */}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/sites/${activeSite?.id}/posts/${item.id}/edit`)}
|
|
||||||
className="p-2 rounded-lg text-green-600 hover:text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:text-green-300 dark:hover:bg-green-900/20 transition-colors"
|
|
||||||
aria-label="Edit content"
|
|
||||||
>
|
|
||||||
<PencilIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
className="p-2 rounded-lg text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/20 transition-colors"
|
|
||||||
aria-label="Delete content"
|
|
||||||
>
|
|
||||||
<TrashBinIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="mt-6 flex justify-center items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Page {currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -458,45 +458,6 @@ export default function DebugStatus() {
|
|||||||
return checks;
|
return checks;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Check Content Manager module health (taxonomy relations)
|
|
||||||
const checkContentManagerModule = useCallback(async (): Promise<HealthCheck[]> => {
|
|
||||||
const checks: HealthCheck[] = [];
|
|
||||||
|
|
||||||
// Check Taxonomy endpoint
|
|
||||||
try {
|
|
||||||
const { response: taxonomyResp, data: taxonomyData } = await apiCall('/v1/writer/taxonomy/');
|
|
||||||
|
|
||||||
if (taxonomyResp.ok && taxonomyData?.success !== false) {
|
|
||||||
checks.push({
|
|
||||||
name: 'Taxonomy System',
|
|
||||||
description: 'Content taxonomy endpoint',
|
|
||||||
status: 'healthy',
|
|
||||||
message: `Found ${taxonomyData?.count || 0} taxonomy items`,
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
name: 'Taxonomy System',
|
|
||||||
description: 'Content taxonomy endpoint',
|
|
||||||
status: 'error',
|
|
||||||
message: taxonomyData?.error || `Failed with ${taxonomyResp.status}`,
|
|
||||||
details: 'Check ContentTaxonomyRelation through model and field mappings',
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
checks.push({
|
|
||||||
name: 'Taxonomy System',
|
|
||||||
description: 'Content taxonomy endpoint',
|
|
||||||
status: 'error',
|
|
||||||
message: error.message || 'Network error',
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Run all health checks
|
// Run all health checks
|
||||||
const runAllChecks = useCallback(async () => {
|
const runAllChecks = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -506,12 +467,11 @@ export default function DebugStatus() {
|
|||||||
const schemaCheck = await checkDatabaseSchemaMapping();
|
const schemaCheck = await checkDatabaseSchemaMapping();
|
||||||
|
|
||||||
// Run module checks in parallel
|
// Run module checks in parallel
|
||||||
const [writerChecks, plannerChecks, sitesChecks, integrationChecks, contentMgrChecks] = await Promise.all([
|
const [writerChecks, plannerChecks, sitesChecks, integrationChecks] = await Promise.all([
|
||||||
checkWriterModule(),
|
checkWriterModule(),
|
||||||
checkPlannerModule(),
|
checkPlannerModule(),
|
||||||
checkSitesModule(),
|
checkSitesModule(),
|
||||||
checkIntegrationModule(),
|
checkIntegrationModule(),
|
||||||
checkContentManagerModule(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build module health results
|
// Build module health results
|
||||||
@@ -541,11 +501,6 @@ export default function DebugStatus() {
|
|||||||
description: 'External platform sync (WordPress, etc.)',
|
description: 'External platform sync (WordPress, etc.)',
|
||||||
checks: integrationChecks,
|
checks: integrationChecks,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
module: 'Content Manager',
|
|
||||||
description: 'Taxonomy and content organization',
|
|
||||||
checks: contentMgrChecks,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
setModuleHealths(moduleHealthResults);
|
setModuleHealths(moduleHealthResults);
|
||||||
@@ -560,7 +515,6 @@ export default function DebugStatus() {
|
|||||||
checkPlannerModule,
|
checkPlannerModule,
|
||||||
checkSitesModule,
|
checkSitesModule,
|
||||||
checkIntegrationModule,
|
checkIntegrationModule,
|
||||||
checkContentManagerModule,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Run checks on mount
|
// Run checks on mount
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { Card } from '../../components/ui/card';
|
|||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import Checkbox from '../../components/form/input/Checkbox';
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
import Label from '../../components/form/Label';
|
import Label from '../../components/form/Label';
|
||||||
import Input from '../../components/form/input/Input';
|
import Input from '../../components/form/input/InputField';
|
||||||
import Select from '../../components/form/input/Select';
|
import Select from '../../components/form/SelectDropdown';
|
||||||
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI } from '../../services/api';
|
import { fetchAPI } from '../../services/api';
|
||||||
|
|||||||
@@ -202,13 +202,6 @@ export default function SiteDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/sites/${siteId}/preview`)}
|
|
||||||
startIcon={<EyeIcon className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => navigate(`/sites/${siteId}/settings`)}
|
onClick={() => navigate(`/sites/${siteId}/settings`)}
|
||||||
|
|||||||
@@ -382,12 +382,16 @@ export default function SiteList() {
|
|||||||
{filteredSites.map((site) => (
|
{filteredSites.map((site) => (
|
||||||
<Card key={site.id} className="rounded-xl border-2 border-slate-200 bg-white dark:border-gray-800 dark:bg-white/3 hover:border-[var(--color-primary)] hover:shadow-lg transition-all">
|
<Card key={site.id} className="rounded-xl border-2 border-slate-200 bg-white dark:border-gray-800 dark:bg-white/3 hover:border-[var(--color-primary)] hover:shadow-lg transition-all">
|
||||||
<div className="relative p-4 pb-6">
|
<div className="relative p-4 pb-6">
|
||||||
<div className="mb-5 size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<GridIcon className="h-6 w-6" />
|
<div className="size-6 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md flex-shrink-0">
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||||
{site.name}
|
{site.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
|
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{site.description || 'No description'}
|
{site.description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* Site Preview
|
|
||||||
* Phase 7: Advanced Site Management
|
|
||||||
* Features: Live iframe preview of deployed site
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { RefreshCwIcon, ExternalLinkIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
import Button from '../../components/ui/button/Button';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { fetchAPI } from '../../services/api';
|
|
||||||
|
|
||||||
export default function SitePreview() {
|
|
||||||
const { id: siteId } = useParams<{ id: string }>();
|
|
||||||
const toast = useToast();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const [blueprint, setBlueprint] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (siteId) {
|
|
||||||
loadPreviewData();
|
|
||||||
}
|
|
||||||
}, [siteId]);
|
|
||||||
|
|
||||||
const loadPreviewData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
// Get the latest blueprint for this site
|
|
||||||
const blueprintsData = await fetchAPI(`/v1/site-builder/blueprints/?site=${siteId}`);
|
|
||||||
const blueprints = Array.isArray(blueprintsData?.results) ? blueprintsData.results : Array.isArray(blueprintsData) ? blueprintsData : [];
|
|
||||||
|
|
||||||
if (blueprints.length > 0) {
|
|
||||||
const latestBlueprint = blueprints[0];
|
|
||||||
setBlueprint(latestBlueprint);
|
|
||||||
|
|
||||||
// Get deployment record to find preview URL
|
|
||||||
if (latestBlueprint.deployed_version || latestBlueprint.status === 'deployed') {
|
|
||||||
try {
|
|
||||||
// Get deployment records for this blueprint
|
|
||||||
const deploymentsData = await fetchAPI(`/v1/publisher/deployments/?site_blueprint=${latestBlueprint.id}`);
|
|
||||||
const deployments = Array.isArray(deploymentsData?.results) ? deploymentsData.results : Array.isArray(deploymentsData) ? deploymentsData : [];
|
|
||||||
|
|
||||||
// Find the latest deployed record
|
|
||||||
const deployedRecord = deployments.find((d: any) => d.status === 'deployed') || deployments[0];
|
|
||||||
|
|
||||||
if (deployedRecord?.deployment_url) {
|
|
||||||
setPreviewUrl(deployedRecord.deployment_url);
|
|
||||||
} else if (deployedRecord) {
|
|
||||||
// If deployment exists but no URL, construct from Sites Renderer
|
|
||||||
// Sites Renderer should be accessible at a different port or subdomain
|
|
||||||
// Check if we have the Sites Renderer URL configured
|
|
||||||
// Use VPS IP or configured URL for Sites Renderer
|
|
||||||
const sitesRendererUrl = import.meta.env.VITE_SITES_RENDERER_URL ||
|
|
||||||
(window as any).__SITES_RENDERER_URL__ ||
|
|
||||||
'http://31.97.144.105:8024';
|
|
||||||
setPreviewUrl(`${sitesRendererUrl}/${siteId}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('No deployment record found:', error);
|
|
||||||
// If blueprint is deployed but no deployment record, try Sites Renderer directly
|
|
||||||
if (latestBlueprint.status === 'deployed') {
|
|
||||||
// Use VPS IP or configured URL for Sites Renderer
|
|
||||||
const sitesRendererUrl = import.meta.env.VITE_SITES_RENDERER_URL ||
|
|
||||||
(window as any).__SITES_RENDERER_URL__ ||
|
|
||||||
'http://31.97.144.105:8024';
|
|
||||||
setPreviewUrl(`${sitesRendererUrl}/${siteId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still no preview URL, check blueprint status
|
|
||||||
if (!previewUrl) {
|
|
||||||
if (latestBlueprint.status === 'ready' || latestBlueprint.status === 'generating' || latestBlueprint.status === 'draft') {
|
|
||||||
// Blueprint exists but not deployed yet - don't set preview URL
|
|
||||||
setPreviewUrl(null);
|
|
||||||
} else if (latestBlueprint.status === 'deployed') {
|
|
||||||
// Blueprint is deployed but no deployment record found - try Sites Renderer
|
|
||||||
const sitesRendererUrl = import.meta.env.VITE_SITES_RENDERER_URL ||
|
|
||||||
(window as any).__SITES_RENDERER_URL__ ||
|
|
||||||
'http://localhost:8024';
|
|
||||||
setPreviewUrl(`${sitesRendererUrl}/${siteId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to load preview: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
if (previewUrl) {
|
|
||||||
const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
|
|
||||||
if (iframe) {
|
|
||||||
iframe.src = iframe.src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenInNewTab = () => {
|
|
||||||
if (previewUrl) {
|
|
||||||
window.open(previewUrl, '_blank');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<PageMeta title="Site Preview" />
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-gray-500">Loading preview...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!previewUrl) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<PageMeta title="Site Preview - IGNY8" />
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Site Preview
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Preview your deployed site
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
{blueprint?.status === 'ready' || blueprint?.status === 'generating'
|
|
||||||
? 'Blueprint is ready but not yet deployed. Deploy your site to preview it.'
|
|
||||||
: blueprint?.status === 'draft'
|
|
||||||
? 'Blueprint is still in draft. Complete the wizard to generate the site structure.'
|
|
||||||
: 'No preview available. Please deploy your site first.'}
|
|
||||||
</p>
|
|
||||||
{blueprint && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
|
||||||
Blueprint: {blueprint.name} ({blueprint.status})
|
|
||||||
</p>
|
|
||||||
{blueprint.status === 'ready' && (
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
tone="brand"
|
|
||||||
onClick={() => {
|
|
||||||
// Navigate to blueprints page or show deploy option
|
|
||||||
window.location.href = `/sites/blueprints`;
|
|
||||||
}}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
Go to Blueprints to Deploy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`p-6 ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-gray-900 p-0' : ''}`}>
|
|
||||||
<PageMeta title="Site Preview - IGNY8" />
|
|
||||||
|
|
||||||
{!isFullscreen && (
|
|
||||||
<div className="mb-6 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Site Preview
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Live preview of your deployed site
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleRefresh} startIcon={<RefreshCwIcon className="w-4 h-4" />}>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleOpenInNewTab} startIcon={<ExternalLinkIcon className="w-4 h-4" />}>
|
|
||||||
Open in New Tab
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => setIsFullscreen(true)} startIcon={<Maximize2Icon className="w-4 h-4" />}>
|
|
||||||
Fullscreen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFullscreen && (
|
|
||||||
<div className="absolute top-4 right-4 z-10">
|
|
||||||
<Button variant="outline" onClick={() => setIsFullscreen(false)} startIcon={<Minimize2Icon className="w-4 h-4" />}>
|
|
||||||
Exit Fullscreen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className={`${isFullscreen ? 'h-full m-0 rounded-none' : ''} overflow-hidden`}>
|
|
||||||
<div className={`relative ${isFullscreen ? 'h-screen' : 'h-[calc(100vh-300px)]'} min-h-[600px]`}>
|
|
||||||
<iframe
|
|
||||||
id="preview-iframe"
|
|
||||||
src={previewUrl}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Site Preview"
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals"
|
|
||||||
allow="fullscreen"
|
|
||||||
/>
|
|
||||||
{!isFullscreen && (
|
|
||||||
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-3 py-1 rounded text-sm">
|
|
||||||
{previewUrl}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ import { useSectorStore } from '../../store/sectorStore';
|
|||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import ProgressModal from '../../components/common/ProgressModal';
|
import ProgressModal from '../../components/common/ProgressModal';
|
||||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||||
import ContentViewerModal from '../../components/common/ContentViewerModal';
|
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||||
@@ -50,10 +49,6 @@ export default function Content() {
|
|||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showContent, setShowContent] = useState(false);
|
const [showContent, setShowContent] = useState(false);
|
||||||
|
|
||||||
// Content viewer modal state
|
|
||||||
const [isViewerModalOpen, setIsViewerModalOpen] = useState(false);
|
|
||||||
const [viewerContent, setViewerContent] = useState<ContentType | null>(null);
|
|
||||||
|
|
||||||
// Progress modal for AI functions
|
// Progress modal for AI functions
|
||||||
const progressModal = useProgressModal();
|
const progressModal = useProgressModal();
|
||||||
const hasReloadedRef = useRef(false);
|
const hasReloadedRef = useRef(false);
|
||||||
@@ -138,11 +133,12 @@ export default function Content() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle view content
|
const navigate = useNavigate();
|
||||||
const handleViewContent = useCallback((row: ContentType) => {
|
|
||||||
setViewerContent(row);
|
// Handle row click - navigate to content view
|
||||||
setIsViewerModalOpen(true);
|
const handleRowClick = useCallback((row: ContentType) => {
|
||||||
}, []);
|
navigate(`/writer/content/${row.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Create page config
|
// Create page config
|
||||||
const pageConfig = useMemo(() => {
|
const pageConfig = useMemo(() => {
|
||||||
@@ -153,13 +149,13 @@ export default function Content() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
onViewContent: handleViewContent,
|
onRowClick: handleRowClick,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeSector,
|
activeSector,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
handleViewContent,
|
handleRowClick,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// Calculate header metrics
|
||||||
@@ -172,8 +168,6 @@ export default function Content() {
|
|||||||
}));
|
}));
|
||||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
|
||||||
if (action === 'view_on_wordpress') {
|
if (action === 'view_on_wordpress') {
|
||||||
if (row.external_url) {
|
if (row.external_url) {
|
||||||
@@ -194,7 +188,7 @@ export default function Content() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Synchronous completion
|
// Synchronous completion
|
||||||
toast.success(`Image prompts generated: ${result.prompts_created || 0} prompt${(result.prompts_created || 0) === 1 ? '' : 's'} created`);
|
toast.success(`Image prompts generation task started. Task ID: ${result.task_id || 'N/A'}`);
|
||||||
loadContent(); // Reload to show new prompts
|
loadContent(); // Reload to show new prompts
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -300,17 +294,6 @@ export default function Content() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Viewer Modal */}
|
|
||||||
<ContentViewerModal
|
|
||||||
isOpen={isViewerModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsViewerModalOpen(false);
|
|
||||||
setViewerContent(null);
|
|
||||||
}}
|
|
||||||
title={viewerContent?.title || 'Content'}
|
|
||||||
contentHtml={viewerContent?.content_html || ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Progress Modal for AI Functions */}
|
{/* Progress Modal for AI Functions */}
|
||||||
<ProgressModal
|
<ProgressModal
|
||||||
isOpen={progressModal.isOpen}
|
isOpen={progressModal.isOpen}
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export default function Review() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [sortBy]);
|
}, [sortBy]);
|
||||||
|
|
||||||
|
// Handle row click - navigate to content view
|
||||||
|
const handleRowClick = useCallback((row: Content) => {
|
||||||
|
navigate(`/writer/content/${row.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Build page config
|
// Build page config
|
||||||
const pageConfig = useMemo(() =>
|
const pageConfig = useMemo(() =>
|
||||||
createReviewPageConfig({
|
createReviewPageConfig({
|
||||||
@@ -118,8 +123,9 @@ export default function Review() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
onRowClick: handleRowClick,
|
||||||
}),
|
}),
|
||||||
[activeSector, searchTerm, statusFilter]
|
[activeSector, searchTerm, statusFilter, handleRowClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Header metrics (calculated from loaded data)
|
// Header metrics (calculated from loaded data)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Content, fetchImages, ImageRecord } from '../services/api';
|
import { Content, fetchImages, ImageRecord } from '../services/api';
|
||||||
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../icons';
|
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon, PencilIcon, ImageIcon, BoltIcon } from '../icons';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
interface ContentViewTemplateProps {
|
interface ContentViewTemplateProps {
|
||||||
content: Content | null;
|
content: Content | null;
|
||||||
@@ -430,6 +431,7 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
|
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [imageRecords, setImageRecords] = useState<ImageRecord[]>([]);
|
const [imageRecords, setImageRecords] = useState<ImageRecord[]>([]);
|
||||||
const [imagesLoading, setImagesLoading] = useState(false);
|
const [imagesLoading, setImagesLoading] = useState(false);
|
||||||
const [imagesError, setImagesError] = useState<string | null>(null);
|
const [imagesError, setImagesError] = useState<string | null>(null);
|
||||||
@@ -900,6 +902,53 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons - Conditional based on status */}
|
||||||
|
{content.status && (
|
||||||
|
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Draft status: Show Edit Content + Generate Images */}
|
||||||
|
{content.status.toLowerCase() === 'draft' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
Edit Content
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/writer/images?contentId=${content.id}`)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
Generate Images
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review status: Show Edit Content + Publish */}
|
||||||
|
{content.status.toLowerCase() === 'review' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
Edit Content
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/writer/published?contentId=${content.id}&action=publish`)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<BoltIcon className="w-4 h-4" />
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Image Status */}
|
{/* Image Status */}
|
||||||
{(content.has_image_prompts || content.has_generated_images) && (
|
{(content.has_image_prompts || content.has_generated_images) && (
|
||||||
<div className="px-8 py-4 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-8 py-4 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
Reference in New Issue
Block a user