test if works or revert
This commit is contained in:
@@ -210,8 +210,8 @@ export default function App() {
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Writer Module - Redirect dashboard to content */}
|
||||
<Route path="/writer" element={<Navigate to="/writer/content" replace />} />
|
||||
{/* Writer Module - Redirect dashboard to tasks */}
|
||||
<Route path="/writer" element={<Navigate to="/writer/tasks" replace />} />
|
||||
<Route path="/writer/tasks" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="writer">
|
||||
|
||||
@@ -59,29 +59,8 @@ export default function ContentImageCell({ image, maxPromptLength = 100 }: Conte
|
||||
);
|
||||
}
|
||||
|
||||
const prompt = image.prompt || '';
|
||||
const shouldTruncate = prompt.length > maxPromptLength;
|
||||
const displayPrompt = showFullPrompt || !shouldTruncate ? prompt : `${prompt.substring(0, maxPromptLength)}...`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Prompt Text */}
|
||||
{prompt && (
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
{displayPrompt}
|
||||
{shouldTruncate && (
|
||||
<button
|
||||
onClick={() => setShowFullPrompt(!showFullPrompt)}
|
||||
className="ml-1 text-brand-500 hover:text-brand-600 text-xs"
|
||||
>
|
||||
{showFullPrompt ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Display */}
|
||||
<div className="relative">
|
||||
{image.status === 'pending' && (
|
||||
|
||||
@@ -83,8 +83,10 @@ export const createContentPageConfig = (
|
||||
setStatusFilter: (value: string) => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
onViewContent?: (row: Content) => void;
|
||||
enableToggleContent?: boolean; // If false, do not add toggleable content column behavior
|
||||
}
|
||||
): ContentPageConfig => {
|
||||
const enableToggle = handlers.enableToggleContent !== false;
|
||||
const showSectorColumn = !handlers.activeSector;
|
||||
|
||||
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||
@@ -99,9 +101,9 @@ export const createContentPageConfig = (
|
||||
...titleColumn,
|
||||
sortable: true,
|
||||
sortField: 'title',
|
||||
toggleable: true,
|
||||
toggleContentKey: 'content_html',
|
||||
toggleContentLabel: 'Generated Content',
|
||||
toggleable: enableToggle,
|
||||
toggleContentKey: enableToggle ? 'content_html' : undefined,
|
||||
toggleContentLabel: enableToggle ? 'Generated Content' : undefined,
|
||||
render: (value: string, row: Content) => (
|
||||
<div>
|
||||
{handlers.onViewContent ? (
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Content } from '../../services/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { formatRelativeDate } from '../../utils/date';
|
||||
import { CheckCircleIcon } from '../../icons';
|
||||
@@ -56,44 +57,61 @@ export function createReviewPageConfig(params: {
|
||||
const showSectorColumn = !params.activeSector;
|
||||
|
||||
const columns: ColumnConfig[] = [
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Categories',
|
||||
sortable: false,
|
||||
width: '180px',
|
||||
render: (_value: any, row: Content) => {
|
||||
const categories = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'category') || [];
|
||||
if (!categories.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((cat: any) => (
|
||||
<span key={cat.id} className="px-2 py-0.5 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium">{cat.name}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
toggleable: true,
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
sortable: false,
|
||||
width: '180px',
|
||||
render: (_value: any, row: Content) => {
|
||||
const tags = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'tag') || [];
|
||||
if (!tags.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag: any) => (
|
||||
<span key={tag.id} className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium">{tag.name}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
toggleable: true,
|
||||
defaultVisible: false,
|
||||
},
|
||||
// Title first, then categories and tags (moved after title per change request)
|
||||
{
|
||||
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">
|
||||
<Link to={`/writer/content/${row.id}`} className="font-medium text-gray-900 dark:text-white hover:underline">
|
||||
{value || `Content #${row.id}`}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Categories',
|
||||
sortable: false,
|
||||
width: '180px',
|
||||
render: (_value: any, row: Content) => {
|
||||
const categories = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'category') || [];
|
||||
if (!categories.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((cat: any) => (
|
||||
<span key={cat.id} className="px-2 py-0.5 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium">{cat.name}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
toggleable: false,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
sortable: false,
|
||||
width: '180px',
|
||||
render: (_value: any, row: Content) => {
|
||||
const tags = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'tag') || [];
|
||||
if (!tags.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag: any) => (
|
||||
<span key={tag.id} className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium">{tag.name}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
toggleable: false,
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
|
||||
24
frontend/src/hooks/useFeatureFlag.ts
Normal file
24
frontend/src/hooks/useFeatureFlag.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
|
||||
// Simple client-side feature flag hook backed by accountSettings
|
||||
export function useFeatureFlag(key: string): boolean {
|
||||
const accountSettings = useSettingsStore((s) => s.accountSettings);
|
||||
const setting = accountSettings?.[key];
|
||||
const enabled = useMemo(() => {
|
||||
if (!setting || !setting.config) return false;
|
||||
try {
|
||||
// Expect config to be boolean or { enabled: boolean }
|
||||
if (typeof setting.config === 'boolean') return Boolean(setting.config);
|
||||
if (typeof setting.config === 'object' && setting.config !== null && 'enabled' in setting.config) {
|
||||
return Boolean((setting.config as any).enabled);
|
||||
}
|
||||
return Boolean(setting.config);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [setting]);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ const AppSidebar: React.FC = () => {
|
||||
workflowItems.push({
|
||||
icon: <TaskIcon />,
|
||||
name: "Writer",
|
||||
path: "/writer/content", // Default to content, submenus shown as in-page navigation
|
||||
path: "/writer/tasks", // Default to tasks (changed from content)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import ProgressModal from '../../components/common/ProgressModal';
|
||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||
import ContentViewerModal from '../../components/common/ContentViewerModal';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||
@@ -50,9 +49,6 @@ export default function Content() {
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
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
|
||||
const progressModal = useProgressModal();
|
||||
@@ -138,11 +134,10 @@ export default function Content() {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Handle view content
|
||||
// Handle view content - navigate to content view instead of opening modal
|
||||
const handleViewContent = useCallback((row: ContentType) => {
|
||||
setViewerContent(row);
|
||||
setIsViewerModalOpen(true);
|
||||
}, []);
|
||||
navigate(`/writer/content/${row.id}`);
|
||||
}, [navigate]);
|
||||
|
||||
// Create page config
|
||||
const pageConfig = useMemo(() => {
|
||||
@@ -154,6 +149,7 @@ export default function Content() {
|
||||
setStatusFilter,
|
||||
setCurrentPage,
|
||||
onViewContent: handleViewContent,
|
||||
enableToggleContent: false, // Disable dropdown toggle on this page; open full view instead
|
||||
});
|
||||
}, [
|
||||
activeSector,
|
||||
@@ -300,16 +296,7 @@ export default function Content() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content Viewer Modal */}
|
||||
<ContentViewerModal
|
||||
isOpen={isViewerModalOpen}
|
||||
onClose={() => {
|
||||
setIsViewerModalOpen(false);
|
||||
setViewerContent(null);
|
||||
}}
|
||||
title={viewerContent?.title || 'Content'}
|
||||
contentHtml={viewerContent?.content_html || ''}
|
||||
/>
|
||||
{/* Content view opens in its own route; modal removed */}
|
||||
|
||||
{/* Progress Modal for AI Functions */}
|
||||
<ProgressModal
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Content, fetchImages, ImageRecord } from '../services/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Content, fetchImages, ImageRecord, publishContent, generateImagePrompts } from '../services/api';
|
||||
import { useToast } from '../components/ui/toast/ToastContainer';
|
||||
import { useFeatureFlag } from '../hooks/useFeatureFlag';
|
||||
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../icons';
|
||||
|
||||
interface ContentViewTemplateProps {
|
||||
@@ -591,6 +594,59 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
|
||||
const shouldShowFeaturedBlock = imagesLoading || Boolean(resolvedFeaturedImage);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [generatingImages, setGeneratingImages] = useState(false);
|
||||
const featureEnabled = useFeatureFlag('feature.content_manager_refactor');
|
||||
|
||||
const handleEditContent = () => {
|
||||
const siteId = content?.site ?? content?.site_id ?? null;
|
||||
if (!siteId || !content?.id) {
|
||||
toast.error('Site or content id missing');
|
||||
return;
|
||||
}
|
||||
navigate(`/sites/${siteId}/posts/${content.id}/edit`);
|
||||
};
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
if (!content?.id) return;
|
||||
try {
|
||||
setGeneratingImages(true);
|
||||
const result = await generateImagePrompts([content.id]);
|
||||
if (result && result.success) {
|
||||
toast.success('Image generation started');
|
||||
// If async task_id returned, open progress modal elsewhere; refresh images after short delay
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
toast.error(result?.error || 'Failed to start image generation');
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to generate images: ${e?.message || e}`);
|
||||
} finally {
|
||||
setGeneratingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!content?.id) return;
|
||||
try {
|
||||
setPublishing(true);
|
||||
const result = await publishContent(content.id);
|
||||
if (result && (result.external_url || result.external_id)) {
|
||||
toast.success('Content published successfully');
|
||||
// Reload to show updated external_id/status
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} else {
|
||||
toast.error('Failed to publish content');
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`Publish failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
@@ -677,7 +733,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileTextIcon className="w-6 h-6" />
|
||||
@@ -694,6 +750,34 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{featureEnabled && (content.status === 'draft' || content.status === 'review') && (
|
||||
<button
|
||||
onClick={handleEditContent}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-white text-gray-700 rounded-lg border border-gray-200 hover:bg-gray-50 transition"
|
||||
>
|
||||
Edit content
|
||||
</button>
|
||||
)}
|
||||
{featureEnabled && content.status === 'draft' && (
|
||||
<button
|
||||
onClick={handleGenerateImages}
|
||||
disabled={generatingImages}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-white text-gray-700 rounded-lg border border-gray-200 hover:bg-gray-50 transition"
|
||||
>
|
||||
{generatingImages ? 'Generating...' : 'Generate images'}
|
||||
</button>
|
||||
)}
|
||||
{featureEnabled && content.status === 'review' && (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={publishing}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition"
|
||||
>
|
||||
{publishing ? 'Publishing...' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user