Add bulk update functionality for image status
- Introduced a new endpoint in the backend to handle bulk updates of image statuses by content ID or image IDs. - Updated the frontend to include a new row action for updating image status and integrated a modal for status confirmation. - Enhanced the API service to support bulk status updates and updated the images page to manage status updates effectively.
This commit is contained in:
@@ -504,6 +504,28 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
'type': 'TaskError'
|
'type': 'TaskError'
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||||
|
def bulk_update(self, request):
|
||||||
|
"""Bulk update image status by content_id or image IDs"""
|
||||||
|
content_id = request.data.get('content_id')
|
||||||
|
image_ids = request.data.get('ids', [])
|
||||||
|
status_value = request.data.get('status')
|
||||||
|
|
||||||
|
if not status_value:
|
||||||
|
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
# Update by content_id if provided, otherwise by image IDs
|
||||||
|
if content_id:
|
||||||
|
updated_count = queryset.filter(content_id=content_id).update(status=status_value)
|
||||||
|
elif image_ids:
|
||||||
|
updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
|
||||||
|
else:
|
||||||
|
return Response({'error': 'Either content_id or ids must be provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
|
@action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
|
||||||
def content_images(self, request):
|
def content_images(self, request):
|
||||||
"""Get images grouped by content - one row per content with featured and in-article images"""
|
"""Get images grouped by content - one row per content with featured and in-article images"""
|
||||||
|
|||||||
106
frontend/src/components/common/SingleRecordStatusUpdateModal.tsx
Normal file
106
frontend/src/components/common/SingleRecordStatusUpdateModal.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Single Record Status Update Modal
|
||||||
|
* Modal for updating status of all images in a single content record
|
||||||
|
* Similar to BulkStatusUpdateModal but for single records
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal } from '../ui/modal';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import SelectDropdown from '../form/SelectDropdown';
|
||||||
|
import Label from '../form/Label';
|
||||||
|
import { InfoIcon } from '../../icons';
|
||||||
|
|
||||||
|
interface SingleRecordStatusUpdateModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (status: string) => void | Promise<void>;
|
||||||
|
title: string;
|
||||||
|
recordName: string;
|
||||||
|
confirmText?: string;
|
||||||
|
statusOptions: Array<{ value: string; label: string }>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SingleRecordStatusUpdateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
recordName,
|
||||||
|
confirmText = 'Update Status',
|
||||||
|
statusOptions,
|
||||||
|
isLoading = false,
|
||||||
|
}: SingleRecordStatusUpdateModalProps) {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!selectedStatus) return;
|
||||||
|
await onConfirm(selectedStatus);
|
||||||
|
// Reset on success (onClose will be called by parent)
|
||||||
|
setSelectedStatus('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedStatus('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
className="max-w-md"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header with icon */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 bg-blue-50 rounded-xl dark:bg-blue-500/10">
|
||||||
|
<InfoIcon className="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Update status for all images in <span className="font-semibold text-gray-900 dark:text-white">{recordName}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Status Selector */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Label className="mb-2">
|
||||||
|
New Status
|
||||||
|
</Label>
|
||||||
|
<SelectDropdown
|
||||||
|
options={statusOptions}
|
||||||
|
placeholder="Select status"
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(value) => setSelectedStatus(value || '')}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isLoading || !selectedStatus}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Updating...' : confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -309,6 +309,17 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'/writer/images': {
|
||||||
|
rowActions: [
|
||||||
|
{
|
||||||
|
key: 'update_status',
|
||||||
|
label: 'Update Status',
|
||||||
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bulkActions: [],
|
||||||
|
},
|
||||||
// Default config (fallback)
|
// Default config (fallback)
|
||||||
default: {
|
default: {
|
||||||
rowActions: [],
|
rowActions: [],
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { ReactComponent as ChatIcon } from "./chat.svg?react";
|
|||||||
import { ReactComponent as MoreDotIcon } from "./moredot.svg?react";
|
import { ReactComponent as MoreDotIcon } from "./moredot.svg?react";
|
||||||
import { ReactComponent as AlertHexaIcon } from "./alert-hexa.svg?react";
|
import { ReactComponent as AlertHexaIcon } from "./alert-hexa.svg?react";
|
||||||
import { ReactComponent as ErrorHexaIcon } from "./info-hexa.svg?react";
|
import { ReactComponent as ErrorHexaIcon } from "./info-hexa.svg?react";
|
||||||
|
import { ReactComponent as CalendarIcon } from "./calendar.svg?react";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ErrorHexaIcon,
|
ErrorHexaIcon,
|
||||||
@@ -112,4 +113,12 @@ export {
|
|||||||
ChatIcon,
|
ChatIcon,
|
||||||
AngleLeftIcon,
|
AngleLeftIcon,
|
||||||
AngleRightIcon,
|
AngleRightIcon,
|
||||||
|
CalendarIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Aliases for commonly used icon names
|
||||||
|
export { AngleLeftIcon as ArrowLeftIcon };
|
||||||
|
export { FileIcon as FileTextIcon };
|
||||||
|
export { TimeIcon as ClockIcon };
|
||||||
|
export { ErrorIcon as XCircleIcon };
|
||||||
|
export { BoxIcon as TagIcon };
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
ContentImagesResponse,
|
ContentImagesResponse,
|
||||||
fetchImageGenerationSettings,
|
fetchImageGenerationSettings,
|
||||||
generateImages,
|
generateImages,
|
||||||
|
bulkUpdateImagesStatus,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { FileIcon, DownloadIcon, BoltIcon } from '../../icons';
|
import { FileIcon, DownloadIcon, BoltIcon } from '../../icons';
|
||||||
import { createImagesPageConfig } from '../../config/pages/images.config';
|
import { createImagesPageConfig } from '../../config/pages/images.config';
|
||||||
import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal';
|
import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal';
|
||||||
|
import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal';
|
||||||
import { useResourceDebug } from '../../hooks/useResourceDebug';
|
import { useResourceDebug } from '../../hooks/useResourceDebug';
|
||||||
|
|
||||||
export default function Images() {
|
export default function Images() {
|
||||||
@@ -76,6 +78,12 @@ export default function Images() {
|
|||||||
const [imageModel, setImageModel] = useState<string | null>(null);
|
const [imageModel, setImageModel] = useState<string | null>(null);
|
||||||
const [imageProvider, setImageProvider] = useState<string | null>(null);
|
const [imageProvider, setImageProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Status update modal state
|
||||||
|
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
|
||||||
|
const [statusUpdateContentId, setStatusUpdateContentId] = useState<number | null>(null);
|
||||||
|
const [statusUpdateRecordName, setStatusUpdateRecordName] = useState<string>('');
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
||||||
|
|
||||||
// Load images - wrapped in useCallback
|
// Load images - wrapped in useCallback
|
||||||
const loadImages = useCallback(async () => {
|
const loadImages = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -175,6 +183,35 @@ export default function Images() {
|
|||||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
|
// Row action handler
|
||||||
|
const handleRowAction = useCallback(async (action: string, row: ContentImagesGroup) => {
|
||||||
|
if (action === 'update_status') {
|
||||||
|
setStatusUpdateContentId(row.content_id);
|
||||||
|
setStatusUpdateRecordName(row.content_title || `Content #${row.content_id}`);
|
||||||
|
setIsStatusModalOpen(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle status update confirmation
|
||||||
|
const handleStatusUpdate = useCallback(async (status: string) => {
|
||||||
|
if (!statusUpdateContentId) return;
|
||||||
|
|
||||||
|
setIsUpdatingStatus(true);
|
||||||
|
try {
|
||||||
|
const result = await bulkUpdateImagesStatus(statusUpdateContentId, status);
|
||||||
|
toast.success(`Successfully updated ${result.updated_count} image(s) status to ${status}`);
|
||||||
|
setIsStatusModalOpen(false);
|
||||||
|
setStatusUpdateContentId(null);
|
||||||
|
setStatusUpdateRecordName('');
|
||||||
|
// Reload images to reflect the changes
|
||||||
|
loadImages();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to update status: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingStatus(false);
|
||||||
|
}
|
||||||
|
}, [statusUpdateContentId, toast, loadImages]);
|
||||||
|
|
||||||
// Build image queue structure
|
// Build image queue structure
|
||||||
const buildImageQueue = useCallback((contentId: number, maxInArticleImages: number) => {
|
const buildImageQueue = useCallback((contentId: number, maxInArticleImages: number) => {
|
||||||
const contentImages = images.find(g => g.content_id === contentId);
|
const contentImages = images.find(g => g.content_id === contentId);
|
||||||
@@ -396,6 +433,7 @@ export default function Images() {
|
|||||||
setStatusFilter('');
|
setStatusFilter('');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
|
onRowAction={handleRowAction}
|
||||||
/>
|
/>
|
||||||
<ImageQueueModal
|
<ImageQueueModal
|
||||||
isOpen={isQueueModalOpen}
|
isOpen={isQueueModalOpen}
|
||||||
@@ -418,6 +456,25 @@ export default function Images() {
|
|||||||
onLog={addAiLog}
|
onLog={addAiLog}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Status Update Modal */}
|
||||||
|
<SingleRecordStatusUpdateModal
|
||||||
|
isOpen={isStatusModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsStatusModalOpen(false);
|
||||||
|
setStatusUpdateContentId(null);
|
||||||
|
setStatusUpdateRecordName('');
|
||||||
|
}}
|
||||||
|
onConfirm={handleStatusUpdate}
|
||||||
|
title="Update Image Status"
|
||||||
|
recordName={statusUpdateRecordName}
|
||||||
|
statusOptions={[
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'generated', label: 'Generated' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
]}
|
||||||
|
isLoading={isUpdatingStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
||||||
{resourceDebugEnabled && aiLogs.length > 0 && (
|
{resourceDebugEnabled && aiLogs.length > 0 && (
|
||||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
|||||||
@@ -1032,6 +1032,13 @@ export async function fetchContentImages(): Promise<ContentImagesResponse> {
|
|||||||
return fetchAPI('/v1/writer/images/content_images/');
|
return fetchAPI('/v1/writer/images/content_images/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> {
|
||||||
|
return fetchAPI(`/v1/writer/images/bulk_update/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content_id: contentId, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateImages(imageIds: number[], contentId?: number): Promise<any> {
|
export async function generateImages(imageIds: number[], contentId?: number): Promise<any> {
|
||||||
return fetchAPI('/v1/writer/images/generate_images/', {
|
return fetchAPI('/v1/writer/images/generate_images/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user