码桶
发现社区成员的开源项目
ZoneDetail.jsx49.6 KB
import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { Globe, Server, Plus, Trash2, RefreshCw, Upload, Download, FileText, Search, Clock, Unlink, MoreVertical, Shield, Key, WifiOff, AlertCircle } from 'lucide-react';
import { getAuthHeaders } from '../utils/auth.ts';
import ConfirmModal from './ConfirmModal.jsx';
import DnsRecordModal from './DnsRecordModal.jsx';
import DnsImportModal from './DnsImportModal.jsx';
import DnsHistoryTab from './DnsHistoryTab.jsx';
import DnsRecordsTab from './DnsRecordsTab.jsx';
import SaasTab from './SaasTab.jsx';
import TabSkeleton from './TabSkeleton.jsx';
const ScheduledChangesModal = React.lazy(() => import('./ScheduledChangesModal.jsx'));
const CacheManagement = React.lazy(() => import('./CacheManagement.jsx'));
const SslManagement = React.lazy(() => import('./SslManagement.jsx'));
const SpeedOptimization = React.lazy(() => import('./SpeedOptimization.jsx'));
const PageRules = React.lazy(() => import('./PageRules.jsx'));
const SecuritySettings = React.lazy(() => import('./SecuritySettings.jsx'));
const NetworkSettings = React.lazy(() => import('./NetworkSettings.jsx'));
const ScrapeShield = React.lazy(() => import('./ScrapeShield.jsx'));
const WorkersRoutes = React.lazy(() => import('./WorkersRoutes.jsx'));
const RedirectRules = React.lazy(() => import('./RedirectRules.jsx'));
const Analytics = React.lazy(() => import('./Analytics.jsx'));
const DnsSettings = React.lazy(() => import('./DnsSettings.jsx'));
const TransformRules = React.lazy(() => import('./TransformRules.jsx'));
const OriginRules = React.lazy(() => import('./OriginRules.jsx'));
const EmailRouting = React.lazy(() => import('./EmailRouting.jsx'));
const CustomPages = React.lazy(() => import('./CustomPages.jsx'));
const DnsAnalytics = React.lazy(() => import('./DnsAnalytics.jsx'));
const ZoneDetail = forwardRef(function ZoneDetail({ zone, auth, authFetch, tab, onBack, t, showToast, onToggleZoneStorage, zoneStorageLoading, onUnbindZone, onRefreshZones, onDnsCountUpdate }, ref) {
const [records, setRecords] = useState([]);
const [hostnames, setHostnames] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [error, setError] = useState(null);
const [confirmModal, setConfirmModal] = useState({ show: false, title: '', message: '', onConfirm: null });
const [showHistory, setShowHistory] = useState(false);
const [selectedRecords, setSelectedRecords] = useState(new Set());
// DNS Record Modal state
const [showDNSModal, setShowDNSModal] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
// Bulk import modal state
const [showBulkImportModal, setShowBulkImportModal] = useState(false);
// Scheduled changes state
const [showScheduledModal, setShowScheduledModal] = useState(false);
const [scheduledCount, setScheduledCount] = useState(0);
// Import loading (for .txt file import in action bar)
const [importLoading, setImportLoading] = useState(false);
const fileInputRef = useRef(null);
// SaaS tab ref for triggering add
const saasTabRef = useRef(null);
// Zone actions (delete/unbind) state
const [showZoneActions, setShowZoneActions] = useState(false);
const zoneActionsRef = useRef(null);
const [showDeleteZone, setShowDeleteZone] = useState(false);
const [deleteZoneConfirm, setDeleteZoneConfirm] = useState('');
const [deleteZoneLoading, setDeleteZoneLoading] = useState(false);
const [showUnbindConfirm, setShowUnbindConfirm] = useState(false);
// Expose openAddRecord to parent via ref
useImperativeHandle(ref, () => ({
openAddRecord: () => { setEditingRecord(null); setShowDNSModal(true); }
}));
const openConfirm = (title, message, onConfirm) => {
setConfirmModal({ show: true, title, message, onConfirm });
};
useEffect(() => {
function handleClickOutside(event) {
if (zoneActionsRef.current && !zoneActionsRef.current.contains(event.target)) {
setShowZoneActions(false);
}
}
if (showZoneActions) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showZoneActions]);
// Lock body scroll when any modal is open
useEffect(() => {
const anyModalOpen = showDNSModal || showBulkImportModal || confirmModal.show || showScheduledModal;
if (anyModalOpen) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
return () => {
document.body.classList.remove('modal-open');
};
}, [showDNSModal, showBulkImportModal, confirmModal.show, showScheduledModal]);
const getHeaders = (withType = false) => getAuthHeaders(auth, withType);
const af = authFetch || fetch; // fallback for client mode (no authFetch)
const parseApiError = (status, data, networkError) => {
if (networkError) return { type: 'network', message: networkError.message || 'Network error' };
if (status === 403) return { type: 'permission', message: data?.errors?.[0]?.message || 'Forbidden' };
if (status === 401) return { type: 'auth', message: data?.errors?.[0]?.message || 'Unauthorized' };
if (status === 400 && JSON.stringify(data).toLowerCase().includes('invalid api')) return { type: 'auth', message: data?.errors?.[0]?.message || 'Invalid API key' };
return { type: 'unknown', message: data?.errors?.[0]?.message || data?.message || `HTTP ${status}` };
};
const handleDeleteZone = async () => {
if (deleteZoneConfirm !== zone.name) return;
setDeleteZoneLoading(true);
try {
const res = await af(`/api/zones/${zone.id}`, {
method: 'DELETE',
headers: getHeaders(true)
});
const data = await res.json();
if (data.success) {
showToast(t('zoneDeleted'), 'success');
setShowDeleteZone(false);
setDeleteZoneConfirm('');
onRefreshZones();
} else {
showToast(data.errors?.[0]?.message || t('zoneDeleteFailed'), 'error');
}
} catch (err) {
showToast(t('zoneDeleteFailed'), 'error');
} finally {
setDeleteZoneLoading(false);
}
};
const fetchScheduledCount = async () => {
if (auth.mode !== 'server') return;
try {
const res = await af('/api/scheduled-changes', { headers: getHeaders() });
if (res.ok) {
const data = await res.json();
setScheduledCount((data.changes || []).length);
}
} catch (err) {
console.error('Failed to fetch scheduled count:', err);
}
};
const handleSchedule = async (record, editingRec, scheduledAt) => {
const action = editingRec ? 'update' : 'create';
const payload = { ...record };
const structuredTypes = ['SRV', 'CAA', 'URI', 'DS', 'TLSA', 'NAPTR', 'SSHFP', 'HTTPS', 'SVCB'];
if (!structuredTypes.includes(payload.type)) {
delete payload.data;
} else {
delete payload.content;
if (payload.type === 'SRV' || payload.type === 'URI') {
delete payload.priority;
if (payload.type === 'SRV' && payload.name) {
payload.data = { ...payload.data, name: payload.name };
}
}
}
try {
const res = await af('/api/scheduled-changes', {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({
zoneId: zone.id,
zoneName: zone.name,
action,
record: payload,
recordId: editingRec ? editingRec.id : undefined,
scheduledAt,
accountIndex: auth.currentAccountIndex || 0
})
});
if (res.ok) {
showToast(t('scheduleCreated'));
setShowDNSModal(false);
setEditingRecord(null);
fetchScheduledCount();
} else {
const data = await res.json().catch(() => ({}));
showToast(data.error || t('errorOccurred'), 'error');
}
} catch (err) {
showToast(t('errorOccurred'), 'error');
}
};
const fetchDNS = async () => {
setLoading(true);
setError(null);
try {
const res = await af(`/api/zones/${zone.id}/dns_records`, { headers: getHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(parseApiError(res.status, data));
setLoading(false);
return;
}
const data = await res.json();
const recs = data.result || [];
setRecords(recs.sort((a, b) => new Date(b.modified_on) - new Date(a.modified_on)));
// Update record count for sidebar/dashboard display
try {
const counts = JSON.parse(localStorage.getItem('dns_record_counts') || '{}');
counts[zone.id] = recs.length;
localStorage.setItem('dns_record_counts', JSON.stringify(counts));
} catch {}
if (onDnsCountUpdate) onDnsCountUpdate(zone.id, recs.length);
} catch (e) {
console.error('Failed to fetch DNS records:', e);
setError(parseApiError(null, null, e));
}
setLoading(false);
};
const fetchHostnames = async () => {
setLoading(true);
setError(null);
try {
const res = await af(`/api/zones/${zone.id}/custom_hostnames`, { headers: getHeaders() });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(parseApiError(res.status, data));
setLoading(false);
return;
}
const data = await res.json();
setHostnames(data.result || []);
} catch (e) {
console.error("Error fetching custom hostnames:", e);
setError(parseApiError(null, null, e));
}
setLoading(false);
};
useEffect(() => {
setShowHistory(false);
setError(null);
if (tab === 'dns') {
fetchDNS();
setSelectedRecords(new Set());
}
if (tab === 'saas') {
fetchHostnames();
}
if (['cache', 'speed', 'ssl', 'pagerules', 'security', 'network', 'scrapeshield', 'workers', 'rules', 'analytics', 'dnsanalytics', 'dnssettings', 'transform', 'origin', 'email', 'custompages'].includes(tab)) {
setLoading(false);
}
fetchScheduledCount();
}, [tab, zone.id]);
const handleDNSSubmit = async (e, newRecord, editingRec) => {
e.preventDefault();
const method = editingRec ? 'PATCH' : 'POST';
const url = `/api/zones/${zone.id}/dns_records${editingRec ? `?id=${editingRec.id}` : ''}`;
const payload = { ...newRecord };
const structuredTypes = ['SRV', 'CAA', 'URI', 'DS', 'TLSA', 'NAPTR', 'SSHFP', 'HTTPS', 'SVCB'];
if (!structuredTypes.includes(payload.type)) {
delete payload.data;
} else {
delete payload.content;
if (payload.type === 'SRV' || payload.type === 'URI') {
delete payload.priority;
if (payload.type === 'SRV' && payload.name) {
payload.data = { ...payload.data, name: payload.name };
}
}
}
const res = await af(url, {
method,
headers: getHeaders(true),
body: JSON.stringify(payload)
});
if (res.ok) {
setShowDNSModal(false);
setEditingRecord(null);
fetchDNS();
showToast(editingRec ? t('updateSuccess') : t('addSuccess'));
} else {
const data = await res.json().catch((err) => { console.error('Failed to parse response JSON:', err); return {}; });
const isFallbackError = data.errors?.some(e => e.code === 1040);
showToast(isFallbackError ? t('fallbackError') : (data.errors?.[0]?.message || data.message || t('errorOccurred')), 'error');
}
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
setImportLoading(true);
const formData = new FormData();
formData.append('file', file);
formData.append('proxied', 'true');
try {
const res = await af(`/api/zones/${zone.id}/dns_import`, {
method: 'POST',
headers: getHeaders(),
body: formData
});
const data = await res.json();
if (res.ok) {
showToast(t('importSuccess'));
fetchDNS();
} else {
showToast(data.message || t('errorOccurred'), 'error');
}
} catch (e) {
showToast(t('errorOccurred'), 'error');
}
setImportLoading(false);
e.target.value = '';
};
const handleExport = async () => {
try {
const headers = getHeaders();
const res = await af(`/api/zones/${zone.id}/dns_export`, { headers });
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dns_records_${zone.name}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
showToast(t('exportSuccess'));
} catch (e) {
showToast(t('errorOccurred'), 'error');
}
};
const handleBatchDelete = async () => {
const count = selectedRecords.size;
if (count === 0) return;
openConfirm(t('confirmTitle'), t('confirmBatchDelete').replace('{count}', count), async () => {
setLoading(true);
try {
const res = await af(`/api/zones/${zone.id}/dns_batch`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({
deletes: Array.from(selectedRecords).map(id => ({ id }))
})
});
const data = await res.json();
if (res.ok) {
showToast(t('deleteSuccess'));
setSelectedRecords(new Set());
fetchDNS();
} else {
showToast(data.message || t('errorOccurred'), 'error');
}
} catch (e) {
showToast(t('errorOccurred'), 'error');
}
setLoading(false);
});
};
const startEdit = (record) => {
setEditingRecord(record);
setShowDNSModal(true);
};
const handleUpdatePriority = async (updatedRecords) => {
// Optimistically update local state
const updatedMap = new Map(updatedRecords.map(r => [r.id, r]));
setRecords(prev => prev.map(r => updatedMap.has(r.id) ? { ...r, priority: updatedMap.get(r.id).priority } : r));
// Persist each priority change via API
try {
const results = await Promise.all(
updatedRecords.map(record =>
af(`/api/zones/${zone.id}/dns_records?id=${record.id}`, {
method: 'PATCH',
headers: getHeaders(true),
body: JSON.stringify({ priority: record.priority })
})
)
);
const allOk = results.every(r => r.ok);
if (allOk) {
showToast(t('priorityUpdated'));
fetchDNS();
} else {
showToast(t('priorityUpdateFailed'), 'error');
fetchDNS();
}
} catch (e) {
showToast(t('priorityUpdateFailed'), 'error');
fetchDNS();
}
};
const filteredRecords = records.filter(r => {
const term = searchTerm.toLowerCase();
return r.name.toLowerCase().includes(term) ||
r.content.toLowerCase().includes(term) ||
r.type.toLowerCase().includes(term) ||
(r.comment && r.comment.toLowerCase().includes(term)) ||
(r.tags && r.tags.some(tag => tag.toLowerCase().includes(term)));
});
const filteredSaaS = hostnames.filter(h =>
h.hostname.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="container">
<div style={{ marginBottom: '0.5rem' }}>
<div className="zone-header-row" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', position: 'relative', flexWrap: 'wrap' }}>
<div className="zone-globe-icon" style={{ padding: '0.25rem', background: 'var(--select-active-bg)', borderRadius: '8px' }}>
<Globe size={24} color="var(--primary)" />
</div>
<h1 className="zone-name-title" style={{ fontSize: '1.5rem', margin: 0, lineHeight: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{zone.name}</h1>
{zone._accountType === 'global_key'
? <span className="badge zone-type-badge" style={{ fontSize: '0.6rem', padding: '2px 6px', background: 'rgba(139, 92, 246, 0.12)', color: '#7c3aed', flexShrink: 0 }}>{t('globalKeyBadge')}</span>
: <span className="badge zone-type-badge" style={{ fontSize: '0.6rem', padding: '2px 6px', background: 'rgba(59, 130, 246, 0.12)', color: '#2563eb', flexShrink: 0 }}>{t('apiTokenBadge')}</span>
}
<div className="zone-action-btns" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexShrink: 0, marginLeft: 'auto' }}>
{onToggleZoneStorage && (
<button
className="zone-action-btn"
onClick={(e) => { e.stopPropagation(); onToggleZoneStorage(zone); }}
disabled={zoneStorageLoading}
style={{
display: 'flex', alignItems: 'center', gap: '0.25rem',
fontSize: '0.65rem', fontWeight: 600,
padding: '3px 8px', borderRadius: '6px', cursor: 'pointer',
border: '1px solid',
borderColor: zone._localKey ? 'var(--primary)' : 'var(--border)',
background: zone._localKey ? 'var(--select-active-bg)' : 'transparent',
color: zone._localKey ? 'var(--primary)' : 'var(--text-muted)',
transition: 'all 0.2s',
}}
title={zone._localKey ? t('uploadToServer') : t('switchToLocal')}
>
{zoneStorageLoading ? <RefreshCw className="spin" size={11} /> : zone._localKey ? <Upload size={11} /> : <Server size={11} />}
<span className="zone-btn-label">{zone._localKey ? t('localBadge') : t('storageServer')}</span>
</button>
)}
{auth.mode === 'server' && (
<button
className="zone-action-btn"
onClick={(e) => { e.stopPropagation(); setShowScheduledModal(true); }}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
fontSize: '0.65rem', fontWeight: 600,
padding: '3px 8px', borderRadius: '6px', cursor: 'pointer',
border: '1px solid',
borderColor: scheduledCount > 0 ? 'var(--primary)' : 'var(--border)',
background: scheduledCount > 0 ? 'var(--select-active-bg)' : 'transparent',
color: scheduledCount > 0 ? 'var(--primary)' : 'var(--text-muted)',
transition: 'all 0.2s',
}}
title={t('scheduledChanges')}
>
<Clock size={12} />
<span className="zone-btn-label">{t('scheduledChanges')}</span>
{scheduledCount > 0 && (
<span style={{
background: 'var(--primary)', color: '#fff',
borderRadius: '50%', width: '16px', height: '16px',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', fontWeight: 700
}}>
{scheduledCount}
</span>
)}
</button>
)}
<div style={{ position: 'relative' }} ref={zoneActionsRef}>
<button
onClick={(e) => { e.stopPropagation(); setShowZoneActions(!showZoneActions); }}
title={t('zoneActions')}
style={{
display: 'flex', alignItems: 'center',
padding: '3px 6px', borderRadius: '6px', cursor: 'pointer',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text-muted)',
transition: 'all 0.2s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<MoreVertical size={14} />
</button>
{showZoneActions && (
<div className="glass-card fade-in" style={{
position: 'absolute', top: '120%', right: 0, zIndex: 100,
minWidth: '180px', padding: '0.25rem',
}}>
<button
className="unstyled"
onClick={() => { setShowZoneActions(false); setShowUnbindConfirm(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Unlink size={14} />
{t('unbindZone')}
</button>
<button
className="unstyled"
onClick={() => { setShowZoneActions(false); setShowDeleteZone(true); setDeleteZoneConfirm(''); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--error)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--error-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Trash2 size={14} />
{t('deleteZone')}
</button>
</div>
)}
</div>
</div>{/* end zone-action-btns */}
</div>
</div>
{/* Nameservers banner */}
{zone.name_servers && zone.name_servers.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap',
padding: '0.5rem 0.75rem', marginBottom: '0.5rem',
borderRadius: '8px', fontSize: '0.75rem',
background: 'var(--select-active-bg)',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
}}>
<Server size={13} style={{ flexShrink: 0, color: 'var(--primary)' }} />
<span style={{ fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap' }}>{t('pointDnsTo')}</span>
{zone.name_servers.map(ns => (
<code key={ns} style={{
fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px',
background: 'var(--input-bg)', border: '1px solid var(--border)',
fontFamily: 'monospace', userSelect: 'all',
}}>{ns}</code>
))}
</div>
)}
{/* Loading skeleton */}
{loading && !error && records.length === 0 && hostnames.length === 0 && (
<div className="glass-card" style={{ padding: '1.25rem', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div className="skeleton" style={{ width: '120px', height: '24px' }} />
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="skeleton" style={{ width: '80px', height: '32px' }} />
<div className="skeleton" style={{ width: '80px', height: '32px' }} />
</div>
</div>
<div className="skeleton" style={{ width: '100%', height: '36px', marginBottom: '1rem' }} />
{[1, 2, 3, 4].map(i => (
<div key={i} className="skeleton-row">
<div className="skeleton" style={{ width: '50px', height: '22px' }} />
<div className="skeleton" style={{ flex: 1, height: '16px' }} />
<div className="skeleton" style={{ width: '150px', height: '16px' }} />
<div className="skeleton" style={{ width: '60px', height: '22px' }} />
</div>
))}
</div>
)}
{/* Error state */}
{error && !loading && (() => {
const isSaasQuotaError = tab === 'saas' && error.message && (error.message.toLowerCase().includes('no quota') || error.message.toLowerCase().includes('authentication error'));
return isSaasQuotaError ? (
<div className="glass-card error-state">
<div className="error-state-icon" style={{ background: 'rgba(245, 158, 11, 0.1)' }}>
<AlertCircle size={28} color="#d97706" />
</div>
<h3>{t('saasNotEnabled')}</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', lineHeight: 1.6 }}>{t('saasNotEnabledDesc')}</p>
<div style={{ margin: '1rem 0', padding: '0.75rem 1rem', background: 'var(--bg)', borderRadius: '8px', fontSize: '0.8rem', textAlign: 'left', lineHeight: 1.7 }}>
<div>{t('saasStep1')}</div>
<div>{t('saasStep2')}</div>
<div>{t('saasStep3')}</div>
</div>
<div className="error-state-actions">
<button className="btn btn-primary" onClick={() => fetchHostnames()}>
<RefreshCw size={14} /> {t('retry')}
</button>
<a href={`https://dash.cloudflare.com/${zone.account?.id || ''}/${zone.name}/ssl-tls/custom-hostnames`} target="_blank" rel="noopener noreferrer" className="btn btn-outline" style={{ textDecoration: 'none' }}>
{t('goToCfDashboard')}
</a>
</div>
</div>
) : (
<div className="glass-card error-state">
<div className="error-state-icon" style={{
background: error.type === 'permission' ? 'rgba(245, 158, 11, 0.1)' :
error.type === 'auth' ? 'var(--error-bg)' :
error.type === 'network' ? 'rgba(59, 130, 246, 0.1)' : 'var(--error-bg)'
}}>
{error.type === 'permission' ? <Shield size={28} color="#d97706" /> :
error.type === 'auth' ? <Key size={28} color="var(--error)" /> :
error.type === 'network' ? <WifiOff size={28} color="#2563eb" /> :
<AlertCircle size={28} color="var(--error)" />}
</div>
<h3>{error.type === 'permission' ? t('errPermission') :
error.type === 'auth' ? t('errAuth') :
error.type === 'network' ? t('errNetwork') : t('errUnknown')}</h3>
<p>{error.type === 'permission' ? (error.message && error.message !== 'Forbidden' ? error.message : t('errPermissionDesc')) :
error.type === 'auth' ? (error.message && error.message !== 'Unauthorized' ? error.message : t('errAuthDesc')) :
error.type === 'network' ? t('errNetworkDesc') : error.message}</p>
<div className="error-state-actions">
<button className="btn btn-primary" onClick={() => tab === 'dns' ? fetchDNS() : fetchHostnames()}>
<RefreshCw size={14} /> {t('retry')}
</button>
{(error.type === 'permission' || error.type === 'auth') && (
<a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener noreferrer" className="btn btn-outline" style={{ textDecoration: 'none' }}>
{t('goToCfDashboard')}
</a>
)}
</div>
</div>
);
})()}
{tab === 'speed' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<SpeedOptimization zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'ssl' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<SslManagement zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'cache' ? (
<React.Suspense fallback={<TabSkeleton variant="cache" />}>
<CacheManagement zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'pagerules' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<PageRules zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'security' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<SecuritySettings zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={(msg, cb) => openConfirm('', msg, cb)} />
</React.Suspense>
) : tab === 'network' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<NetworkSettings zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'scrapeshield' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<ScrapeShield zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'workers' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<WorkersRoutes zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'rules' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<RedirectRules zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'analytics' ? (
<React.Suspense fallback={<TabSkeleton variant="analytics" />}>
<Analytics zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'dnssettings' ? (
<React.Suspense fallback={<TabSkeleton variant="settings" />}>
<DnsSettings zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'transform' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<TransformRules zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'origin' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<OriginRules zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'email' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<EmailRouting zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} openConfirm={openConfirm} />
</React.Suspense>
) : tab === 'custompages' ? (
<React.Suspense fallback={<TabSkeleton variant="list" />}>
<CustomPages zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : tab === 'dnsanalytics' ? (
<React.Suspense fallback={<TabSkeleton variant="analytics" />}>
<DnsAnalytics zone={zone} getHeaders={getHeaders} authFetch={af} t={t} showToast={showToast} />
</React.Suspense>
) : !error && !(loading && records.length === 0 && hostnames.length === 0) ? (
<div className="glass-card" style={{ padding: '1.25rem', overflow: 'hidden' }}>
<div className="flex-stack header-stack" style={{ marginBottom: '1.0rem', flexWrap: 'wrap', gap: '1rem' }}>
<div className="header-top-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<h2 style={{ margin: 0, whiteSpace: 'nowrap' }}>{tab === 'dns' ? t('dnsRecords') : t('saasHostnames')}</h2>
<div className="header-actions" style={{ display: 'flex', gap: '0.5rem' }}>
{tab === 'dns' && (
<>
{selectedRecords.size > 0 && (
<button className="btn" style={{ background: 'var(--error)', color: 'white', border: 'none' }} onClick={handleBatchDelete}>
<Trash2 size={16} />
<span className="btn-text">{t('batchDelete')} ({selectedRecords.size})</span>
</button>
)}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImport}
accept=".txt"
/>
<button className="btn btn-outline" onClick={() => fileInputRef.current.click()} disabled={importLoading}>
<Upload size={16} className={importLoading ? 'spin' : ''} />
<span className="btn-text">{t('import')}</span>
</button>
<button className="btn btn-outline" onClick={() => { setShowBulkImportModal(true); }}>
<FileText size={16} />
<span className="btn-text">{t('bulkImport')}</span>
</button>
<button className="btn btn-outline" onClick={handleExport}>
<Download size={16} />
<span className="btn-text">{t('export')}</span>
</button>
<button
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
onClick={() => { setShowHistory(true); }}
>
<RefreshCw size={16} />
<span className="btn-text">{t('dnsHistory')}</span>
</button>
</>
)}
<button
className="btn btn-outline"
onClick={() => tab === 'dns' ? fetchDNS() : fetchHostnames()}
disabled={loading}
>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
<span className="btn-text">{t('refresh')}</span>
</button>
{tab === 'dns' ? (
<button className="btn btn-primary" onClick={() => { setEditingRecord(null); setShowDNSModal(true); }}>
<Plus size={16} /> <span className="btn-text">{t('addRecord')}</span>
</button>
) : (
<button className="btn btn-primary" onClick={() => {
if (saasTabRef.current && saasTabRef.current.openAddSaaS) {
saasTabRef.current.openAddSaaS();
}
}}>
<Plus size={16} /> <span className="btn-text">{t('addSaaS')}</span>
</button>
)}
</div>
</div>
<div style={{ position: 'relative', width: '100%', maxWidth: '100%' }} className="search-container">
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{ paddingLeft: '32px', height: '36px', fontSize: '0.8125rem', width: '100%' }}
/>
<Search size={14} style={{ position: 'absolute', left: '10px', top: '11px', color: 'var(--text-muted)' }} />
</div>
</div>
{showHistory && tab === 'dns' ? (
<DnsHistoryTab
zone={zone}
auth={auth}
authFetch={af}
onClose={() => setShowHistory(false)}
onRollbackComplete={() => fetchDNS()}
t={t}
showToast={showToast}
records={records}
/>
) : (
<div className="table-container">
{tab === 'dns' ? (
<DnsRecordsTab
zone={zone}
records={records}
setRecords={setRecords}
filteredRecords={filteredRecords}
loading={loading}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
selectedRecords={selectedRecords}
setSelectedRecords={setSelectedRecords}
fetchDNS={fetchDNS}
onOpenAddRecord={() => { setEditingRecord(null); setShowDNSModal(true); }}
onOpenEditRecord={startEdit}
onOpenBulkImport={() => setShowBulkImportModal(true)}
onShowHistory={() => setShowHistory(true)}
onUpdatePriority={handleUpdatePriority}
getHeaders={getHeaders} authFetch={af}
t={t}
showToast={showToast}
openConfirm={openConfirm}
/>
) : (
<SaasTab
ref={saasTabRef}
zone={zone}
hostnames={hostnames}
filteredSaaS={filteredSaaS}
loading={loading}
fetchHostnames={fetchHostnames}
getHeaders={getHeaders} authFetch={af}
t={t}
showToast={showToast}
openConfirm={openConfirm}
/>
)}
</div>
)}
</div>
) : null}
{/* DNS Record Modal */}
<DnsRecordModal
zone={zone}
show={showDNSModal}
editingRecord={editingRecord}
onClose={() => { setShowDNSModal(false); setEditingRecord(null); }}
onSubmit={handleDNSSubmit}
onSchedule={auth.mode === 'server' ? handleSchedule : undefined}
t={t}
showToast={showToast}
/>
{/* Bulk Import Modal */}
<DnsImportModal
zone={zone}
show={showBulkImportModal}
onClose={() => setShowBulkImportModal(false)}
onImportComplete={() => fetchDNS()}
auth={auth}
getHeaders={getHeaders} authFetch={af}
t={t}
showToast={showToast}
/>
{/* Confirm Modal */}
<ConfirmModal
confirmModal={confirmModal}
setConfirmModal={setConfirmModal}
t={t}
/>
{/* Scheduled Changes Modal */}
{showScheduledModal && (
<React.Suspense fallback={null}>
<ScheduledChangesModal
show={showScheduledModal}
onClose={() => { setShowScheduledModal(false); fetchScheduledCount(); }}
/>
</React.Suspense>
)}
{/* Unbind Zone Confirmation Modal */}
{showUnbindConfirm && (
<div className="modal-overlay" style={{ zIndex: 2000 }}
onClick={(e) => { if (e.target === e.currentTarget) setShowUnbindConfirm(false); }}>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('unbindZone')} style={{ padding: '2rem', maxWidth: '400px', width: '90%', textAlign: 'center' }}>
<div style={{ width: '48px', height: '48px', background: 'rgba(245, 158, 11, 0.1)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1.25rem' }}>
<Unlink size={24} color="#d97706" />
</div>
<h2 style={{ marginBottom: '0.75rem', fontSize: '1.1rem' }}>{t('unbindZone')}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.5rem', lineHeight: '1.6' }}>
{t('unbindZoneDesc')}
</p>
<p style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '1.25rem' }}>{zone.name}</p>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={() => setShowUnbindConfirm(false)}>{t('cancel')}</button>
<button
className="btn btn-primary"
style={{ flex: 1, background: '#d97706' }}
onClick={() => { setShowUnbindConfirm(false); onUnbindZone(zone.id); }}
>
{t('unbindZone')}
</button>
</div>
</div>
</div>
)}
{/* Delete Zone Confirmation Modal */}
{showDeleteZone && (
<div className="modal-overlay" style={{ zIndex: 2000 }}
onClick={(e) => { if (e.target === e.currentTarget) { setShowDeleteZone(false); setDeleteZoneConfirm(''); } }}>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('deleteZone')} style={{ padding: '2rem', maxWidth: '440px', width: '90%', textAlign: 'center' }}>
<div style={{ width: '48px', height: '48px', background: 'var(--error-bg)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1.25rem' }}>
<Trash2 size={24} color="var(--error)" />
</div>
<h2 style={{ marginBottom: '0.75rem', fontSize: '1.1rem' }}>{t('deleteZone')}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '0.5rem', lineHeight: '1.6' }}>
{t('deleteZoneWarning')}
</p>
<p style={{ fontSize: '0.8rem', marginBottom: '1rem' }}>
{t('deleteZoneTypeConfirm').replace('{zone}', zone.name)}
</p>
<input
type="text"
value={deleteZoneConfirm}
onChange={(e) => setDeleteZoneConfirm(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && deleteZoneConfirm === zone.name) handleDeleteZone(); }}
placeholder={zone.name}
style={{ width: '100%', marginBottom: '1.25rem', textAlign: 'center', fontWeight: 600 }}
autoFocus
/>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={() => { setShowDeleteZone(false); setDeleteZoneConfirm(''); }}>{t('cancel')}</button>
<button
className="btn btn-primary"
style={{ flex: 1, background: 'var(--error)', opacity: deleteZoneConfirm === zone.name ? 1 : 0.5 }}
disabled={deleteZoneConfirm !== zone.name || deleteZoneLoading}
onClick={handleDeleteZone}
>
{deleteZoneLoading ? <RefreshCw className="spin" size={14} /> : t('deleteZone')}
</button>
</div>
</div>
</div>
)}
</div >
);
});
export default ZoneDetail;