Phase 6
This commit is contained in:
@@ -76,6 +76,7 @@ const Industries = lazy(() => import("./pages/Settings/Industries"));
|
||||
const Status = lazy(() => import("./pages/Settings/Status"));
|
||||
const ApiMonitor = lazy(() => import("./pages/Settings/ApiMonitor"));
|
||||
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
||||
const Publishing = lazy(() => import("./pages/Settings/Publishing"));
|
||||
const Sites = lazy(() => import("./pages/Settings/Sites"));
|
||||
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
|
||||
|
||||
@@ -419,6 +420,11 @@ export default function App() {
|
||||
<Integration />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/publishing" element={
|
||||
<Suspense fallback={null}>
|
||||
<Publishing />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/sites" element={
|
||||
<Suspense fallback={null}>
|
||||
<Sites />
|
||||
|
||||
83
frontend/src/components/integration/IntegrationStatus.tsx
Normal file
83
frontend/src/components/integration/IntegrationStatus.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Integration Status Component
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React from 'react';
|
||||
import { CheckCircleIcon, XCircleIcon, ClockIcon, SyncIcon } from 'lucide-react';
|
||||
|
||||
interface IntegrationStatusProps {
|
||||
syncEnabled: boolean;
|
||||
syncStatus: 'success' | 'failed' | 'pending' | 'syncing';
|
||||
lastSyncAt: string | null;
|
||||
syncError?: string | null;
|
||||
}
|
||||
|
||||
export default function IntegrationStatus({
|
||||
syncEnabled,
|
||||
syncStatus,
|
||||
lastSyncAt,
|
||||
syncError,
|
||||
}: IntegrationStatusProps) {
|
||||
const getStatusIcon = () => {
|
||||
switch (syncStatus) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||
case 'syncing':
|
||||
return <SyncIcon className="w-5 h-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <ClockIcon className="w-5 h-5 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (syncStatus) {
|
||||
case 'success':
|
||||
return 'Synced';
|
||||
case 'failed':
|
||||
return 'Sync Failed';
|
||||
case 'syncing':
|
||||
return 'Syncing...';
|
||||
default:
|
||||
return 'Not Synced';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{syncEnabled && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<div>Last sync: {formatDate(lastSyncAt)}</div>
|
||||
{syncError && (
|
||||
<div className="text-red-600 dark:text-red-400 mt-1">
|
||||
Error: {syncError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!syncEnabled && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Two-way sync is disabled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/components/integration/PlatformSelector.tsx
Normal file
31
frontend/src/components/integration/PlatformSelector.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Platform Selector Component
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React from 'react';
|
||||
import SelectDropdown from '../form/SelectDropdown';
|
||||
|
||||
interface PlatformSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'shopify', label: 'Shopify' },
|
||||
{ value: 'custom', label: 'Custom API' },
|
||||
];
|
||||
|
||||
export default function PlatformSelector({ value, onChange, disabled }: PlatformSelectorProps) {
|
||||
return (
|
||||
<SelectDropdown
|
||||
options={PLATFORMS}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder="Select platform"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
339
frontend/src/components/integration/SiteIntegrationsSection.tsx
Normal file
339
frontend/src/components/integration/SiteIntegrationsSection.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Site Integrations Section
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlusIcon, TrashIcon, TestTubeIcon, SyncIcon } from 'lucide-react';
|
||||
import Button from '../ui/button/Button';
|
||||
import { Modal } from '../ui/modal';
|
||||
import FormModal, { FormField } from '../common/FormModal';
|
||||
import PlatformSelector from './PlatformSelector';
|
||||
import IntegrationStatus from './IntegrationStatus';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
interface SiteIntegration {
|
||||
id: number;
|
||||
site: number;
|
||||
site_name?: string;
|
||||
platform: string;
|
||||
platform_type: string;
|
||||
is_active: boolean;
|
||||
sync_enabled: boolean;
|
||||
sync_status: 'success' | 'failed' | 'pending' | 'syncing';
|
||||
last_sync_at: string | null;
|
||||
sync_error: string | null;
|
||||
}
|
||||
|
||||
interface SiteIntegrationsSectionProps {
|
||||
siteId?: number;
|
||||
}
|
||||
|
||||
export default function SiteIntegrationsSection({ siteId }: SiteIntegrationsSectionProps) {
|
||||
const toast = useToast();
|
||||
const [integrations, setIntegrations] = useState<SiteIntegration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<SiteIntegration | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadIntegrations();
|
||||
}, [siteId]);
|
||||
|
||||
const loadIntegrations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = siteId ? `?site=${siteId}` : '';
|
||||
const data = await fetchAPI(`/v1/integration/integrations/${params}`);
|
||||
if (data && Array.isArray(data)) {
|
||||
setIntegrations(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load integrations: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setSelectedIntegration(null);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (integration: SiteIntegration) => {
|
||||
setSelectedIntegration(integration);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this integration?')) return;
|
||||
|
||||
try {
|
||||
await fetchAPI(`/v1/integration/integrations/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
toast.success('Integration deleted successfully');
|
||||
loadIntegrations();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to delete integration: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (integration: SiteIntegration) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/test_connection/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (data?.success) {
|
||||
toast.success(data.message || 'Connection test successful');
|
||||
} else {
|
||||
toast.error(data?.message || 'Connection test failed');
|
||||
}
|
||||
loadIntegrations();
|
||||
} catch (error: any) {
|
||||
toast.error(`Connection test failed: ${error.message}`);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async (integration: SiteIntegration) => {
|
||||
try {
|
||||
const data = await fetchAPI(`/v1/integration/integrations/${integration.id}/sync/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
direction: 'both',
|
||||
}),
|
||||
});
|
||||
if (data?.success) {
|
||||
toast.success(`Sync completed. ${data.synced_count || 0} items synced`);
|
||||
} else {
|
||||
toast.error('Sync failed');
|
||||
}
|
||||
loadIntegrations();
|
||||
} catch (error: any) {
|
||||
toast.error(`Sync failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
site: siteId || formData.site,
|
||||
platform: formData.platform,
|
||||
platform_type: formData.platform_type || 'cms',
|
||||
config_json: {
|
||||
site_url: formData.site_url,
|
||||
...(formData.platform === 'shopify' && {
|
||||
shop_domain: formData.shop_domain,
|
||||
}),
|
||||
},
|
||||
credentials_json: {
|
||||
username: formData.username,
|
||||
app_password: formData.app_password,
|
||||
...(formData.platform === 'shopify' && {
|
||||
access_token: formData.access_token,
|
||||
}),
|
||||
},
|
||||
is_active: formData.is_active !== false,
|
||||
sync_enabled: formData.sync_enabled || false,
|
||||
};
|
||||
|
||||
if (selectedIntegration) {
|
||||
// Update
|
||||
await fetchAPI(`/v1/integration/integrations/${selectedIntegration.id}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.success('Integration updated successfully');
|
||||
} else {
|
||||
// Create
|
||||
await fetchAPI('/v1/integration/integrations/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.success('Integration created successfully');
|
||||
}
|
||||
setShowAddModal(false);
|
||||
setShowEditModal(false);
|
||||
loadIntegrations();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to save integration: ${error.message}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFields = (): FormField[] => {
|
||||
const platform = selectedIntegration?.platform || 'wordpress';
|
||||
|
||||
const baseFields: FormField[] = [
|
||||
{
|
||||
name: 'platform',
|
||||
label: 'Platform',
|
||||
type: 'custom',
|
||||
component: (
|
||||
<PlatformSelector
|
||||
value={platform}
|
||||
onChange={() => {}}
|
||||
disabled={!!selectedIntegration}
|
||||
/>
|
||||
),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (platform === 'wordpress') {
|
||||
baseFields.push(
|
||||
{ name: 'site_url', label: 'WordPress Site URL', type: 'text', required: true, placeholder: 'https://example.com' },
|
||||
{ name: 'username', label: 'Username', type: 'text', required: true },
|
||||
{ name: 'app_password', label: 'Application Password', type: 'password', required: true }
|
||||
);
|
||||
} else if (platform === 'shopify') {
|
||||
baseFields.push(
|
||||
{ name: 'shop_domain', label: 'Shop Domain', type: 'text', required: true, placeholder: 'myshop.myshopify.com' },
|
||||
{ name: 'access_token', label: 'Access Token', type: 'password', required: true }
|
||||
);
|
||||
} else {
|
||||
baseFields.push(
|
||||
{ name: 'site_url', label: 'API URL', type: 'text', required: true },
|
||||
{ name: 'username', label: 'API Key', type: 'text', required: true },
|
||||
{ name: 'app_password', label: 'API Secret', type: 'password', required: false }
|
||||
);
|
||||
}
|
||||
|
||||
baseFields.push(
|
||||
{ name: 'is_active', label: 'Active', type: 'checkbox', defaultValue: true },
|
||||
{ name: 'sync_enabled', label: 'Enable Two-Way Sync', type: 'checkbox', defaultValue: false }
|
||||
);
|
||||
|
||||
return baseFields;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading integrations...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Site Integrations</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Connect your sites to external platforms (WordPress, Shopify, Custom APIs)
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd} variant="primary">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{integrations.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-gray-600 dark:text-gray-400">No integrations configured</p>
|
||||
<Button onClick={handleAdd} variant="outline" className="mt-4">
|
||||
Add Your First Integration
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{integrations.map((integration) => (
|
||||
<div
|
||||
key={integration.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white capitalize">
|
||||
{integration.platform}
|
||||
</h3>
|
||||
{integration.site_name && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{integration.site_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(integration)}
|
||||
disabled={isTesting}
|
||||
title="Test Connection"
|
||||
>
|
||||
<TestTubeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(integration.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntegrationStatus
|
||||
syncEnabled={integration.sync_enabled}
|
||||
syncStatus={integration.sync_status}
|
||||
lastSyncAt={integration.last_sync_at}
|
||||
syncError={integration.sync_error}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(integration)}
|
||||
className="flex-1"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{integration.sync_enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSync(integration)}
|
||||
className="flex-1"
|
||||
>
|
||||
<SyncIcon className="w-4 h-4 mr-1" />
|
||||
Sync
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<FormModal
|
||||
isOpen={showAddModal || showEditModal}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
onSubmit={handleSave}
|
||||
title={selectedIntegration ? 'Edit Integration' : 'Add Integration'}
|
||||
fields={getFields()}
|
||||
submitLabel="Save"
|
||||
cancelLabel="Cancel"
|
||||
isLoading={isSaving}
|
||||
initialValues={selectedIntegration ? {
|
||||
platform: selectedIntegration.platform,
|
||||
site_url: selectedIntegration.site_name || '',
|
||||
is_active: selectedIntegration.is_active,
|
||||
sync_enabled: selectedIntegration.sync_enabled,
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
242
frontend/src/components/publishing/PublishingRules.tsx
Normal file
242
frontend/src/components/publishing/PublishingRules.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Publishing Rules Component
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { PlusIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
|
||||
import Button from '../ui/button/Button';
|
||||
import Checkbox from '../form/input/Checkbox';
|
||||
import Label from '../form/Label';
|
||||
import SelectDropdown from '../form/SelectDropdown';
|
||||
|
||||
export interface PublishingRule {
|
||||
id: string;
|
||||
content_type: string;
|
||||
trigger: 'auto' | 'manual' | 'scheduled';
|
||||
destinations: string[];
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
schedule?: string; // Cron expression for scheduled
|
||||
}
|
||||
|
||||
interface PublishingRulesProps {
|
||||
rules: PublishingRule[];
|
||||
onChange: (rules: PublishingRule[]) => void;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES = [
|
||||
{ value: 'blog_post', label: 'Blog Post' },
|
||||
{ value: 'page', label: 'Page' },
|
||||
{ value: 'product', label: 'Product' },
|
||||
{ value: 'all', label: 'All Content' },
|
||||
];
|
||||
|
||||
const TRIGGERS = [
|
||||
{ value: 'auto', label: 'Auto-Publish' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
];
|
||||
|
||||
const DESTINATIONS = [
|
||||
{ value: 'sites', label: 'IGNY8 Sites' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'shopify', label: 'Shopify' },
|
||||
];
|
||||
|
||||
export default function PublishingRules({ rules, onChange }: PublishingRulesProps) {
|
||||
const [localRules, setLocalRules] = useState<PublishingRule[]>(rules);
|
||||
|
||||
const handleAddRule = () => {
|
||||
const newRule: PublishingRule = {
|
||||
id: `rule_${Date.now()}`,
|
||||
content_type: 'all',
|
||||
trigger: 'manual',
|
||||
destinations: ['sites'],
|
||||
priority: localRules.length + 1,
|
||||
enabled: true,
|
||||
};
|
||||
const updated = [...localRules, newRule];
|
||||
setLocalRules(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (id: string) => {
|
||||
const updated = localRules.filter((r) => r.id !== id);
|
||||
setLocalRules(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleUpdateRule = (id: string, field: keyof PublishingRule, value: any) => {
|
||||
const updated = localRules.map((rule) =>
|
||||
rule.id === id ? { ...rule, [field]: value } : rule
|
||||
);
|
||||
setLocalRules(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleMoveRule = (id: string, direction: 'up' | 'down') => {
|
||||
const index = localRules.findIndex((r) => r.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= localRules.length) return;
|
||||
|
||||
const updated = [...localRules];
|
||||
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
||||
|
||||
// Update priorities
|
||||
updated.forEach((rule, i) => {
|
||||
rule.priority = i + 1;
|
||||
});
|
||||
|
||||
setLocalRules(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleToggleDestinations = (ruleId: string, destination: string) => {
|
||||
const rule = localRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
|
||||
const destinations = rule.destinations.includes(destination)
|
||||
? rule.destinations.filter((d) => d !== destination)
|
||||
: [...rule.destinations, destination];
|
||||
|
||||
handleUpdateRule(ruleId, 'destinations', destinations);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Publishing Rules
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure how and where content is published
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddRule} variant="primary" size="sm">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localRules.length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">No publishing rules configured</p>
|
||||
<Button onClick={handleAddRule} variant="outline">
|
||||
Add Your First Rule
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localRules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Content Type</Label>
|
||||
<SelectDropdown
|
||||
options={CONTENT_TYPES}
|
||||
value={rule.content_type}
|
||||
onChange={(e) =>
|
||||
handleUpdateRule(rule.id, 'content_type', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Trigger</Label>
|
||||
<SelectDropdown
|
||||
options={TRIGGERS}
|
||||
value={rule.trigger}
|
||||
onChange={(e) =>
|
||||
handleUpdateRule(rule.id, 'trigger', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Priority</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMoveRule(rule.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUpIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{rule.priority}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMoveRule(rule.id, 'down')}
|
||||
disabled={index === localRules.length - 1}
|
||||
>
|
||||
<ArrowDownIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Checkbox
|
||||
checked={rule.enabled}
|
||||
onChange={(e) =>
|
||||
handleUpdateRule(rule.id, 'enabled', e.target.checked)
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Destinations</Label>
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
{DESTINATIONS.map((dest) => (
|
||||
<Checkbox
|
||||
key={dest.value}
|
||||
checked={rule.destinations.includes(dest.value)}
|
||||
onChange={() => handleToggleDestinations(rule.id, dest.value)}
|
||||
label={dest.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.trigger === 'scheduled' && (
|
||||
<div>
|
||||
<Label>Schedule (Cron Expression)</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.schedule || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateRule(rule.id, 'schedule', e.target.value)
|
||||
}
|
||||
placeholder="0 0 * * *"
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Cron format: minute hour day month weekday
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export const createTasksPageConfig = (
|
||||
setStructureFilter: (value: string) => void;
|
||||
typeFilter: string;
|
||||
setTypeFilter: (value: string) => void;
|
||||
sourceFilter: string;
|
||||
setSourceFilter: (value: string) => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
}
|
||||
): TasksPageConfig => {
|
||||
@@ -103,6 +105,23 @@ export const createTasksPageConfig = (
|
||||
toggleable: true,
|
||||
toggleContentKey: 'description',
|
||||
toggleContentLabel: 'Idea & Content Outline',
|
||||
render: (value: string, row: Task) => {
|
||||
const isSiteBuilder = value?.startsWith('[Site Builder]');
|
||||
const displayTitle = isSiteBuilder ? value.replace('[Site Builder] ', '') : value;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{displayTitle}
|
||||
</span>
|
||||
{isSiteBuilder && (
|
||||
<Badge color="purple" size="sm" variant="light">
|
||||
Site Builder
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Sector column - only show when viewing all sectors
|
||||
...(showSectorColumn ? [{
|
||||
@@ -297,6 +316,17 @@ export const createTasksPageConfig = (
|
||||
{ value: 'tutorial', label: 'Tutorial' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Sources' },
|
||||
{ value: 'site_builder', label: 'Site Builder' },
|
||||
{ value: 'ideas', label: 'Ideas' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cluster_id',
|
||||
label: 'Cluster',
|
||||
|
||||
@@ -201,6 +201,7 @@ const AppSidebar: React.FC = () => {
|
||||
{ name: "General", path: "/settings" },
|
||||
{ name: "Plans", path: "/settings/plans" },
|
||||
{ name: "Integration", path: "/settings/integration" },
|
||||
{ name: "Publishing", path: "/settings/publishing" },
|
||||
{ name: "Import / Export", path: "/settings/import-export" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import ValidationCard from '../../components/common/ValidationCard';
|
||||
import ImageGenerationCard from '../../components/common/ImageGenerationCard';
|
||||
import ImageResultCard from '../../components/common/ImageResultCard';
|
||||
import ImageServiceCard from '../../components/common/ImageServiceCard';
|
||||
import SiteIntegrationsSection from '../../components/integration/SiteIntegrationsSection';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import FormModal, { FormField } from '../../components/common/FormModal';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
@@ -1082,6 +1083,9 @@ export default function Integration() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Site Integrations Section */}
|
||||
<SiteIntegrationsSection />
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
|
||||
165
frontend/src/pages/Settings/Publishing.tsx
Normal file
165
frontend/src/pages/Settings/Publishing.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Publishing Settings Page
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import Label from '../../components/form/Label';
|
||||
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
export default function Publishing() {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
|
||||
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
|
||||
const [publishingRules, setPublishingRules] = useState<PublishingRule[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// TODO: Load from backend API when endpoint is available
|
||||
// For now, use defaults
|
||||
setDefaultDestinations(['sites']);
|
||||
setAutoPublishEnabled(false);
|
||||
setPublishingRules([]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load settings: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
// TODO: Save to backend API when endpoint is available
|
||||
toast.success('Publishing settings saved successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDestination = (destination: string) => {
|
||||
setDefaultDestinations((prev) =>
|
||||
prev.includes(destination)
|
||||
? prev.filter((d) => d !== destination)
|
||||
: [...prev, destination]
|
||||
);
|
||||
};
|
||||
|
||||
const DESTINATIONS = [
|
||||
{ value: 'sites', label: 'IGNY8 Sites' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'shopify', label: 'Shopify' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Publishing Settings" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Publishing Settings - IGNY8" />
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Publishing Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure default publishing destinations and rules
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Default Destinations */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Default Publishing Destinations
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Select default platforms where content will be published
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{DESTINATIONS.map((dest) => (
|
||||
<div key={dest.value} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={defaultDestinations.includes(dest.value)}
|
||||
onChange={() => handleToggleDestination(dest.value)}
|
||||
label={dest.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Auto-Publish Settings */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Auto-Publish Settings
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Automatically publish content when it's ready
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={autoPublishEnabled}
|
||||
onChange={(e) => setAutoPublishEnabled(e.target.checked)}
|
||||
label="Enable auto-publish"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{autoPublishEnabled && (
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
When enabled, content will be automatically published to selected destinations
|
||||
when generation is complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Publishing Rules */}
|
||||
<Card className="p-6">
|
||||
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
83
frontend/src/pages/Sites/Editor.tsx
Normal file
83
frontend/src/pages/Sites/Editor.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Site Content Editor
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
interface Page {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
blocks: any[];
|
||||
}
|
||||
|
||||
export default function SiteContentEditor() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedPage, setSelectedPage] = useState<Page | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadPages();
|
||||
}
|
||||
}, [siteId]);
|
||||
|
||||
const loadPages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// TODO: Load pages from SiteBlueprint API
|
||||
// For now, placeholder
|
||||
setPages([]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load pages: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Content Editor" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading pages...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Content Editor - IGNY8" />
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Site Content Editor
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Edit content for site pages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Content editor will be implemented in Phase 7
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
202
frontend/src/pages/Sites/Manage.tsx
Normal file
202
frontend/src/pages/Sites/Manage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Site Management Dashboard
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PlusIcon, EditIcon, SettingsIcon, EyeIcon, TrashIcon } from 'lucide-react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
interface Site {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
site_type: string;
|
||||
hosting_type: string;
|
||||
status: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
page_count?: number;
|
||||
integration_count?: number;
|
||||
}
|
||||
|
||||
export default function SiteManagement() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
}, []);
|
||||
|
||||
const loadSites = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchAPI('/v1/auth/sites/');
|
||||
if (data && Array.isArray(data)) {
|
||||
setSites(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load sites: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSite = () => {
|
||||
navigate('/site-builder');
|
||||
};
|
||||
|
||||
const handleEdit = (siteId: number) => {
|
||||
navigate(`/sites/${siteId}/edit`);
|
||||
};
|
||||
|
||||
const handleSettings = (siteId: number) => {
|
||||
navigate(`/sites/${siteId}/settings`);
|
||||
};
|
||||
|
||||
const handleView = (siteId: number) => {
|
||||
navigate(`/sites/${siteId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (siteId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||
|
||||
try {
|
||||
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
toast.success('Site deleted successfully');
|
||||
loadSites();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to delete site: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Management" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading sites...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Management - IGNY8" />
|
||||
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Site Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage your sites, pages, and content
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateSite} variant="primary">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create New Site
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sites.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
No sites created yet
|
||||
</p>
|
||||
<Button onClick={handleCreateSite} variant="primary">
|
||||
Create Your First Site
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((site) => (
|
||||
<Card key={site.id} className="p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{site.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{site.slug}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
site.is_active
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{site.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded capitalize">
|
||||
{site.site_type}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded capitalize">
|
||||
{site.hosting_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{site.page_count || 0} pages
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(site.id)}
|
||||
title="View"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(site.id)}
|
||||
title="Edit"
|
||||
>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSettings(site.id)}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(site.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
170
frontend/src/pages/Sites/PageManager.tsx
Normal file
170
frontend/src/pages/Sites/PageManager.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Page Manager
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { PlusIcon, EditIcon, TrashIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
interface Page {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
order: number;
|
||||
blocks: any[];
|
||||
}
|
||||
|
||||
export default function PageManager() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const toast = useToast();
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadPages();
|
||||
}
|
||||
}, [siteId]);
|
||||
|
||||
const loadPages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// TODO: Load pages from SiteBlueprint API
|
||||
// For now, placeholder
|
||||
setPages([]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load pages: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPage = () => {
|
||||
// TODO: Navigate to page creation
|
||||
toast.info('Page creation will be implemented in Phase 7');
|
||||
};
|
||||
|
||||
const handleEditPage = (pageId: number) => {
|
||||
// TODO: Navigate to page editor
|
||||
toast.info('Page editor will be implemented in Phase 7');
|
||||
};
|
||||
|
||||
const handleDeletePage = async (pageId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this page?')) return;
|
||||
// TODO: Delete page
|
||||
toast.info('Page deletion will be implemented in Phase 7');
|
||||
};
|
||||
|
||||
const handleMovePage = async (pageId: number, direction: 'up' | 'down') => {
|
||||
// TODO: Update page order
|
||||
toast.info('Page reordering will be implemented in Phase 7');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Page Manager" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading pages...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Page Manager - IGNY8" />
|
||||
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Page Manager
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Manage pages for your site
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddPage} variant="primary">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
No pages created yet
|
||||
</p>
|
||||
<Button onClick={handleAddPage} variant="primary">
|
||||
Add Your First Page
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-3">
|
||||
{pages.map((page, index) => (
|
||||
<div
|
||||
key={page.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMovePage(page.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUpIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMovePage(page.id, 'down')}
|
||||
disabled={index === pages.length - 1}
|
||||
>
|
||||
<ArrowDownIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{page.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
/{page.slug} • {page.type} • {page.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditPage(page.id)}
|
||||
>
|
||||
<EditIcon className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeletePage(page.id)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
174
frontend/src/pages/Sites/Settings.tsx
Normal file
174
frontend/src/pages/Sites/Settings.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Site Settings
|
||||
* Phase 6: Site Integration & Multi-Destination Publishing
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import Label from '../../components/form/Label';
|
||||
import SelectDropdown from '../../components/form/SelectDropdown';
|
||||
import Checkbox from '../../components/form/input/Checkbox';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchAPI } from '../../services/api';
|
||||
|
||||
export default function SiteSettings() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [site, setSite] = useState<any>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
site_type: 'marketing',
|
||||
hosting_type: 'igny8_sites',
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadSite();
|
||||
}
|
||||
}, [siteId]);
|
||||
|
||||
const loadSite = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchAPI(`/v1/auth/sites/${siteId}/`);
|
||||
if (data) {
|
||||
setSite(data);
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
slug: data.slug || '',
|
||||
site_type: data.site_type || 'marketing',
|
||||
hosting_type: data.hosting_type || 'igny8_sites',
|
||||
is_active: data.is_active !== false,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load site: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await fetchAPI(`/v1/auth/sites/${siteId}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
toast.success('Site settings saved successfully');
|
||||
loadSite();
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const SITE_TYPES = [
|
||||
{ value: 'marketing', label: 'Marketing Site' },
|
||||
{ value: 'ecommerce', label: 'Ecommerce Site' },
|
||||
{ value: 'blog', label: 'Blog' },
|
||||
{ value: 'portfolio', label: 'Portfolio' },
|
||||
{ value: 'corporate', label: 'Corporate' },
|
||||
];
|
||||
|
||||
const HOSTING_TYPES = [
|
||||
{ value: 'igny8_sites', label: 'IGNY8 Sites' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'shopify', label: 'Shopify' },
|
||||
{ value: 'multi', label: 'Multi-Destination' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Settings" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading site settings...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Site Settings - IGNY8" />
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Site Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Configure site type, hosting, and other settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Site Name</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Slug</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="mt-1 w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Site Type</Label>
|
||||
<SelectDropdown
|
||||
options={SITE_TYPES}
|
||||
value={formData.site_type}
|
||||
onChange={(e) => setFormData({ ...formData, site_type: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Hosting Type</Label>
|
||||
<SelectDropdown
|
||||
options={HOSTING_TYPES}
|
||||
value={formData.hosting_type}
|
||||
onChange={(e) => setFormData({ ...formData, hosting_type: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
label="Active"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} variant="primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function Tasks() {
|
||||
const [clusterFilter, setClusterFilter] = useState('');
|
||||
const [structureFilter, setStructureFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Pagination state
|
||||
@@ -129,8 +130,15 @@ export default function Tasks() {
|
||||
try {
|
||||
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
|
||||
|
||||
// Build search term - combine user search with Site Builder filter if needed
|
||||
let finalSearchTerm = searchTerm;
|
||||
if (sourceFilter === 'site_builder') {
|
||||
// If user has a search term, combine it with Site Builder prefix
|
||||
finalSearchTerm = searchTerm ? `[Site Builder] ${searchTerm}` : '[Site Builder]';
|
||||
}
|
||||
|
||||
const filters: TasksFilters = {
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
...(finalSearchTerm && { search: finalSearchTerm }),
|
||||
...(statusFilter && { status: statusFilter }),
|
||||
...(clusterFilter && { cluster_id: clusterFilter }),
|
||||
...(structureFilter && { content_structure: structureFilter }),
|
||||
@@ -495,6 +503,8 @@ export default function Tasks() {
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
clusterFilter,
|
||||
sourceFilter,
|
||||
setSourceFilter,
|
||||
setClusterFilter,
|
||||
structureFilter,
|
||||
setStructureFilter,
|
||||
@@ -502,7 +512,7 @@ export default function Tasks() {
|
||||
setTypeFilter,
|
||||
setCurrentPage,
|
||||
});
|
||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
|
||||
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter, sourceFilter]);
|
||||
|
||||
// Calculate header metrics
|
||||
const headerMetrics = useMemo(() => {
|
||||
@@ -565,6 +575,7 @@ export default function Tasks() {
|
||||
cluster_id: clusterFilter,
|
||||
content_structure: structureFilter,
|
||||
content_type: typeFilter,
|
||||
source: sourceFilter,
|
||||
}}
|
||||
onFilterChange={(key, value) => {
|
||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||
@@ -578,6 +589,8 @@ export default function Tasks() {
|
||||
setStructureFilter(stringValue);
|
||||
} else if (key === 'content_type') {
|
||||
setTypeFilter(stringValue);
|
||||
} else if (key === 'source') {
|
||||
setSourceFilter(stringValue);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user