Files
igny8/frontend/src/pages/Settings/Sites.tsx
IGNY8 VPS (Salman) 133d63813a api monitors
2025-11-15 11:31:49 +00:00

609 lines
21 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import PageMeta from '../../components/common/PageMeta';
import SiteCard from '../../components/common/SiteCard';
import FormModal, { FormField } from '../../components/common/FormModal';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import Alert from '../../components/ui/alert/Alert';
import {
fetchSites,
createSite,
updateSite,
deleteSite,
setActiveSite,
selectSectorsForSite,
fetchIndustries,
fetchSiteSectors,
Site,
Industry,
Sector,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import ApiStatusMonitor from '../../components/debug/ApiStatusMonitor';
// Site Icon SVG
const SiteIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="8" fill="#3B82F6"/>
<path d="M12 16L20 10L28 16V28C28 28.5304 27.7893 29.0391 27.4142 29.4142C27.0391 29.7893 26.5304 30 26 30H14C13.4696 30 12.9609 29.7893 12.5858 29.4142C12.2107 29.0391 12 28.5304 12 28V16Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 30V20H24V30" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export default function Sites() {
const toast = useToast();
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
const [selectedSite, setSelectedSite] = useState<Site | null>(null);
const [showSiteModal, setShowSiteModal] = useState(false);
const [showSectorsModal, setShowSectorsModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const [industries, setIndustries] = useState<Industry[]>([]);
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
const [selectedSectors, setSelectedSectors] = useState<string[]>([]);
const [isSelectingSectors, setIsSelectingSectors] = useState(false);
// Form state for site creation/editing
const [formData, setFormData] = useState({
name: '',
domain: '',
description: '',
is_active: true, // Default to true to match backend model default
});
// Load sites and industries
useEffect(() => {
loadSites();
loadIndustries();
}, []);
const loadSites = async () => {
try {
setLoading(true);
const response = await fetchSites();
setSites(response.results || []);
} catch (error: any) {
toast.error(`Failed to load sites: ${error.message}`);
} finally {
setLoading(false);
}
};
const loadIndustries = async () => {
try {
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
}
};
const handleToggle = async (siteId: number, enabled: boolean) => {
// Prevent multiple simultaneous toggle operations
if (togglingSiteId !== null) {
toast.error('Please wait for the current operation to complete');
return;
}
try {
setTogglingSiteId(siteId);
if (enabled) {
// Activate site (multiple sites can be active simultaneously)
await setActiveSite(siteId);
toast.success('Site activated successfully');
} else {
// Deactivate site - only this specific site
const site = sites.find(s => s.id === siteId);
if (site) {
await updateSite(siteId, { is_active: false });
toast.success('Site deactivated successfully');
}
}
await loadSites();
} catch (error: any) {
toast.error(`Failed to update site: ${error.message}`);
} finally {
setTogglingSiteId(null);
}
};
const handleSettings = (site: Site) => {
setSelectedSite(site);
setShowSectorsModal(true);
// Load current sectors for this site
loadSiteSectors(site);
};
const loadSiteSectors = async (site: Site) => {
try {
const sectors = await fetchSiteSectors(site.id);
const sectorSlugs = sectors.map((s: any) => s.slug);
setSelectedSectors(sectorSlugs);
// Use site's industry if available, otherwise try to determine from sectors
if (site.industry_slug) {
setSelectedIndustry(site.industry_slug);
} else {
// Fallback: try to determine industry from sectors
for (const industry of industries) {
const matchingSectors = industry.sectors.filter(s => sectorSlugs.includes(s.slug));
if (matchingSectors.length > 0) {
setSelectedIndustry(industry.slug);
break;
}
}
}
} catch (error: any) {
console.error('Failed to load site sectors:', error);
}
};
const handleDetails = (site: Site) => {
setSelectedSite(site);
setFormData({
name: site.name || '',
domain: site.domain || '',
description: site.description || '',
is_active: site.is_active || false,
});
setShowDetailsModal(true);
};
const handleSaveDetails = async () => {
if (!selectedSite) return;
try {
setIsSaving(true);
// Normalize domain before sending
const normalizedFormData = {
...formData,
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
};
await updateSite(selectedSite.id, normalizedFormData);
toast.success('Site updated successfully');
setShowDetailsModal(false);
await loadSites();
} catch (error: any) {
toast.error(`Failed to update site: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const handleCreateSite = () => {
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: true, // Default to true to match backend model default
});
setShowSiteModal(true);
};
const handleEditSite = (site: Site) => {
setSelectedSite(site);
setFormData({
name: site.name || '',
domain: site.domain || '',
description: site.description || '',
is_active: site.is_active || false,
});
setShowSiteModal(true);
};
// Helper function to normalize domain URL
const normalizeDomain = (domain: string): string => {
if (!domain || !domain.trim()) {
return domain;
}
const trimmed = domain.trim();
// If it already starts with https://, keep it as is
if (trimmed.startsWith('https://')) {
return trimmed;
}
// If it starts with http://, replace with https://
if (trimmed.startsWith('http://')) {
return trimmed.replace('http://', 'https://');
}
// Otherwise, add https://
return `https://${trimmed}`;
};
const handleSaveSite = async () => {
try {
setIsSaving(true);
// Normalize domain before sending
const normalizedFormData = {
...formData,
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
};
if (selectedSite) {
// Update existing site
await updateSite(selectedSite.id, normalizedFormData);
toast.success('Site updated successfully');
} else {
// Create new site
const newSite = await createSite({
...normalizedFormData,
is_active: normalizedFormData.is_active || false,
});
toast.success('Site created successfully');
// If this is the first site or user wants it active, activate it
if (sites.length === 0 || normalizedFormData.is_active) {
await setActiveSite(newSite.id);
}
}
setShowSiteModal(false);
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: false,
});
await loadSites();
} catch (error: any) {
toast.error(`Failed to save site: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const handleSelectSectors = async () => {
if (!selectedSite || !selectedIndustry || selectedSectors.length === 0) {
toast.error('Please select an industry and at least one sector');
return;
}
if (selectedSectors.length > 5) {
toast.error('Maximum 5 sectors allowed per site');
return;
}
try {
setIsSelectingSectors(true);
const result = await selectSectorsForSite(
selectedSite.id,
selectedIndustry,
selectedSectors
);
toast.success(result.message || 'Sectors selected successfully');
setShowSectorsModal(false);
await loadSites();
} catch (error: any) {
toast.error(`Failed to select sectors: ${error.message}`);
} finally {
setIsSelectingSectors(false);
}
};
const handleDeleteSite = async (site: Site) => {
if (!window.confirm(`Are you sure you want to delete "${site.name}"? This action cannot be undone.`)) {
return;
}
try {
await deleteSite(site.id);
toast.success('Site deleted successfully');
await loadSites();
if (showDetailsModal) {
setShowDetailsModal(false);
}
} catch (error: any) {
toast.error(`Failed to delete site: ${error.message}`);
}
};
const getSiteFormFields = (): FormField[] => [
{
key: 'name',
label: 'Site Name',
type: 'text',
value: formData.name,
onChange: (value: any) => setFormData({ ...formData, name: value }),
required: true,
placeholder: 'Enter site name',
},
{
key: 'domain',
label: 'Domain',
type: 'text',
value: formData.domain,
onChange: (value: any) => setFormData({ ...formData, domain: value }),
required: false,
placeholder: 'example.com (https:// will be added automatically)',
},
{
key: 'description',
label: 'Description',
type: 'textarea',
value: formData.description,
onChange: (value: any) => setFormData({ ...formData, description: value }),
required: false,
placeholder: 'Enter site description',
rows: 4,
},
{
key: 'is_active',
label: 'Set as Active Site',
type: 'select',
value: formData.is_active ? 'true' : 'false',
onChange: (value: any) => setFormData({ ...formData, is_active: value === 'true' }),
required: false,
options: [
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
],
},
];
const getIndustrySectors = () => {
if (!selectedIndustry) return [];
const industry = industries.find(i => i.slug === selectedIndustry);
return industry?.sectors || [];
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400">Loading sites...</p>
</div>
</div>
);
}
return (
<>
<PageMeta title="Sites Management" description="Manage your sites and configure industries and sectors" />
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Sites Management</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage your sites, configure industries, and select sectors. Multiple sites can be active simultaneously.
</p>
</div>
<Button onClick={handleCreateSite} variant="primary">
+ Add Site
</Button>
</div>
{/* Info Alert */}
<Alert
variant="info"
title="Sites Configuration"
message="Each site can have up to 5 sectors selected from 15 major industries. Keywords and clusters are automatically associated with sectors. Multiple sites can be active simultaneously."
/>
{/* Sites Grid */}
{sites.length === 0 ? (
<div className="rounded-2xl border border-gray-200 bg-white p-12 text-center dark:border-gray-800 dark:bg-white/3">
<SiteIcon />
<h3 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
No sites yet
</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Create your first site to get started
</p>
<Button onClick={handleCreateSite} variant="primary" className="mt-4">
Create Site
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{sites.map((site) => (
<SiteCard
key={site.id}
site={site}
icon={<SiteIcon />}
onToggle={handleToggle}
onSettings={handleSettings}
onDetails={handleDetails}
isToggling={togglingSiteId === site.id}
/>
))}
</div>
)}
{/* Create/Edit Site Modal */}
<FormModal
isOpen={showSiteModal}
onClose={() => {
setShowSiteModal(false);
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: false,
});
}}
onSubmit={handleSaveSite}
title={selectedSite ? 'Edit Site' : 'Create New Site'}
submitLabel={selectedSite ? 'Update Site' : 'Create Site'}
fields={getSiteFormFields()}
isLoading={isSaving}
/>
{/* Sectors Selection Modal */}
<FormModal
isOpen={showSectorsModal}
onClose={() => setShowSectorsModal(false)}
onSubmit={handleSelectSectors}
title={selectedSite ? `Configure Sectors for ${selectedSite.name}` : 'Configure Sectors'}
submitLabel={isSelectingSectors ? 'Saving...' : 'Save Sectors'}
cancelLabel="Cancel"
isLoading={isSelectingSectors}
className="max-w-2xl"
customBody={
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Industry
</label>
<select
value={selectedIndustry}
onChange={(e) => {
setSelectedIndustry(e.target.value);
setSelectedSectors([]); // Reset sectors when industry changes
}}
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"
>
<option value="">Select an industry...</option>
{industries.map((industry) => (
<option key={industry.slug} value={industry.slug}>
{industry.name}
</option>
))}
</select>
{selectedIndustry && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{industries.find(i => i.slug === selectedIndustry)?.description}
</p>
)}
</div>
{selectedIndustry && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Sectors (max 5)
</label>
<div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-4 dark:border-gray-700">
{getIndustrySectors().map((sector) => (
<label
key={sector.slug}
className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
>
<input
type="checkbox"
checked={selectedSectors.includes(sector.slug)}
onChange={(e) => {
if (e.target.checked) {
if (selectedSectors.length >= 5) {
toast.error('Maximum 5 sectors allowed per site');
return;
}
setSelectedSectors([...selectedSectors, sector.slug]);
} else {
setSelectedSectors(selectedSectors.filter(s => s !== sector.slug));
}
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-white">
{sector.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sector.description}
</div>
</div>
</label>
))}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Selected: {selectedSectors.length} / 5 sectors
</p>
</div>
)}
</div>
}
customFooter={
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="outline"
onClick={() => setShowSectorsModal(false)}
disabled={isSelectingSectors}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!selectedIndustry || selectedSectors.length === 0 || isSelectingSectors}
>
{isSelectingSectors ? 'Saving...' : 'Save Sectors'}
</Button>
</div>
}
/>
{/* Site Details Modal - Editable */}
{selectedSite && (
<FormModal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedSite(null);
}}
onSubmit={handleSaveDetails}
title={`Edit Site: ${selectedSite.name}`}
submitLabel="Save Changes"
fields={getSiteFormFields()}
isLoading={isSaving}
customFooter={
<div className="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="danger"
onClick={() => {
if (selectedSite) {
handleDeleteSite(selectedSite);
}
}}
disabled={isSaving}
>
Delete Site
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
setShowDetailsModal(false);
setSelectedSite(null);
}}
disabled={isSaving}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSaveDetails}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
}
/>
)}
</div>
{/* API Status Monitor - Only shows when debug toggle is enabled */}
<ApiStatusMonitor
title="Auth"
endpoints={[
{ name: 'List Sites', method: 'GET', endpoint: '/v1/auth/sites/' },
{ name: 'Get Industries', method: 'GET', endpoint: '/v1/auth/industries/' },
{ name: 'Get Seed Keywords', method: 'GET', endpoint: '/v1/auth/seed-keywords/' },
]}
/>
</>
);
}