Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchContent, Content as ContentType } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function Content() {
const toast = useToast();
const [content, setContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true);
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null);
useEffect(() => {
loadContent();
}, []);
const loadContent = async () => {
try {
setLoading(true);
const response = await fetchContent();
setContent(response.results || []);
} catch (error: any) {
toast.error(`Failed to load content: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Content" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">View all generated content</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<div className="space-y-4">
{content.map((item: ContentType) => (
<Card key={item.id} className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Task #{item.task}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generated: {new Date(item.generated_at).toLocaleString()}
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{item.word_count} words
</div>
</div>
<div
className="prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: item.html_content }}
/>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function WriterDashboard() {
return (
<>
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 mb-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Tasks</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Queued tasks</p>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Drafts</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Draft content</p>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Published</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Published content</p>
</div>
</div>
<ComponentCard title="Coming Soon" desc="Content creation overview">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Writer Dashboard - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Overview of content tasks and workflow will be displayed here
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Drafts Page - Filtered Tasks with status='draft'
* Consistent with Keywords page layout, structure and design
*/
import Tasks from './Tasks';
export default function Drafts() {
// Drafts is just Tasks with status='draft' filter applied
// For now, we'll use the Tasks component but could enhance it later
// to show only draft status tasks by default
return <Tasks />;
}

View File

@@ -0,0 +1,231 @@
/**
* Images Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchTaskImages,
deleteTaskImage,
bulkDeleteTaskImages,
autoGenerateImages,
TaskImage,
TaskImageFilters,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon } from '../../icons';
import { createImagesPageConfig } from '../../config/pages/images.config';
export default function Images() {
const toast = useToast();
// Data state
const [images, setImages] = useState<TaskImage[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [imageTypeFilter, setImageTypeFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load images - wrapped in useCallback
const loadImages = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: TaskImageFilters = {
...(imageTypeFilter && { image_type: imageTypeFilter }),
...(statusFilter && { status: statusFilter }),
page: currentPage,
ordering,
};
// Note: TaskImages API doesn't support search by task title yet
// We'll filter client-side for now
const data = await fetchTaskImages(filters);
let filteredResults = data.results || [];
// Client-side search filter
if (searchTerm) {
filteredResults = filteredResults.filter(img =>
img.task_title?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
setImages(filteredResults);
setTotalCount(filteredResults.length);
setTotalPages(Math.ceil(filteredResults.length / 10));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading images:', error);
toast.error(`Failed to load images: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, imageTypeFilter, statusFilter, sortBy, sortDirection, searchTerm]);
useEffect(() => {
loadImages();
}, [loadImages]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadImages();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadImages]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'generate_images') {
try {
const numIds = ids.map(id => parseInt(id));
// Note: autoGenerateImages expects task_ids, not image_ids
// This would need to be adjusted based on API design
toast.info(`Generate images for ${ids.length} items`);
// await autoGenerateImages(numIds);
} catch (error: any) {
toast.error(`Failed to generate images: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, []);
// Create page config
const pageConfig = useMemo(() => {
return createImagesPageConfig({
searchTerm,
setSearchTerm,
imageTypeFilter,
setImageTypeFilter,
statusFilter,
setStatusFilter,
setCurrentPage,
});
}, [searchTerm, imageTypeFilter, statusFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ images, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, images, totalCount]);
return (
<TablePageTemplate
title="Task Images"
titleIcon={<FileIcon className="text-purple-500 size-5" />}
subtitle="Manage images for content tasks"
columns={pageConfig.columns}
data={images}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
image_type: imageTypeFilter,
status: statusFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'image_type') {
setImageTypeFilter(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
}
setCurrentPage(1);
}}
onDelete={async (id: number) => {
await deleteTaskImage(id);
loadImages();
}}
onBulkDelete={async (ids: number[]) => {
// Note: bulkDeleteTaskImages doesn't exist yet, using individual deletes
for (const id of ids) {
await deleteTaskImage(id);
}
loadImages();
return { deleted_count: ids.length };
}}
onBulkExport={handleBulkExport}
onBulkAction={handleBulkAction}
getItemDisplayName={(row: TaskImage) => row.task_title || `Image ${row.id}`}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="image"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setImageTypeFilter('');
setStatusFilter('');
setCurrentPage(1);
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Published Page - Filtered Tasks with status='published'
* Consistent with Keywords page layout, structure and design
*/
import Tasks from './Tasks';
export default function Published() {
// Published is just Tasks with status='published' filter applied
// For now, we'll use the Tasks component but could enhance it later
// to show only published status tasks by default
return <Tasks />;
}

View File

@@ -0,0 +1,465 @@
/**
* Tasks Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchTasks,
createTask,
updateTask,
deleteTask,
bulkDeleteTasks,
bulkUpdateTasksStatus,
autoGenerateContent,
autoGenerateImages,
Task,
TasksFilters,
TaskCreateData,
fetchClusters,
Cluster,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { TaskIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createTasksPageConfig } from '../../config/pages/tasks.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
export default function Tasks() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [tasks, setTasks] = useState<Task[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [structureFilter, setStructureFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [formData, setFormData] = useState<TaskCreateData>({
title: '',
description: '',
keywords: '',
cluster_id: null,
content_structure: 'blog_post',
content_type: 'blog_post',
status: 'queued',
word_count: 0,
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load tasks - wrapped in useCallback
const loadTasks = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: TasksFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
...(typeFilter && { content_type: typeFilter }),
page: currentPage,
page_size: pageSize,
ordering,
};
const data = await fetchTasks(filters);
setTasks(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading tasks:', error);
toast.error(`Failed to load tasks: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, structureFilter, typeFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadTasks();
};
const handleSectorChange = () => {
loadTasks();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadTasks]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadTasks();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadTasks]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateTasksStatus(numIds, status);
await loadTasks();
} catch (error: any) {
throw error;
}
}, [loadTasks]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler for single task actions
const handleRowAction = useCallback(async (action: string, row: Task) => {
if (action === 'generate_content') {
// Validate task has required data
if (!row.title) {
toast.error('Task must have a title to generate content');
return;
}
// Optional: Validate task status (can generate for any status)
// if (row.status !== 'queued') {
// toast.error(`Only tasks with status "queued" can generate content. Current status: ${row.status}`);
// return;
// }
try {
const result = await autoGenerateContent([row.id]);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Content');
toast.success('Content generation started');
} else {
// Synchronous completion
toast.success(`Content generated successfully: ${result.tasks_updated || 0} article generated`);
await loadTasks();
}
} else {
toast.error(result.error || 'Failed to generate content');
}
} catch (error: any) {
toast.error(`Failed to generate content: ${error.message}`);
}
}
}, [toast, loadTasks, progressModal]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
// generate_content removed from bulk actions - only available as row action
if (action === 'generate_images') {
if (ids.length === 0) {
toast.error('Please select at least one task to generate images');
return;
}
if (ids.length > 10) {
toast.error('Maximum 10 tasks allowed for image generation');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await autoGenerateImages(numIds);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Images');
toast.success('Image generation started');
} else {
// Synchronous completion
toast.success(`Image generation complete: ${result.images_created || 0} images generated`);
await loadTasks();
}
} else {
toast.error(result.error || 'Failed to generate images');
}
} catch (error: any) {
toast.error(`Failed to generate images: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadTasks, progressModal]);
// Create page config
const pageConfig = useMemo(() => {
return createTasksPageConfig({
clusters,
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
clusterFilter,
setClusterFilter,
structureFilter,
setStructureFilter,
typeFilter,
setTypeFilter,
setCurrentPage,
});
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ tasks, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, tasks, totalCount]);
const resetForm = useCallback(() => {
setFormData({
title: '',
description: '',
keywords: '',
cluster_id: null,
content_structure: 'blog_post',
content_type: 'blog_post',
status: 'queued',
word_count: 0,
});
setIsEditMode(false);
setEditingTask(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingTask) {
await updateTask(editingTask.id, formData);
toast.success('Task updated successfully');
} else {
await createTask(formData);
toast.success('Task created successfully');
}
setIsModalOpen(false);
resetForm();
loadTasks();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Content Tasks"
titleIcon={<TaskIcon className="text-brand-500 size-5" />}
subtitle="Manage content generation queue and tasks"
columns={pageConfig.columns}
data={tasks}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'cluster_id') {
setClusterFilter(stringValue);
} else if (key === 'content_structure') {
setStructureFilter(stringValue);
} else if (key === 'content_type') {
setTypeFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingTask(row);
setFormData({
title: row.title || '',
description: row.description || '',
keywords: row.keywords || '',
cluster_id: row.cluster_id || null,
content_structure: row.content_structure || 'blog_post',
content_type: row.content_type || 'blog_post',
status: row.status || 'queued',
word_count: row.word_count || 0,
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Task"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteTask(id);
loadTasks();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteTasks(ids);
loadTasks();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: Task) => row.title}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="task"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setStructureFilter('');
setTypeFilter('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadTasks();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Task' : 'Add Task'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
</>
);
}