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,73 @@
/**
* Bulk Export Confirmation Modal
* Reusable modal for confirming bulk export operations
* Used across all table pages (Keywords, Clusters, Ideas, Tasks, etc.)
*/
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { InfoIcon } from '../../icons';
interface BulkExportModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
message: string;
confirmText?: string;
isLoading?: boolean;
}
export default function BulkExportModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Export',
isLoading = false,
}: BulkExportModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
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-6">
{message}
</p>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Exporting...' : confirmText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,106 @@
/**
* Bulk Status Update Modal
* Reusable modal for updating status of multiple selected records
* Used across all table pages (Keywords, Clusters, Ideas, Tasks, etc.)
*/
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 BulkStatusUpdateModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (status: string) => void | Promise<void>;
title: string;
message: string;
confirmText?: string;
statusOptions: Array<{ value: string; label: string }>;
isLoading?: boolean;
}
export default function BulkStatusUpdateModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Update Status',
statusOptions,
isLoading = false,
}: BulkStatusUpdateModalProps) {
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">
{message}
</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>
);
}

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
const ChartTab: React.FC = () => {
const [selected, setSelected] = useState<
"optionOne" | "optionTwo" | "optionThree"
>("optionOne");
const getButtonClass = (option: "optionOne" | "optionTwo" | "optionThree") =>
selected === option
? "shadow-theme-xs text-gray-900 dark:text-white bg-white dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400";
return (
<div className="flex items-center gap-0.5 rounded-lg bg-gray-100 p-0.5 dark:bg-gray-900">
<button
onClick={() => setSelected("optionOne")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionOne"
)}`}
>
Monthly
</button>
<button
onClick={() => setSelected("optionTwo")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionTwo"
)}`}
>
Quarterly
</button>
<button
onClick={() => setSelected("optionThree")}
className={`px-3 py-2 font-medium w-full rounded-md text-theme-sm hover:text-gray-900 dark:hover:text-white ${getButtonClass(
"optionThree"
)}`}
>
Annually
</button>
</div>
);
};
export default ChartTab;

View File

@@ -0,0 +1,38 @@
interface ComponentCardProps {
title: string;
children: React.ReactNode;
className?: string; // Additional custom classes for styling
desc?: string; // Description text
}
const ComponentCard: React.FC<ComponentCardProps> = ({
title,
children,
className = "",
desc = "",
}) => {
return (
<div
className={`rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] ${className}`}
>
{/* Card Header */}
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
{title}
</h3>
{desc && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{desc}
</p>
)}
</div>
{/* Card Body */}
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">{children}</div>
</div>
</div>
);
};
export default ComponentCard;

View File

@@ -0,0 +1,76 @@
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
isLoading?: boolean;
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
isLoading = false,
}: ConfirmDialogProps) {
const variantStyles = {
danger: {
button: 'variant="primary"',
className: '',
},
warning: {
button: 'variant="primary"',
className: '',
},
info: {
button: 'variant="primary"',
className: '',
},
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-md"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
{title}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{message}
</p>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{cancelText}
</Button>
<Button
variant="primary"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmText}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,82 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-screen p-6">
<div className="text-center max-w-md">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-4">
Something went wrong
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
window.location.reload();
}}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
Reload Page
</button>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-gray-500 dark:text-gray-400">
Error Details
</summary>
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,224 @@
import { ReactNode } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import SelectDropdown from '../form/SelectDropdown';
import Label from '../form/Label';
export interface FormField {
key: string;
label: string;
type: 'text' | 'number' | 'email' | 'password' | 'select' | 'textarea';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
value: any;
onChange: (value: any) => void;
required?: boolean;
min?: number;
max?: number;
rows?: number;
className?: string;
}
interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
fields?: FormField[];
submitLabel?: string;
cancelLabel?: string;
isLoading?: boolean;
className?: string;
customFooter?: ReactNode;
customBody?: ReactNode; // Custom body content that replaces fields
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
fields = [],
submitLabel = 'Create',
cancelLabel = 'Cancel',
isLoading = false,
className = 'max-w-2xl',
customFooter,
customBody,
}: FormModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className={className}
>
<div className="p-6">
<h3 className="text-lg font-semibold mb-6 text-gray-800 dark:text-white">
{title}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
{customBody ? (
customBody
) : (
<>
{fields.find(f => f.key === 'keyword') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{fields.find(f => f.key === 'keyword')!.label}
{fields.find(f => f.key === 'keyword')!.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={fields.find(f => f.key === 'keyword')!.value || ''}
onChange={(e) => fields.find(f => f.key === 'keyword')!.onChange(e.target.value)}
placeholder={fields.find(f => f.key === 'keyword')!.placeholder}
required={fields.find(f => f.key === 'keyword')!.required}
/>
</div>
)}
{(fields.find(f => f.key === 'volume') || fields.find(f => f.key === 'difficulty')) && (
<div className="grid grid-cols-2 gap-4">
{fields.find(f => f.key === 'volume') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{fields.find(f => f.key === 'volume')!.label}
{fields.find(f => f.key === 'volume')!.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type="number"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={fields.find(f => f.key === 'volume')!.value || ''}
onChange={(e) => {
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
fields.find(f => f.key === 'volume')!.onChange(value);
}}
placeholder={fields.find(f => f.key === 'volume')!.placeholder}
required={fields.find(f => f.key === 'volume')!.required}
/>
</div>
)}
{fields.find(f => f.key === 'difficulty') && (() => {
const difficultyField = fields.find(f => f.key === 'difficulty')!;
return (
<div>
<Label className="mb-2">
{difficultyField.label}
{difficultyField.required && <span className="text-error-500 ml-1">*</span>}
</Label>
{difficultyField.type === 'select' ? (
<SelectDropdown
options={difficultyField.options || []}
placeholder={difficultyField.placeholder || difficultyField.label}
value={difficultyField.value || ''}
onChange={(value) => difficultyField.onChange(value)}
className="w-full"
/>
) : (
<input
type="number"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={difficultyField.value || ''}
onChange={(e) => {
const value = e.target.value === '' ? '' : parseInt(e.target.value) || 0;
difficultyField.onChange(value);
}}
placeholder={difficultyField.placeholder}
required={difficultyField.required}
min={difficultyField.min}
max={difficultyField.max}
/>
)}
</div>
);
})()}
</div>
)}
{fields.filter(f => f.key !== 'keyword' && f.key !== 'volume' && f.key !== 'difficulty').map((field) => {
if (field.type === 'select') {
return (
<div key={field.key}>
<Label className="mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</Label>
<SelectDropdown
options={field.options || []}
placeholder={field.placeholder || field.label}
value={field.value || ''}
onChange={(value) => field.onChange(value)}
className="w-full"
/>
</div>
);
}
if (field.type === 'textarea') {
return (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</label>
<textarea
rows={field.rows || 4}
className="w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
/>
</div>
);
}
return (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{field.label}
{field.required && <span className="text-error-500 ml-1">*</span>}
</label>
<input
type={field.type}
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
placeholder={field.placeholder}
required={field.required}
min={field.min}
max={field.max}
/>
</div>
);
})}
</>
)}
{customFooter || (
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
type="submit"
variant="primary"
disabled={isLoading}
>
{isLoading ? 'Processing...' : submitLabel}
</Button>
</div>
)}
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
export default function GlobalErrorDisplay() {
const { errors, clearError, clearAllErrors } = useErrorHandler('GlobalErrorDisplay');
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(errors.length > 0);
}, [errors.length]);
if (!isVisible || errors.length === 0) {
return null;
}
return (
<div className="fixed top-4 right-4 z-[9999] max-w-md space-y-2">
{errors.map((error, index) => (
<div
key={index}
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg shadow-lg p-4 animate-in slide-in-from-right"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-red-600 dark:text-red-400 text-lg"></span>
<span className="text-sm font-semibold text-red-800 dark:text-red-200">
{error.source}
</span>
</div>
<p className="text-sm text-red-700 dark:text-red-300 mb-2">
{error.message}
</p>
{error.stack && (
<details className="mt-2">
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer hover:underline">
Show stack trace
</summary>
<pre className="mt-2 text-xs bg-red-100 dark:bg-red-900/40 p-2 rounded overflow-auto max-h-32">
{error.stack}
</pre>
</details>
)}
</div>
<button
onClick={() => clearError(index)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200 text-xl leading-none"
aria-label="Dismiss error"
>
×
</button>
</div>
</div>
))}
{errors.length > 1 && (
<button
onClick={clearAllErrors}
className="w-full px-3 py-2 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
Clear All Errors
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function GridShape() {
return (
<>
<div className="absolute right-0 top-0 -z-1 w-full max-w-[250px] xl:max-w-[450px]">
<img src="/images/shape/grid-01.svg" alt="grid" />
</div>
<div className="absolute bottom-0 left-0 -z-1 w-full max-w-[250px] rotate-180 xl:max-w-[450px]">
<img src="/images/shape/grid-01.svg" alt="grid" />
</div>
</>
);
}

View File

@@ -0,0 +1,293 @@
/**
* HTMLContentRenderer Component
* Safely renders HTML content with proper formatting and sanitization
*/
import React, { useMemo } from 'react';
import { sanitizeHTML, isHTML } from '../../utils/htmlSanitizer';
interface HTMLContentRendererProps {
content: string | null | undefined;
className?: string;
maxHeight?: string;
}
/**
* Parse and format content outline (JSON structure)
*/
function formatContentOutline(content: any): string {
if (!content) return '';
let html = '<div class="content-outline">';
// Handle introduction section - can be object or string
if (content.introduction) {
html += '<div class="outline-intro">';
if (typeof content.introduction === 'string') {
// Introduction is a simple string
html += `<div class="outline-paragraph">${escapeHTML(content.introduction)}</div>`;
} else if (typeof content.introduction === 'object') {
// Introduction is an object with hook and paragraphs
if (content.introduction.hook) {
html += `<div class="outline-hook"><strong>Hook:</strong> ${escapeHTML(content.introduction.hook)}</div>`;
}
if (content.introduction.paragraphs && Array.isArray(content.introduction.paragraphs)) {
content.introduction.paragraphs.forEach((para: any, index: number) => {
if (para.details) {
html += `<div class="outline-paragraph"><strong>Intro Paragraph ${index + 1}:</strong> ${escapeHTML(para.details)}</div>`;
}
});
}
}
html += '</div>';
}
// Handle sections array format (Format 3: nested structure)
if (content.sections && Array.isArray(content.sections)) {
content.sections.forEach((section: any) => {
if (!section) return;
html += '<div class="outline-section">';
// Handle section title (can be "H2: ..." or just text)
if (section.title) {
const titleText = section.title.replace(/^H2:\s*/i, '').trim();
if (titleText.toLowerCase() === 'conclusion') {
html += `<h3 class="section-heading">${escapeHTML(titleText)}</h3>`;
} else {
html += `<h3 class="section-heading">${escapeHTML(titleText)}</h3>`;
}
}
// Handle section content - can be array or string
if (section.content) {
if (Array.isArray(section.content)) {
// Content is an array of objects with title (H3) and content
section.content.forEach((item: any) => {
if (item.title) {
const subTitleText = item.title.replace(/^H3:\s*/i, '').trim();
html += `<h4 class="subsection-heading">${escapeHTML(subTitleText)}</h4>`;
}
if (item.content) {
html += `<div class="section-details">${escapeHTML(String(item.content))}</div>`;
}
});
} else if (typeof section.content === 'string') {
// Content is a simple string
html += `<div class="section-details">${escapeHTML(section.content)}</div>`;
}
}
html += '</div>';
});
}
// Handle H2 sections - can be array or simple key-value pairs
if (content.H2) {
if (Array.isArray(content.H2)) {
// Structured format: array of section objects
content.H2.forEach((section: any) => {
if (section.heading || typeof section === 'string') {
html += `<div class="outline-section">`;
const heading = section.heading || section;
html += `<h3 class="section-heading">${escapeHTML(heading)}</h3>`;
// Handle content type badge
if (section.content_type) {
html += `<div class="content-type-badge">${escapeHTML(section.content_type.replace('_', ' ').toUpperCase())}</div>`;
}
// Handle subsections (H3)
if (section.subsections && Array.isArray(section.subsections)) {
section.subsections.forEach((subsection: any) => {
const subheading = subsection.subheading || subsection.heading || subsection;
html += `<h4 class="subsection-heading">${escapeHTML(subheading)}</h4>`;
if (subsection.details) {
html += `<div class="section-details">${escapeHTML(subsection.details)}</div>`;
}
});
}
// Handle details
if (section.details) {
html += `<div class="section-details">${escapeHTML(section.details)}</div>`;
}
html += `</div>`;
}
});
} else if (typeof content.H2 === 'string') {
// Simple format: just a string (GPT-4o mini sometimes returns this)
html += `<div class="outline-section">`;
html += `<h3 class="section-heading">${escapeHTML(content.H2)}</h3>`;
html += `</div>`;
} else if (typeof content.H2 === 'object') {
// Simple key-value format (GPT-4o mini format)
Object.entries(content.H2).forEach(([key, value]: [string, any]) => {
html += `<div class="outline-section">`;
html += `<h3 class="section-heading">${escapeHTML(value)}</h3>`;
html += `</div>`;
});
}
}
// Handle H3 as a direct property (for GPT-4o mini simple format)
if (content.H3 && !content.H2) {
html += `<div class="outline-section">`;
if (typeof content.H3 === 'string') {
html += `<h4 class="subsection-heading">${escapeHTML(content.H3)}</h4>`;
} else if (typeof content.H3 === 'object') {
Object.entries(content.H3).forEach(([key, value]: [string, any]) => {
html += `<h4 class="subsection-heading">${escapeHTML(value)}</h4>`;
});
}
html += `</div>`;
}
html += '</div>';
return html;
}
/**
* Escape HTML to prevent XSS
*/
function escapeHTML(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
const HTMLContentRenderer: React.FC<HTMLContentRendererProps> = ({
content,
className = '',
maxHeight,
}) => {
const renderedContent = useMemo(() => {
if (!content) return '<div class="text-gray-400 italic">No content available</div>';
// If content is already an object (dict), use it directly
if (typeof content === 'object' && content !== null) {
// Check for any known structure format
if (content.H2 || content.H3 || content.introduction || content.sections) {
return formatContentOutline(content);
}
// If it's an object but not structured, try to format it
try {
// Check if it has any keys that suggest it's a structured outline
const keys = Object.keys(content);
if (keys.length > 0) {
// Try to format it as outline anyway
return formatContentOutline(content);
}
return escapeHTML(JSON.stringify(content, null, 2));
} catch {
return escapeHTML(JSON.stringify(content, null, 2));
}
}
// If content is a string, try to parse as JSON first
if (typeof content === 'string') {
// Try to parse as JSON (content outline from GPT-4o mini)
try {
const parsed = JSON.parse(content);
if (typeof parsed === 'object' && (parsed.H2 || parsed.H3 || parsed.introduction || parsed.sections)) {
return formatContentOutline(parsed);
}
} catch {
// Not JSON, continue with HTML/text processing
}
// Check if it's HTML (normalized content from backend)
if (isHTML(content)) {
// Content is already normalized HTML - sanitize and return
const sanitized = sanitizeHTML(content);
// Add wrapper classes for better styling in toggle row
// Check if content already has article or wrapper
if (sanitized.trim().startsWith('<article') || sanitized.trim().startsWith('<div')) {
return `<div class="normalized-html-content">${sanitized}</div>`;
}
return `<div class="normalized-html-content"><article>${sanitized}</article></div>`;
}
// Plain text (from GPT-4o) - format bullet points and line breaks
// Convert bullet points to HTML list
const lines = content.split('\n');
let html = '<div class="content-outline">';
let inList = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (inList) {
html += '</ul>';
inList = false;
}
html += '<br>';
continue;
}
// Check for bullet points (- or *)
if (trimmed.match(/^[-*]\s+/)) {
if (!inList) {
html += '<ul class="outline-list">';
inList = true;
}
const text = trimmed.replace(/^[-*]\s+/, '');
// Check for nested bullets (indented)
if (trimmed.startsWith(' ') || trimmed.startsWith('\t')) {
html += `<li class="outline-item nested">${escapeHTML(text)}</li>`;
} else {
html += `<li class="outline-item">${escapeHTML(text)}</li>`;
}
}
// Check for H2 headings (starting with - H2:)
else if (trimmed.match(/^[-*]\s*H2[:]/i)) {
if (inList) {
html += '</ul>';
inList = false;
}
const heading = trimmed.replace(/^[-*]\s*H2[:]\s*/i, '');
html += `<h3 class="section-heading">${escapeHTML(heading)}</h3>`;
}
// Check for H3 headings (starting with - H3:)
else if (trimmed.match(/^[-*]\s*H3[:]/i)) {
if (inList) {
html += '</ul>';
inList = false;
}
const heading = trimmed.replace(/^[-*]\s*H3[:]\s*/i, '');
html += `<h4 class="subsection-heading">${escapeHTML(heading)}</h4>`;
}
// Regular paragraph
else {
if (inList) {
html += '</ul>';
inList = false;
}
html += `<p class="outline-paragraph">${escapeHTML(trimmed)}</p>`;
}
}
if (inList) {
html += '</ul>';
}
html += '</div>';
return html;
}
// Fallback: convert to string
return escapeHTML(String(content));
}, [content]);
return (
<div
className={`html-content-renderer ${className}`}
style={maxHeight ? { maxHeight, overflow: 'auto' } : undefined}
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
);
};
export default HTMLContentRenderer;

View File

@@ -0,0 +1,444 @@
import { ReactNode, useState, useEffect } from 'react';
import Button from '../ui/button/Button';
import { useToast } from '../ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface ImageGenerationCardProps {
title: string;
description?: string;
integrationId: string;
icon?: ReactNode;
}
interface GeneratedImage {
url: string;
revised_prompt?: string;
model?: string;
provider?: string;
size?: string;
format?: string;
cost?: string;
}
interface ImageSettings {
service?: string;
model?: string;
runwareModel?: string;
}
/**
* Image Generation Testing Card Component
* Full implementation with form fields and image display
*/
export default function ImageGenerationCard({
title,
description,
integrationId,
icon,
}: ImageGenerationCardProps) {
const toast = useToast();
const [isGenerating, setIsGenerating] = useState(false);
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title');
const [imageType, setImageType] = useState('realistic');
const [imageSize, setImageSize] = useState('1024x1024');
const [imageFormat, setImageFormat] = useState('webp');
const [imageSettings, setImageSettings] = useState<ImageSettings>({});
// Valid image sizes per model (from OpenAI official documentation)
const VALID_SIZES_BY_MODEL: Record<string, string[]> = {
'dall-e-3': ['1024x1024', '1024x1792', '1792x1024'],
'dall-e-2': ['256x256', '512x512', '1024x1024'],
};
// Get valid sizes for current model
const getValidSizes = (): string[] => {
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: null;
if (model && VALID_SIZES_BY_MODEL[model]) {
return VALID_SIZES_BY_MODEL[model];
}
// Default to DALL-E 3 sizes if unknown
return VALID_SIZES_BY_MODEL['dall-e-3'];
};
// Update size if current size is invalid for the selected model
useEffect(() => {
const validSizes = getValidSizes();
if (validSizes.length > 0 && !validSizes.includes(imageSize)) {
// Reset to first valid size (usually the default)
setImageSize(validSizes[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageSettings.model, imageSettings.service]); // imageSize intentionally omitted to avoid infinite loop
const [generatedImage, setGeneratedImage] = useState<GeneratedImage | null>(null);
const [error, setError] = useState<string | null>(null);
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
// Load default image generation settings on mount
useEffect(() => {
const loadImageSettings = async () => {
try {
const response = await fetch(
`${API_BASE_URL}/v1/system/settings/integrations/image_generation/`,
{ credentials: 'include' }
);
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
setImageSettings(data.data);
}
}
} catch (error) {
console.error('Error loading image settings:', error);
}
};
loadImageSettings();
}, [API_BASE_URL]);
const handleGenerate = async () => {
console.log('[ImageGenerationCard] handleGenerate called');
if (!prompt.trim()) {
toast.error('Please enter a prompt description');
return;
}
setIsGenerating(true);
setError(null);
setGeneratedImage(null);
try {
// Get the default service and model from settings
const service = imageSettings.service || 'openai';
const model = service === 'openai'
? (imageSettings.model || 'dall-e-3')
: (imageSettings.runwareModel || 'runware:97@1');
console.log('[ImageGenerationCard] Service and model:', { service, model, imageSettings });
// Build prompt with template (similar to reference plugin)
const fullPrompt = `Create a high-quality ${imageType} image. ${prompt}`;
console.log('[ImageGenerationCard] Full prompt:', fullPrompt.substring(0, 100) + '...');
const requestBody = {
prompt: fullPrompt,
negative_prompt: negativePrompt,
image_type: imageType,
image_size: imageSize,
image_format: imageFormat,
provider: service,
model: model,
};
console.log('[ImageGenerationCard] Making request to image generation endpoint');
console.log('[ImageGenerationCard] Request body:', requestBody);
const data = await fetchAPI('/v1/system/settings/integrations/image_generation/generate/', {
method: 'POST',
body: JSON.stringify(requestBody),
});
console.log('[ImageGenerationCard] Response data:', data);
if (!data.success) {
throw new Error(data.error || 'Failed to generate image');
}
const imageData = {
url: data.image_url,
revised_prompt: data.revised_prompt,
model: data.model || model,
provider: data.provider || service,
size: imageSize,
format: imageFormat.toUpperCase(),
cost: data.cost,
};
setGeneratedImage(imageData);
// Emit custom event for ImageResultCard to listen to
window.dispatchEvent(
new CustomEvent('imageGenerated', {
detail: imageData,
})
);
console.log('[ImageGenerationCard] Image generation successful:', imageData);
toast.success('Image generated successfully!');
} catch (err: any) {
console.error('[ImageGenerationCard] Error in handleGenerate:', {
error: err,
message: err.message,
stack: err.stack,
});
const errorMessage = err.message || 'Failed to generate image';
setError(errorMessage);
// Emit error event for ImageResultCard
window.dispatchEvent(
new CustomEvent('imageGenerationError', {
detail: errorMessage,
})
);
toast.error(errorMessage);
} finally {
console.log('[ImageGenerationCard] handleGenerate completed');
setIsGenerating(false);
}
};
// Get display name for provider and model
const getProviderDisplay = () => {
const service = imageSettings.service || 'openai';
if (service === 'openai') {
const model = imageSettings.model || 'dall-e-3';
const modelNames: Record<string, string> = {
'dall-e-3': 'DALL·E 3',
'dall-e-2': 'DALL·E 2',
'gpt-image-1': 'GPT Image 1 (Full)',
'gpt-image-1-mini': 'GPT Image 1 Mini',
};
return `OpenAI ${modelNames[model] || model}`;
} else {
return 'Runware';
}
};
// Image size options - dynamically generated based on selected model
const sizeLabels: Record<string, string> = {
'1024x1024': 'Square - 1024 x 1024',
'1024x1792': 'Portrait - 1024 x 1792',
'1792x1024': 'Landscape - 1792 x 1024',
'256x256': 'Small - 256 x 256',
'512x512': 'Medium - 512 x 512',
};
const sizeOptions = getValidSizes().map(size => ({
value: size,
label: sizeLabels[size] || size,
}));
// Image type options
const typeOptions = [
{ value: 'realistic', label: 'Realistic' },
{ value: 'illustration', label: 'Illustration' },
{ value: '3D render', label: '3D Render' },
{ value: 'minimalist', label: 'Minimalist' },
{ value: 'cartoon', label: 'Cartoon' },
];
// Format options
const formatOptions = [
{ value: 'webp', label: 'WEBP' },
{ value: 'jpg', label: 'JPG' },
{ value: 'png', label: 'PNG' },
];
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
{icon && (
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
<div className="space-y-5">
{/* API Provider and Model Display */}
<div className="flex items-center gap-3 rounded-lg bg-blue-50 px-4 py-3 dark:bg-blue-900/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="text-blue-600 dark:text-blue-400"
>
<path
d="M10 2L3 7V17C3 17.5304 3.21071 18.0391 3.58579 18.4142C3.96086 18.7893 4.46957 19 5 19H15C15.5304 19 16.0391 18.7893 16.4142 18.4142C16.7893 18.0391 17 17.5304 17 17V7L10 2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div>
<p className="text-xs font-medium text-blue-600 dark:text-blue-400">Provider & Model</p>
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
{getProviderDisplay()}
</p>
</div>
</div>
{/* Prompt Description - Full Width */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Prompt Description *
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={6}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe the visual elements, style, mood, and composition you want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Describe the visual elements, style, mood, and composition you want in the image.
</p>
</div>
{/* Negative Prompt - Small */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Negative Prompt
</label>
<textarea
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
rows={2}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Describe what you DON'T want in the image..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Specify elements to avoid in the generated image (text, watermarks, logos, etc.).
</p>
</div>
{/* 3 Column Dropdowns */}
<div className="grid grid-cols-3 gap-4">
{/* Image Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Type
</label>
<select
value={imageType}
onChange={(e) => setImageType(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Size */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Size
</label>
<select
value={imageSize}
onChange={(e) => setImageSize(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{sizeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Image Format */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Image Format
</label>
<select
value={imageFormat}
onChange={(e) => setImageFormat(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
>
{formatOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Generate Button - Bottom Right */}
<div className="flex justify-end">
<Button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="inline-flex items-center gap-2 px-6 py-2.5"
>
{isGenerating ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Generating...
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M21 15l-3.086-3.086a2 2 0 00-2.828 0L6 21" />
</svg>
Generate Image
</>
)}
</Button>
</div>
</div>
</div>
{/* Error display */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,253 @@
import { ReactNode, useEffect, useState } from 'react';
interface ImageResultCardProps {
title: string;
description?: string;
icon?: ReactNode;
generatedImage?: {
url: string;
revised_prompt?: string;
model?: string;
provider?: string;
size?: string;
format?: string;
cost?: string;
} | null;
error?: string | null;
}
/**
* Image Result Display Card Component
* Displays the generated image with details
*/
export default function ImageResultCard({
title,
description,
icon,
generatedImage,
error,
}: ImageResultCardProps) {
const [imageData, setImageData] = useState(generatedImage);
const [errorState, setErrorState] = useState(error);
// Listen for image generation events from ImageGenerationCard
useEffect(() => {
const handleImageGenerated = (event: CustomEvent) => {
setImageData(event.detail);
setErrorState(null);
};
const handleImageError = (event: CustomEvent) => {
setErrorState(event.detail);
setImageData(null);
};
window.addEventListener('imageGenerated', handleImageGenerated as EventListener);
window.addEventListener('imageGenerationError', handleImageError as EventListener);
return () => {
window.removeEventListener('imageGenerated', handleImageGenerated as EventListener);
window.removeEventListener('imageGenerationError', handleImageError as EventListener);
};
}, []);
useEffect(() => {
setImageData(generatedImage);
}, [generatedImage]);
useEffect(() => {
setErrorState(error);
}, [error]);
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
{icon && (
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
)}
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
{errorState ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-red-100 p-4 dark:bg-red-900/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-red-600 dark:text-red-400"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<h4 className="mb-2 text-lg font-semibold text-gray-800 dark:text-white">
Generation Failed
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">{errorState}</p>
</div>
) : imageData?.url ? (
<div className="space-y-5">
{/* Generated Image */}
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<img
src={imageData.url}
alt="Generated image"
className="w-full object-contain"
style={{ maxHeight: '400px' }}
/>
</div>
{/* Image Details */}
<div className="space-y-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
<h4 className="text-sm font-semibold text-gray-800 dark:text-white">
Image Details
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Size:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.size || '1024x1024'} pixels
</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Format:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.format || 'WEBP'}
</span>
</div>
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Model:</span>
<span className="ml-2 text-gray-800 dark:text-white">
{imageData.model || 'DALL·E 3'}
</span>
</div>
{imageData.cost && (
<div>
<span className="font-medium text-gray-600 dark:text-gray-400">Cost:</span>
<span className="ml-2 text-gray-800 dark:text-white">{imageData.cost}</span>
</div>
)}
</div>
{/* Revised Prompt */}
{imageData.revised_prompt && (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<p className="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
Revised Prompt:
</p>
<p className="text-xs text-gray-700 dark:text-gray-300">
{imageData.revised_prompt}
</p>
</div>
)}
{/* Negative Prompt (if available) */}
{imageData.negative_prompt && (
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
<p className="mb-2 text-xs font-medium text-gray-600 dark:text-gray-400">
Negative Prompt:
</p>
<p className="text-xs text-gray-700 dark:text-gray-300">
{imageData.negative_prompt}
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3">
<a
href={imageData.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Original
</a>
<button
onClick={() => {
navigator.clipboard.writeText(imageData.url);
}}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy URL
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-gray-100 p-4 dark:bg-gray-800">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-400 dark:text-gray-500"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M21 15l-3.086-3.086a2 2 0 00-2.828 0L6 21" />
</svg>
</div>
<p className="text-sm text-gray-400 dark:text-gray-500">
No image generated yet. Fill out the form and click "Generate Image" to create your
first AI image.
</p>
</div>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,187 @@
import { ReactNode, useState, useEffect } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import { usePersistentToggle } from '../../hooks/usePersistentToggle';
import { useToast } from '../ui/toast/ToastContainer';
type ValidationStatus = 'not_configured' | 'pending' | 'success' | 'error';
interface ImageServiceCardProps {
icon: ReactNode;
title: string;
description: string;
validationStatus: ValidationStatus;
onSettings: () => void;
onDetails: () => void;
}
/**
* Image Generation Service Card Component
* Manages default image generation service and model selection app-wide
* This is separate from individual API integrations (OpenAI/Runware)
*/
export default function ImageServiceCard({
icon,
title,
description,
validationStatus,
onSettings,
onDetails,
}: ImageServiceCardProps) {
const toast = useToast();
// Use built-in persistent toggle for image generation service
const persistentToggle = usePersistentToggle({
resourceId: 'image_generation',
getEndpoint: '/v1/system/settings/integrations/{id}/',
saveEndpoint: '/v1/system/settings/integrations/{id}/save/',
initialEnabled: false,
onToggleSuccess: (enabled) => {
toast.success(`Image generation service ${enabled ? 'enabled' : 'disabled'}`);
},
onToggleError: (error) => {
toast.error(`Failed to update image generation service: ${error.message}`);
},
});
const enabled = persistentToggle.enabled;
const isToggling = persistentToggle.loading;
const [imageSettings, setImageSettings] = useState<{ service?: string; model?: string; runwareModel?: string }>({});
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
// Load image settings to get provider and model
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch(
`${API_BASE_URL}/v1/system/settings/integrations/image_generation/`,
{ credentials: 'include' }
);
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
setImageSettings(data.data);
}
}
} catch (error) {
console.error('Error loading image settings:', error);
}
};
loadSettings();
}, [API_BASE_URL, enabled]); // Reload when enabled changes
const handleToggle = (newEnabled: boolean) => {
persistentToggle.toggle(newEnabled);
};
// Get provider and model display text
const getProviderModelText = () => {
const service = imageSettings.service || 'openai';
if (service === 'openai') {
const model = imageSettings.model || 'dall-e-3';
const modelNames: Record<string, string> = {
'dall-e-3': 'DALL·E 3',
'dall-e-2': 'DALL·E 2',
'gpt-image-1': 'GPT Image 1 (Full)',
'gpt-image-1-mini': 'GPT Image 1 Mini',
};
return `OpenAI ${modelNames[model] || model}`;
} else if (service === 'runware') {
const model = imageSettings.runwareModel || 'runware:97@1';
// Map model ID to display name
const modelDisplayNames: Record<string, string> = {
'runware:97@1': 'HiDream-I1 Full',
'runware:gen3a_turbo': 'Gen3a Turbo',
'runware:gen3a': 'Gen3a',
};
const displayName = modelDisplayNames[model] || model;
return `Runware ${displayName}`;
}
return 'Not configured';
};
// Get text color based on provider and status
const getTextColor = () => {
const service = imageSettings.service || 'openai';
const isConfigured = service && (imageSettings.model || imageSettings.runwareModel);
// Grey if not configured or pending
if (!isConfigured || validationStatus === 'not_configured' || validationStatus === 'pending') {
return 'text-gray-400 dark:text-gray-500';
}
// Black for both OpenAI and Runware when configured
return 'text-black dark:text-white';
};
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Provider + Model Text - Same size as heading */}
<div className="absolute top-5 right-5 h-fit">
<p className={`text-lg font-semibold ${getTextColor()} transition-colors duration-200`}>
{getProviderModelText()}
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={onSettings}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={onDetails}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={enabled}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,216 @@
import { ReactNode } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import { usePersistentToggle } from '../../hooks/usePersistentToggle';
import { useToast } from '../ui/toast/ToastContainer';
type ValidationStatus = 'not_configured' | 'pending' | 'success' | 'error';
interface IntegrationCardProps {
icon: ReactNode;
title: string;
description: string;
enabled?: boolean; // Optional - if not provided, will use persistent toggle hook
validationStatus: ValidationStatus; // 'not_configured' | 'pending' | 'success' | 'error'
onToggle?: (enabled: boolean) => void; // Optional - if not provided, will use persistent toggle hook
onSettings: () => void;
onDetails: () => void;
// Optional props for built-in persistence
integrationId?: string; // If provided, enables built-in persistence
getEndpoint?: string; // API endpoint pattern for loading (default if integrationId provided)
saveEndpoint?: string; // API endpoint pattern for saving (default if integrationId provided)
onToggleSuccess?: (enabled: boolean, data?: any) => void; // Callback when toggle succeeds - receives enabled state and full config data
onToggleError?: (error: Error) => void; // Callback when toggle fails
modelName?: string; // For Runware: display model name instead of status circle
}
export default function IntegrationCard({
icon,
title,
description,
enabled: externalEnabled,
validationStatus,
onToggle: externalOnToggle,
onSettings,
onDetails,
integrationId,
getEndpoint,
saveEndpoint,
onToggleSuccess: externalOnToggleSuccess,
onToggleError: externalOnToggleError,
modelName,
}: IntegrationCardProps) {
const toast = useToast();
// Use built-in persistent toggle if integrationId is provided
// This hook automatically loads state on mount and saves on toggle
// When using built-in persistence, we IGNORE external enabled prop to avoid conflicts
const persistentToggle = integrationId ? usePersistentToggle({
resourceId: integrationId,
getEndpoint: getEndpoint || '/v1/system/settings/integrations/{id}/',
saveEndpoint: saveEndpoint || '/v1/system/settings/integrations/{id}/save/',
initialEnabled: false, // Always start with false, let hook load from API
onToggleSuccess: (enabled, data) => {
// Show success toast
toast.success(`${integrationId} ${enabled ? 'enabled' : 'disabled'}`);
// Call external callbacks if provided - pass both enabled state and full config data
if (externalOnToggleSuccess) {
externalOnToggleSuccess(enabled, data);
}
// Don't call external onToggle when using built-in persistence
// The hook manages its own state, parent should not interfere
},
onToggleError: (error) => {
toast.error(`Failed to update ${integrationId}: ${error.message}`);
if (externalOnToggleError) {
externalOnToggleError(error);
}
},
}) : null;
// Determine which enabled state and toggle function to use
// When integrationId is provided, hook is the SINGLE source of truth
// When not provided, use external prop (backwards compatible)
const enabled = persistentToggle
? persistentToggle.enabled
: (externalEnabled ?? false);
const handleToggle = persistentToggle
? (newEnabled: boolean) => {
// Built-in persistence - automatically saves to backend
persistentToggle.toggle(newEnabled);
}
: (newEnabled: boolean) => {
// External handler mode - parent manages state
if (externalOnToggle) {
externalOnToggle(newEnabled);
}
};
const isToggling = persistentToggle ? persistentToggle.loading : false;
// Determine status circle color
const getStatusColor = () => {
if (!enabled || validationStatus === 'not_configured') {
return 'bg-gray-400 dark:bg-gray-500'; // Grey for disabled or not configured
}
if (validationStatus === 'pending') {
return 'bg-gray-400 dark:bg-gray-500 animate-pulse'; // Grey while validating (with pulse)
}
if (validationStatus === 'success') {
return 'bg-green-500 dark:bg-green-600'; // Green for success
}
if (validationStatus === 'error') {
return 'bg-red-500 dark:bg-red-600'; // Red for error
}
return 'bg-gray-400 dark:bg-gray-500'; // Default grey
};
// Get status text and color
const getStatusText = () => {
if (!enabled || validationStatus === 'not_configured') {
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'pending') {
return { text: 'Pending', color: 'text-gray-400 dark:text-gray-500', bold: false };
}
if (validationStatus === 'success') {
return { text: 'Enabled', color: 'text-gray-800 dark:text-white', bold: true };
}
if (validationStatus === 'error') {
return { text: 'Error', color: 'text-red-600 dark:text-red-400', bold: false };
}
return { text: 'Disabled', color: 'text-gray-400 dark:text-gray-500', bold: false };
};
const statusText = getStatusText();
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Status Text and Circle - Same row */}
{/* For Runware: Show model name instead of circle */}
{integrationId === 'runware' ? (
<div className="absolute top-5 right-5">
<span className={`text-sm font-semibold ${modelName ? 'text-gray-800 dark:text-white' : 'text-gray-400 dark:text-gray-500'}`}>
{modelName || 'Disabled'}
</span>
</div>
) : (
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
{statusText.text}
</span>
<div className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
title={
validationStatus === 'not_configured' ? 'Not configured' :
validationStatus === 'pending' ? 'Validating...' :
validationStatus === 'success' ? 'Validated successfully' :
validationStatus === 'error' ? 'Validation failed' : 'Unknown status'
}
/>
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={onSettings}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={onDetails}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={enabled}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState, useRef } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
interface LoadingState {
source: string;
startTime: number;
duration: number;
}
const loadingStates = new Map<string, LoadingState>();
const listeners = new Set<(states: LoadingState[]) => void>();
export function trackLoading(source: string, isLoading: boolean) {
if (isLoading) {
loadingStates.set(source, {
source,
startTime: Date.now(),
duration: 0,
});
} else {
loadingStates.delete(source);
}
listeners.forEach(listener => {
const states = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
listener(states);
});
}
export default function LoadingStateMonitor() {
const [localLoadingStates, setLocalLoadingStates] = useState<LoadingState[]>([]);
const { addError } = useErrorHandler('LoadingStateMonitor');
const reportedStuckStates = useRef<Set<string>>(new Set());
const addErrorRef = useRef(addError);
// Keep addError ref updated
useEffect(() => {
addErrorRef.current = addError;
}, [addError]);
useEffect(() => {
const updateStates = (statesFromListener: LoadingState[]) => {
// Use states from listener (always provided by trackLoading)
const states = statesFromListener.filter(s => s.duration < 60000); // Only show states less than 60 seconds old
setLocalLoadingStates(states);
// Detect stuck loading states (more than 5 seconds) - only report once per state
const stuck = states.filter(s => s.duration > 5000 && !reportedStuckStates.current.has(s.source));
if (stuck.length > 0) {
stuck.forEach(state => {
reportedStuckStates.current.add(state.source);
// Use ref to avoid dependency issues
addErrorRef.current(
new Error(`Loading state stuck: ${state.source} (${(state.duration / 1000).toFixed(1)}s)`),
'LoadingStateMonitor'
);
});
}
// Clean up reported states that are no longer stuck
const noLongerStuck = Array.from(reportedStuckStates.current).filter(
source => !states.find(s => s.source === source && s.duration > 5000)
);
noLongerStuck.forEach(source => reportedStuckStates.current.delete(source));
};
// Initial update from global Map
const initialStates = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
updateStates(initialStates);
listeners.add(updateStates);
// Periodic check (in case listener doesn't fire)
const interval = setInterval(() => {
const currentStates = Array.from(loadingStates.values()).map(s => ({
...s,
duration: Date.now() - s.startTime,
}));
updateStates(currentStates);
}, 1000);
return () => {
listeners.delete(updateStates);
clearInterval(interval);
};
}, []); // Empty deps - updateStates reads from global Map via listeners
// Auto-reset stuck loading states after 10 seconds
useEffect(() => {
const stuck = localLoadingStates.filter(s => s.duration > 10000);
if (stuck.length > 0) {
stuck.forEach(state => {
console.warn(`Auto-resetting stuck loading state: ${state.source}`);
trackLoading(state.source, false);
reportedStuckStates.current.delete(state.source);
});
}
}, [localLoadingStates]);
return null; // This component doesn't render anything visible
}

View File

@@ -0,0 +1,51 @@
import { Link } from "react-router";
interface BreadcrumbProps {
pageTitle: string;
}
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<h2
className="text-xl font-semibold text-gray-800 dark:text-white/90"
x-text="pageName"
>
{pageTitle}
</h2>
<nav>
<ol className="flex items-center gap-1.5">
<li>
<Link
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
to="/"
>
Home
<svg
className="stroke-current"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</li>
<li className="text-sm text-gray-800 dark:text-white/90">
{pageTitle}
</li>
</ol>
</nav>
</div>
);
};
export default PageBreadcrumb;

View File

@@ -0,0 +1,89 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { useErrorHandler } from '../../hooks/useErrorHandler';
interface Props {
children: ReactNode;
fallback?: ReactNode;
pageName?: string;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class PageErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(`[${this.props.pageName || 'Page'}] Error:`, error, errorInfo);
// Error will be caught by GlobalErrorDisplay via useErrorHandler
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-[400px] p-6">
<div className="text-center max-w-md">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-4">
Something went wrong
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred on this page'}
</p>
<div className="space-x-3">
<button
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.reload();
}}
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600"
>
Reload Page
</button>
<button
onClick={() => {
this.setState({ hasError: false, error: null });
}}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
>
Try Again
</button>
</div>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 text-left">
<summary className="cursor-pointer text-sm text-gray-500 dark:text-gray-400">
Error Details (Dev Mode)
</summary>
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-64">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,20 @@
import { HelmetProvider, Helmet } from "react-helmet-async";
const PageMeta = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
</Helmet>
);
export const AppWrapper = ({ children }: { children: React.ReactNode }) => (
<HelmetProvider>{children}</HelmetProvider>
);
export default PageMeta;

View File

@@ -0,0 +1,201 @@
import React, { useEffect } from 'react';
import { Modal } from '../ui/modal';
import { ProgressBar } from '../ui/progress';
import Button from '../ui/button/Button';
export interface ProgressModalProps {
isOpen: boolean;
title: string;
percentage: number; // 0-100
status: 'pending' | 'processing' | 'completed' | 'error';
message: string;
details?: {
current: number;
total: number;
completed: number;
currentItem?: string;
phase?: string;
};
onClose?: () => void;
onCancel?: () => void;
taskId?: string;
}
export default function ProgressModal({
isOpen,
title,
percentage,
status,
message,
details,
onClose,
onCancel,
taskId,
}: ProgressModalProps) {
// Auto-close on completion after 2 seconds
// Don't auto-close on error - let user manually close to see error details
useEffect(() => {
if (status === 'completed' && onClose) {
const timer = setTimeout(() => {
onClose();
}, 2000);
return () => clearTimeout(timer);
}
// Don't auto-close on error - user should manually dismiss
}, [status, onClose]);
// Determine color based on status
const getProgressColor = (): 'primary' | 'success' | 'error' | 'warning' => {
if (status === 'error') return 'error';
if (status === 'completed') return 'success';
if (status === 'processing') return 'primary';
return 'primary';
};
// Get status icon
const getStatusIcon = () => {
if (status === 'completed') {
return (
<svg
className="w-6 h-6 text-success-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
);
}
if (status === 'error') {
return (
<svg
className="w-6 h-6 text-error-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
}
// Processing/Pending - spinner
return (
<svg
className="w-6 h-6 text-brand-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-lg"
showCloseButton={status === 'completed' || status === 'error'}
>
<div className="p-6">
{/* Header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0 mt-1">{getStatusIcon()}</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{message}</p>
</div>
</div>
{/* Progress Bar */}
<div className="mb-6">
<ProgressBar
value={percentage}
color={getProgressColor()}
size="lg"
showLabel={true}
label={`${Math.round(percentage)}%`}
/>
</div>
{/* Details */}
{details && (
<div className="mb-6 space-y-2">
{details.currentItem && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">Current:</span>{' '}
<span className="text-gray-600 dark:text-gray-400">
{details.currentItem}
</span>
</div>
)}
{details.total > 0 && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-medium">Progress:</span>{' '}
<span className="text-gray-600 dark:text-gray-400">
{details.current} of {details.total} completed
</span>
</div>
)}
{details.phase && (
<div className="text-xs text-gray-500 dark:text-gray-500">
Phase: {details.phase}
</div>
)}
</div>
)}
{/* Task ID (for debugging) */}
{taskId && import.meta.env.DEV && (
<div className="mb-4 text-xs text-gray-400 dark:text-gray-600">
Task ID: {taskId}
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3">
{onCancel && status !== 'completed' && status !== 'error' && (
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={status === 'processing'}
>
Cancel
</Button>
)}
{(status === 'completed' || status === 'error') && onClose && (
<Button variant="primary" size="sm" onClick={onClose}>
{status === 'completed' ? 'Close' : 'Dismiss'}
</Button>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useLocation } from "react-router";
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,149 @@
/**
* Sector Selector Component
* Displays a dropdown to select sectors for the active site
* Used in the header area of pages that need sector filtering
*/
import { useState, useEffect, useRef } from 'react';
import { Dropdown } from '../ui/dropdown/Dropdown';
import { DropdownItem } from '../ui/dropdown/DropdownItem';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
export default function SectorSelector() {
const { activeSite } = useSiteStore();
const { activeSector, sectors, setActiveSector, loading } = useSectorStore();
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
// Don't render if no active site
if (!activeSite) {
return null;
}
// Don't render if no sectors available
if (!loading && sectors.length === 0) {
return (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
<span>No sectors available</span>
</div>
);
}
const handleSectorSelect = (sectorId: number | null) => {
if (sectorId === null) {
// "All Sectors" option
setActiveSector(null);
setIsOpen(false);
} else {
const sector = sectors.find(s => s.id === sectorId);
if (sector) {
setActiveSector(sector);
setIsOpen(false);
}
}
};
return (
<div className="relative inline-block">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:bg-gray-700 dropdown-toggle"
aria-label="Select sector"
disabled={loading || sectors.length === 0}
>
<span className="flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<span className="max-w-[150px] truncate">
{loading ? 'Loading...' : activeSector?.name || 'All Sectors'}
</span>
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Dropdown
isOpen={isOpen}
onClose={() => setIsOpen(false)}
anchorRef={buttonRef}
placement="bottom-right"
className="w-64 p-2 overflow-y-auto max-h-[300px]"
>
{/* "All Sectors" option */}
<DropdownItem
onItemClick={() => handleSectorSelect(null)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
!activeSector
? "bg-blue-50 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">All Sectors</span>
{!activeSector && (
<svg
className="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
{sectors.map((sector) => (
<DropdownItem
key={sector.id}
onItemClick={() => handleSectorSelect(sector.id)}
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
activeSector?.id === sector.id
? "bg-blue-50 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
}`}
>
<span className="flex-1">{sector.name}</span>
{activeSector?.id === sector.id && (
<svg
className="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</DropdownItem>
))}
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { ReactNode } from 'react';
import Switch from '../form/switch/Switch';
import Button from '../ui/button/Button';
import Badge from '../ui/badge/Badge';
import { Site } from '../../services/api';
interface SiteCardProps {
site: Site;
icon: ReactNode;
onToggle: (siteId: number, enabled: boolean) => void;
onSettings: (site: Site) => void;
onDetails: (site: Site) => void;
isToggling?: boolean;
}
export default function SiteCard({
site,
icon,
onToggle,
onSettings,
onDetails,
isToggling = false,
}: SiteCardProps) {
const handleToggle = (enabled: boolean) => {
onToggle(site.id, enabled);
};
const getStatusColor = () => {
if (site.is_active) {
return 'bg-green-500 dark:bg-green-600';
}
return 'bg-gray-400 dark:bg-gray-500';
};
const getStatusText = () => {
if (site.is_active) {
return { text: 'Active', color: 'text-green-600 dark:text-green-400', bold: true };
}
return { text: 'Inactive', color: 'text-gray-400 dark:text-gray-500', bold: false };
};
const statusText = getStatusText();
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-9">
<div className="mb-5 inline-flex h-10 w-10 items-center justify-center">
{icon}
</div>
<h3 className="mb-3 text-lg font-semibold text-gray-800 dark:text-white/90">
{site.name}
</h3>
<p className="max-w-xs text-sm text-gray-500 dark:text-gray-400 mb-2">
{site.description || 'No description'}
</p>
{site.domain && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-2">
{site.domain}
</p>
)}
<div className="flex items-center gap-2 mb-2 flex-wrap">
{site.industry_name && (
<Badge variant="light" color="info" className="text-xs">
{site.industry_name}
</Badge>
)}
<Badge variant="light" color="info" className="text-xs">
{site.active_sectors_count} / 5 Sectors
</Badge>
{site.status && (
<Badge variant="light" color={site.status === 'active' ? 'success' : 'dark'} className="text-xs">
{site.status}
</Badge>
)}
</div>
{/* Status Text and Circle - Same row */}
<div className="absolute top-5 right-5 flex items-center gap-2">
<span className={`text-sm ${statusText.color} ${statusText.bold ? 'font-bold' : ''} transition-colors duration-200`}>
{statusText.text}
</span>
<div
className={`w-[25px] h-[25px] rounded-full ${getStatusColor()} transition-colors duration-200`}
title={site.is_active ? 'Active site' : 'Inactive site'}
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 p-5 dark:border-gray-800">
<div className="flex gap-3">
<Button
variant="outline"
size="md"
onClick={() => onSettings(site)}
className="shadow-theme-xs inline-flex h-11 w-11 items-center justify-center rounded-lg border border-gray-300 text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
>
<path
d="M5.64615 4.59906C5.05459 4.25752 4.29808 4.46015 3.95654 5.05171L2.69321 7.23986C2.35175 7.83128 2.5544 8.58754 3.14582 8.92899C3.97016 9.40493 3.97017 10.5948 3.14583 11.0707C2.55441 11.4122 2.35178 12.1684 2.69323 12.7598L3.95657 14.948C4.2981 15.5395 5.05461 15.7422 5.64617 15.4006C6.4706 14.9247 7.50129 15.5196 7.50129 16.4715C7.50129 17.1545 8.05496 17.7082 8.73794 17.7082H11.2649C11.9478 17.7082 12.5013 17.1545 12.5013 16.4717C12.5013 15.5201 13.5315 14.9251 14.3556 15.401C14.9469 15.7423 15.7029 15.5397 16.0443 14.9485L17.3079 12.7598C17.6494 12.1684 17.4467 11.4121 16.8553 11.0707C16.031 10.5948 16.031 9.40494 16.8554 8.92902C17.4468 8.58757 17.6494 7.83133 17.3079 7.23992L16.0443 5.05123C15.7029 4.45996 14.9469 4.25737 14.3556 4.59874C13.5315 5.07456 12.5013 4.47961 12.5013 3.52798C12.5013 2.84515 11.9477 2.2915 11.2649 2.2915L8.73795 2.2915C8.05496 2.2915 7.50129 2.84518 7.50129 3.52816C7.50129 4.48015 6.47059 5.07505 5.64615 4.59906Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.5714 9.99977C12.5714 11.4196 11.4204 12.5706 10.0005 12.5706C8.58069 12.5706 7.42969 11.4196 7.42969 9.99977C7.42969 8.57994 8.58069 7.42894 10.0005 7.42894C11.4204 7.42894 12.5714 8.57994 12.5714 9.99977Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Button>
<Button
variant="outline"
size="md"
onClick={() => onDetails(site)}
className="shadow-theme-xs inline-flex h-11 items-center justify-center rounded-lg border border-gray-300 px-4 py-3 text-sm font-medium text-gray-700 dark:border-gray-700 dark:text-gray-400"
>
Details
</Button>
</div>
<Switch
label=""
checked={site.is_active}
disabled={isToggling}
onChange={handleToggle}
/>
</div>
</article>
);
}

View File

@@ -0,0 +1,41 @@
import { useTheme } from "../../context/ThemeContext";
export const ThemeToggleButton: React.FC = () => {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full hover:text-dark-900 h-11 w-11 hover:bg-gray-100 hover:text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
};

View File

@@ -0,0 +1,40 @@
import { useTheme } from "../../context/ThemeContext";
export default function ThemeTogglerTwo() {
const { toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="inline-flex items-center justify-center text-white transition-colors rounded-full size-14 bg-brand-500 hover:bg-brand-600"
>
<svg
className="hidden dark:block"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99998 1.5415C10.4142 1.5415 10.75 1.87729 10.75 2.2915V3.5415C10.75 3.95572 10.4142 4.2915 9.99998 4.2915C9.58577 4.2915 9.24998 3.95572 9.24998 3.5415V2.2915C9.24998 1.87729 9.58577 1.5415 9.99998 1.5415ZM10.0009 6.79327C8.22978 6.79327 6.79402 8.22904 6.79402 10.0001C6.79402 11.7712 8.22978 13.207 10.0009 13.207C11.772 13.207 13.2078 11.7712 13.2078 10.0001C13.2078 8.22904 11.772 6.79327 10.0009 6.79327ZM5.29402 10.0001C5.29402 7.40061 7.40135 5.29327 10.0009 5.29327C12.6004 5.29327 14.7078 7.40061 14.7078 10.0001C14.7078 12.5997 12.6004 14.707 10.0009 14.707C7.40135 14.707 5.29402 12.5997 5.29402 10.0001ZM15.9813 5.08035C16.2742 4.78746 16.2742 4.31258 15.9813 4.01969C15.6884 3.7268 15.2135 3.7268 14.9207 4.01969L14.0368 4.90357C13.7439 5.19647 13.7439 5.67134 14.0368 5.96423C14.3297 6.25713 14.8045 6.25713 15.0974 5.96423L15.9813 5.08035ZM18.4577 10.0001C18.4577 10.4143 18.1219 10.7501 17.7077 10.7501H16.4577C16.0435 10.7501 15.7077 10.4143 15.7077 10.0001C15.7077 9.58592 16.0435 9.25013 16.4577 9.25013H17.7077C18.1219 9.25013 18.4577 9.58592 18.4577 10.0001ZM14.9207 15.9806C15.2135 16.2735 15.6884 16.2735 15.9813 15.9806C16.2742 15.6877 16.2742 15.2128 15.9813 14.9199L15.0974 14.036C14.8045 13.7431 14.3297 13.7431 14.0368 14.036C13.7439 14.3289 13.7439 14.8038 14.0368 15.0967L14.9207 15.9806ZM9.99998 15.7088C10.4142 15.7088 10.75 16.0445 10.75 16.4588V17.7088C10.75 18.123 10.4142 18.4588 9.99998 18.4588C9.58577 18.4588 9.24998 18.123 9.24998 17.7088V16.4588C9.24998 16.0445 9.58577 15.7088 9.99998 15.7088ZM5.96356 15.0972C6.25646 14.8043 6.25646 14.3295 5.96356 14.0366C5.67067 13.7437 5.1958 13.7437 4.9029 14.0366L4.01902 14.9204C3.72613 15.2133 3.72613 15.6882 4.01902 15.9811C4.31191 16.274 4.78679 16.274 5.07968 15.9811L5.96356 15.0972ZM4.29224 10.0001C4.29224 10.4143 3.95645 10.7501 3.54224 10.7501H2.29224C1.87802 10.7501 1.54224 10.4143 1.54224 10.0001C1.54224 9.58592 1.87802 9.25013 2.29224 9.25013H3.54224C3.95645 9.25013 4.29224 9.58592 4.29224 10.0001ZM4.9029 5.9637C5.1958 6.25659 5.67067 6.25659 5.96356 5.9637C6.25646 5.6708 6.25646 5.19593 5.96356 4.90303L5.07968 4.01915C4.78679 3.72626 4.31191 3.72626 4.01902 4.01915C3.72613 4.31204 3.72613 4.78692 4.01902 5.07981L4.9029 5.9637Z"
fill="currentColor"
/>
</svg>
<svg
className="dark:hidden"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.4547 11.97L18.1799 12.1611C18.265 11.8383 18.1265 11.4982 17.8401 11.3266C17.5538 11.1551 17.1885 11.1934 16.944 11.4207L17.4547 11.97ZM8.0306 2.5459L8.57989 3.05657C8.80718 2.81209 8.84554 2.44682 8.67398 2.16046C8.50243 1.8741 8.16227 1.73559 7.83948 1.82066L8.0306 2.5459ZM12.9154 13.0035C9.64678 13.0035 6.99707 10.3538 6.99707 7.08524H5.49707C5.49707 11.1823 8.81835 14.5035 12.9154 14.5035V13.0035ZM16.944 11.4207C15.8869 12.4035 14.4721 13.0035 12.9154 13.0035V14.5035C14.8657 14.5035 16.6418 13.7499 17.9654 12.5193L16.944 11.4207ZM16.7295 11.7789C15.9437 14.7607 13.2277 16.9586 10.0003 16.9586V18.4586C13.9257 18.4586 17.2249 15.7853 18.1799 12.1611L16.7295 11.7789ZM10.0003 16.9586C6.15734 16.9586 3.04199 13.8433 3.04199 10.0003H1.54199C1.54199 14.6717 5.32892 18.4586 10.0003 18.4586V16.9586ZM3.04199 10.0003C3.04199 6.77289 5.23988 4.05695 8.22173 3.27114L7.83948 1.82066C4.21532 2.77574 1.54199 6.07486 1.54199 10.0003H3.04199ZM6.99707 7.08524C6.99707 5.52854 7.5971 4.11366 8.57989 3.05657L7.48132 2.03522C6.25073 3.35885 5.49707 5.13487 5.49707 7.08524H6.99707Z"
fill="currentColor"
/>
</svg>
</button>
);
}

View File

@@ -0,0 +1,173 @@
/**
* ToggleTableRow Component
* Reusable component for displaying long HTML content in table rows with expand/collapse functionality
*/
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, HorizontaLDots } from '../../icons';
import HTMLContentRenderer from './HTMLContentRenderer';
interface ToggleTableRowProps {
/** The row data */
row: any;
/** The column key that contains the toggleable content */
contentKey: string;
/** Custom label for the expanded content (e.g., "Content Outline", "Description") */
contentLabel?: string;
/** Column span for the expanded row (should match number of columns in table) */
colSpan: number;
/** Whether the row is expanded (controlled) */
isExpanded?: boolean;
/** Whether the row is initially expanded (uncontrolled) */
defaultExpanded?: boolean;
/** Callback when toggle state changes */
onToggle?: (expanded: boolean, rowId: string | number) => void;
/** Custom className */
className?: string;
}
const ToggleTableRow: React.FC<ToggleTableRowProps> = ({
row,
contentKey,
contentLabel = 'Content',
colSpan,
isExpanded: controlledExpanded,
defaultExpanded = false,
onToggle,
className = '',
}) => {
// Use controlled state if provided, otherwise use internal state
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
const contentRef = useRef<HTMLDivElement>(null);
// Get content - handle fallback to description if primary contentKey is empty
let content = row[contentKey];
if (!content || (typeof content === 'string' && content.trim().length === 0)) {
// Try fallback to description if primary content is empty
content = row.description || row.content_outline || null;
}
// Check if content exists - handle both strings and objects
const hasContent = content && (
typeof content === 'string'
? content.trim().length > 0
: typeof content === 'object' && content !== null && Object.keys(content).length > 0
);
useEffect(() => {
if (isExpanded && contentRef.current) {
// Measure content height for smooth animation
const height = contentRef.current.scrollHeight;
setContentHeight(height);
} else {
setContentHeight(0);
}
}, [isExpanded, content]);
const handleToggle = () => {
if (!hasContent) return;
const newExpanded = !isExpanded;
// Update internal state if uncontrolled
if (controlledExpanded === undefined) {
setInternalExpanded(newExpanded);
}
// Notify parent
if (onToggle) {
onToggle(newExpanded, row.id ?? row.id);
}
};
if (!hasContent) {
return null;
}
// Don't render anything if not expanded - no row HTML before toggle
if (!isExpanded) {
return null;
}
return (
<tr
className={`toggle-content-row expanded ${className}`}
aria-hidden={false}
>
<td
colSpan={colSpan}
className="px-5 py-0 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-white/[0.05]"
>
<div
ref={contentRef}
className="overflow-hidden"
>
<div className="py-4 px-2">
<div className="mb-2 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 tracking-wide">
{contentLabel}
</div>
<div className="html-content-wrapper">
<HTMLContentRenderer
content={content}
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
/>
</div>
</div>
</div>
</td>
</tr>
);
};
/**
* Toggle Button Component - To be used in table cells
*/
interface ToggleButtonProps {
/** Whether the row is expanded */
isExpanded: boolean;
/** Click handler */
onClick: () => void;
/** Whether content exists */
hasContent: boolean;
/** Custom className */
className?: string;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
isExpanded,
onClick,
hasContent,
className = '',
}) => {
if (!hasContent) {
return (
<span className={`inline-flex items-center justify-center w-8 h-8 text-gray-300 dark:text-gray-600 ${className}`}>
<HorizontaLDots className="w-4 h-4" />
</span>
);
}
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200 ${
isExpanded
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${className}`}
aria-label={isExpanded ? 'Collapse content' : 'Expand content'}
aria-expanded={isExpanded}
>
<ChevronDownIcon
className={`w-4 h-4 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
);
};
export default ToggleTableRow;

View File

@@ -0,0 +1,269 @@
import { ReactNode, useState } from 'react';
import Button from '../ui/button/Button';
import { fetchAPI } from '../../services/api';
interface ValidationCardProps {
title: string;
description?: string;
integrationId: string;
icon?: ReactNode;
}
interface TestResult {
success: boolean;
message: string;
model_used?: string;
response?: string;
tokens_used?: string;
total_tokens?: number;
cost?: string;
full_response?: any;
}
/**
* Validation Card Component
* Two-way response validation testing for OpenAI API
* Matches reference plugin implementation exactly
*/
export default function ValidationCard({
title,
description,
integrationId,
icon,
}: ValidationCardProps) {
const [isLoading, setIsLoading] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [withResponse, setWithResponse] = useState(false);
// Support OpenAI and Runware
if (integrationId !== 'openai' && integrationId !== 'runware') {
return null;
}
const testApiConnection = async (withResponseTest: boolean = false) => {
setIsLoading(true);
setWithResponse(withResponseTest);
setTestResult(null);
try {
// Get saved settings to get API key and model
const settingsData = await fetchAPI(`/v1/system/settings/integrations/${integrationId}/`);
let apiKey = '';
let model = 'gpt-4.1';
if (settingsData.success && settingsData.data) {
apiKey = settingsData.data.apiKey || '';
model = settingsData.data.model || 'gpt-4.1';
}
if (!apiKey) {
setTestResult({
success: false,
message: 'API key not configured. Please configure your API key in settings first.',
});
setIsLoading(false);
return;
}
// Call test endpoint
// For Runware, we don't need with_response or model config
const requestBody: any = {
apiKey: apiKey,
};
if (integrationId === 'openai') {
requestBody.config = {
model: model,
with_response: withResponseTest,
};
}
const data = await fetchAPI(`/v1/system/settings/integrations/${integrationId}/test/`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (data.success) {
setTestResult({
success: true,
message: data.message || 'API connection successful!',
model_used: data.model_used || data.model,
response: data.response,
tokens_used: data.tokens_used,
total_tokens: data.total_tokens,
cost: data.cost,
full_response: data.full_response || {
image_url: data.image_url,
provider: data.provider,
size: data.size,
},
});
} else {
setTestResult({
success: false,
message: data.error || data.message || 'API connection failed',
});
}
} catch (error: any) {
setTestResult({
success: false,
message: `API connection failed: ${error.message || 'Unknown error'}`,
});
} finally {
setIsLoading(false);
}
};
return (
<article className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/3">
<div className="relative p-5 pb-6">
<h3 className="mb-2 text-base font-semibold text-gray-800 dark:text-white/90">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="border-t border-gray-200 p-5 dark:border-gray-800">
<div className="space-y-4">
{/* Test Buttons */}
<div className="flex gap-3">
{integrationId === 'openai' ? (
<>
<Button
variant="outline"
onClick={() => testApiConnection(false)}
disabled={isLoading}
className="flex-1"
>
{isLoading && !withResponse ? 'Testing...' : 'Test OpenAI Connection'}
</Button>
<Button
variant="outline"
onClick={() => testApiConnection(true)}
disabled={isLoading}
className="flex-1"
>
{isLoading && withResponse ? 'Testing...' : 'Test OpenAI Response (Ping)'}
</Button>
</>
) : (
// Runware: Single button for 128x128 image generation validation
<Button
variant="outline"
onClick={() => testApiConnection(false)}
disabled={isLoading}
className="flex-1"
>
{isLoading ? 'Testing...' : 'Test Runware Connection'}
</Button>
)}
</div>
{/* Test Results */}
{testResult && (
<div className="space-y-3">
{/* Success Message */}
{testResult.success && (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">{testResult.message}</span>
</div>
)}
{/* Error Message */}
{!testResult.success && (
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm font-medium">{testResult.message}</span>
</div>
)}
{/* Detailed Results Box */}
{testResult.success && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 p-4 rounded">
<div className="space-y-2 text-sm">
{integrationId === 'openai' && withResponse ? (
// OpenAI response test details
<>
<div>
<strong className="text-gray-700 dark:text-gray-300">Model Used:</strong>{' '}
<span className="text-gray-900 dark:text-white font-mono-custom">{testResult.model_used || 'N/A'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Expected:</strong>{' '}
<span className="text-gray-900 dark:text-white">"OK! Ping Received"</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Actual Response:</strong>{' '}
<span className="text-gray-900 dark:text-white">"{testResult.response || 'N/A'}"</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Token Limit Sent:</strong>{' '}
<span className="text-gray-900 dark:text-white">N/A (from your settings)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Tokens Used:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.tokens_used || 'N/A'} (input/output)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Total Tokens:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.total_tokens || 'N/A'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Cost:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.cost || '$0.0000'}</span>
</div>
</>
) : integrationId === 'runware' ? (
// Runware image generation test details
<>
<div>
<strong className="text-gray-700 dark:text-gray-300">Provider:</strong>{' '}
<span className="text-gray-900 dark:text-white">Runware</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Model:</strong>{' '}
<span className="text-gray-900 dark:text-white font-mono-custom">{testResult.model_used || 'runware:97@1'}</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Image Size:</strong>{' '}
<span className="text-gray-900 dark:text-white">128 x 128 (test image)</span>
</div>
<div>
<strong className="text-gray-700 dark:text-gray-300">Cost:</strong>{' '}
<span className="text-gray-900 dark:text-white">{testResult.cost || '$0.0360'}</span>
</div>
{testResult.full_response?.image_url && (
<div>
<strong className="text-gray-700 dark:text-gray-300">Test Image:</strong>{' '}
<a
href={testResult.full_response.image_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
View Image
</a>
</div>
)}
</>
) : null}
</div>
</div>
)}
</div>
)}
</div>
</div>
</article>
);
}