码桶
发现社区成员的开源项目
DnsRecordsTab.jsx33 KB
import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
import { Edit2, Trash2, ChevronDown, ChevronRight, Check, X, GripVertical } from 'lucide-react';
const GROUP_BY_KEY = 'dns_group_by';
const DRAGGABLE_TYPES = ['MX', 'SRV'];
const DnsRecordsTab = ({
zone,
records,
setRecords,
filteredRecords,
loading,
searchTerm,
setSearchTerm,
selectedRecords,
setSelectedRecords,
fetchDNS,
onOpenAddRecord,
onOpenEditRecord,
onOpenBulkImport,
onShowHistory,
onUpdatePriority,
getHeaders,
authFetch,
t,
showToast,
openConfirm
}) => {
const af = authFetch || fetch;
const [savingRecords, setSavingRecords] = useState(new Set());
const [importLoading, setImportLoading] = useState(false);
const [expandedRecords, setExpandedRecords] = useState(new Set());
const [groupBy, setGroupBy] = useState(() => localStorage.getItem(GROUP_BY_KEY) || 'none');
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
const fileInputRef = useRef(null);
// Inline editing state
const [editingCell, setEditingCell] = useState(null); // { id, field }
const [editValue, setEditValue] = useState('');
const [inlineSaving, setInlineSaving] = useState(false);
const inlineInputRef = useRef(null);
// Drag and drop state
const [draggedRecord, setDraggedRecord] = useState(null);
const [dragOverRecord, setDragOverRecord] = useState(null);
// Focus input when inline editing starts
useEffect(() => {
if (editingCell && inlineInputRef.current) {
inlineInputRef.current.focus();
inlineInputRef.current.select();
}
}, [editingCell]);
const handleGroupByChange = (value) => {
setGroupBy(value);
localStorage.setItem(GROUP_BY_KEY, value);
setCollapsedGroups(new Set());
};
const toggleGroupCollapse = (groupKey) => {
setCollapsedGroups(prev => {
const next = new Set(prev);
if (next.has(groupKey)) next.delete(groupKey);
else next.add(groupKey);
return next;
});
};
const groupedRecords = useMemo(() => {
if (groupBy === 'none') return null;
const groups = new Map();
for (const record of filteredRecords) {
let key;
if (groupBy === 'type') {
key = record.type;
} else {
// Group by subdomain
const zoneName = zone?.name || '';
let sub = record.name;
if (sub === zoneName) {
sub = '@';
} else if (sub.endsWith('.' + zoneName)) {
sub = sub.slice(0, -(zoneName.length + 1));
}
key = sub;
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(record);
}
// Sort groups
const sorted = Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
return sorted;
}, [filteredRecords, groupBy, zone?.name]);
const toggleExpand = (id) => {
setExpandedRecords(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const deleteRecord = async (id) => {
openConfirm(t('confirmTitle'), t('confirmDelete'), async () => {
const res = await af(`/api/zones/${zone.id}/dns_records?id=${id}`, {
method: 'DELETE',
headers: getHeaders(true)
});
if (res.ok) {
fetchDNS();
showToast(t('deleteSuccess'));
} else {
const data = await res.json().catch((err) => { console.error('Failed to parse response JSON:', err); return {}; });
showToast(data.message || t('errorOccurred'), 'error');
}
});
};
const toggleProxied = async (record) => {
if (!['A', 'AAAA', 'CNAME'].includes(record.type)) return;
if (savingRecords.has(record.id)) return;
const originalStatus = record.proxied;
setSavingRecords(prev => new Set(prev).add(record.id));
setRecords(prev => prev.map(r =>
r.id === record.id ? { ...r, proxied: !originalStatus } : r
));
try {
const res = await af(`/api/zones/${zone.id}/dns_records?id=${record.id}`, {
method: 'PATCH',
headers: getHeaders(true),
body: JSON.stringify({ proxied: !originalStatus })
});
const data = await res.json();
if (!res.ok) {
setRecords(prev => prev.map(r =>
r.id === record.id ? { ...r, proxied: originalStatus } : r
));
const isFallbackError = data.errors?.some(e => e.code === 1040);
if (isFallbackError) {
showToast(t('fallbackError'), 'error');
} else {
showToast(data.errors?.[0]?.message || data.message || t('errorOccurred'), 'error');
}
} else {
showToast(t('updateSuccess'));
}
} catch (e) {
setRecords(prev => prev.map(r =>
r.id === record.id ? { ...r, proxied: originalStatus } : r
));
showToast(t('errorOccurred'), 'error');
} finally {
setSavingRecords(prev => { const next = new Set(prev); next.delete(record.id); return next; });
}
};
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 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 handleBatchDelete = async () => {
const count = selectedRecords.size;
if (count === 0) return;
openConfirm(t('confirmTitle'), t('confirmBatchDelete').replace('{count}', count), async () => {
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');
}
});
};
const toggleSelectAll = () => {
if (selectedRecords.size === filteredRecords.length) {
setSelectedRecords(new Set());
} else {
setSelectedRecords(new Set(filteredRecords.map(r => r.id)));
}
};
const toggleSelect = (id) => {
setSelectedRecords(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const formatGroupLabel = (key, count) => {
if (groupBy === 'type') {
return `${key} ${t('dnsRecords')} (${count})`;
}
return `${key} (${count})`;
};
// ─── Inline Editing ──────────────────────────────────────────
const startInlineEdit = useCallback((record, field) => {
if (inlineSaving) return;
const value = field === 'ttl' ? (record.ttl === 1 ? '' : String(record.ttl)) : record[field];
setEditingCell({ id: record.id, field });
setEditValue(value || '');
}, [inlineSaving]);
const cancelInlineEdit = useCallback(() => {
setEditingCell(null);
setEditValue('');
}, []);
const saveInlineEdit = useCallback(async (record) => {
if (!editingCell || inlineSaving) return;
const { field } = editingCell;
let newValue = editValue;
// Validate
if (field === 'content' && !newValue.trim()) {
cancelInlineEdit();
return;
}
if (field === 'ttl') {
if (newValue === '' || newValue === '0') {
newValue = 1; // auto
} else {
newValue = parseInt(newValue, 10);
if (isNaN(newValue) || newValue < 1) {
cancelInlineEdit();
return;
}
}
}
// No change
if (field === 'ttl' && newValue === record.ttl) {
cancelInlineEdit();
return;
}
if (field === 'content' && newValue === record.content) {
cancelInlineEdit();
return;
}
setInlineSaving(true);
try {
const payload = {
type: record.type,
name: record.name,
content: field === 'content' ? newValue : record.content,
ttl: field === 'ttl' ? newValue : record.ttl,
proxied: record.proxied
};
if (record.priority !== undefined) {
payload.priority = record.priority;
}
const res = await af(`/api/zones/${zone.id}/dns_records?id=${record.id}`, {
method: 'PATCH',
headers: getHeaders(true),
body: JSON.stringify(payload)
});
if (res.ok) {
showToast(t('updateSuccess'));
fetchDNS();
} else {
const data = await res.json().catch(() => ({}));
const isFallbackError = data.errors?.some(e => e.code === 1040);
showToast(isFallbackError ? t('fallbackError') : (data.errors?.[0]?.message || data.message || t('errorOccurred')), 'error');
}
} catch (e) {
showToast(t('errorOccurred'), 'error');
}
setInlineSaving(false);
cancelInlineEdit();
}, [editingCell, editValue, inlineSaving, zone?.id, getHeaders, af, fetchDNS, showToast, t, cancelInlineEdit]);
const handleInlineKeyDown = useCallback((e, record) => {
if (e.key === 'Enter') {
e.preventDefault();
saveInlineEdit(record);
} else if (e.key === 'Escape') {
e.preventDefault();
cancelInlineEdit();
}
}, [saveInlineEdit, cancelInlineEdit]);
const renderInlineEditCell = (record, field, displayValue) => {
const isEditing = editingCell && editingCell.id === record.id && editingCell.field === field;
if (isEditing) {
return (
<div className="inline-edit-wrapper">
<input
ref={inlineInputRef}
type={field === 'ttl' ? 'number' : 'text'}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleInlineKeyDown(e, record)}
onBlur={() => {
// Delay to allow button clicks to register
setTimeout(() => {
if (editingCell && editingCell.id === record.id) {
cancelInlineEdit();
}
}, 150);
}}
disabled={inlineSaving}
min={field === 'ttl' ? '0' : undefined}
placeholder={field === 'ttl' ? t('ttlAuto') : ''}
/>
<div className="inline-edit-actions">
<button
className="inline-save"
onMouseDown={(e) => { e.preventDefault(); saveInlineEdit(record); }}
title={t('inlineEditSave')}
disabled={inlineSaving}
>
<Check size={14} />
</button>
<button
className="inline-cancel"
onMouseDown={(e) => { e.preventDefault(); cancelInlineEdit(); }}
title={t('inlineEditCancel')}
>
<X size={14} />
</button>
</div>
</div>
);
}
return displayValue;
};
// ─── Drag and Drop ───────────────────────────────────────────
const isDraggableGroup = useCallback((groupKey) => {
return groupBy === 'type' && DRAGGABLE_TYPES.includes(groupKey);
}, [groupBy]);
const handleDragStart = useCallback((e, record) => {
setDraggedRecord(record);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', record.id);
// Timeout to allow the drag image to render before changing opacity
setTimeout(() => {
const row = e.target.closest('tr');
if (row) row.classList.add('dragging');
}, 0);
}, []);
const handleDragEnd = useCallback((e) => {
const row = e.target.closest('tr');
if (row) row.classList.remove('dragging');
setDraggedRecord(null);
setDragOverRecord(null);
// Clean up all drag-over classes
document.querySelectorAll('.drag-over, .drag-over-top, .drag-over-bottom').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
}, []);
const handleDragOver = useCallback((e, record) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedRecord && draggedRecord.id !== record.id) {
setDragOverRecord(record);
// Add visual feedback
const row = e.target.closest('tr');
if (row) {
document.querySelectorAll('.drag-over, .drag-over-top, .drag-over-bottom').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
row.classList.add('drag-over');
}
}
}, [draggedRecord]);
const handleDragLeave = useCallback((e) => {
const row = e.target.closest('tr');
if (row) {
row.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
}
}, []);
const handleDrop = useCallback((e, targetRecord, groupRecords) => {
e.preventDefault();
document.querySelectorAll('.drag-over, .drag-over-top, .drag-over-bottom').forEach(el => {
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
});
if (!draggedRecord || draggedRecord.id === targetRecord.id) {
setDraggedRecord(null);
setDragOverRecord(null);
return;
}
// Reorder the records
const reordered = [...groupRecords];
const dragIndex = reordered.findIndex(r => r.id === draggedRecord.id);
const targetIndex = reordered.findIndex(r => r.id === targetRecord.id);
if (dragIndex === -1 || targetIndex === -1) return;
// Remove dragged item and insert at target position
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(targetIndex, 0, moved);
// Assign sequential priorities: 10, 20, 30...
const updates = reordered.map((record, index) => ({
...record,
priority: (index + 1) * 10
}));
// Call parent callback to persist
if (onUpdatePriority) {
onUpdatePriority(updates);
}
setDraggedRecord(null);
setDragOverRecord(null);
}, [draggedRecord, onUpdatePriority]);
// ─── Row Renderers ───────────────────────────────────────────
const renderDesktopRow = (record, { draggable = false, groupRecords = null } = {}) => (
<tr
key={record.id}
draggable={draggable ? 'true' : undefined}
onDragStart={draggable ? (e) => handleDragStart(e, record) : undefined}
onDragEnd={draggable ? handleDragEnd : undefined}
onDragOver={draggable ? (e) => handleDragOver(e, record) : undefined}
onDragLeave={draggable ? handleDragLeave : undefined}
onDrop={draggable ? (e) => handleDrop(e, record, groupRecords) : undefined}
>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{draggable && (
<span className="drag-handle" title={t('dragHandle')}>
<GripVertical size={14} />
</span>
)}
<input
type="checkbox"
checked={selectedRecords.has(record.id)}
onChange={() => toggleSelect(record.id)}
className="record-checkbox"
/>
</div>
</td>
<td>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', alignItems: 'center' }}>
<span className="badge badge-blue">{record.type}</span>
{(record.tags || []).map((tag, idx) => (
<span key={idx} className="badge badge-green" style={{ fontSize: '0.6rem', padding: '1px 5px' }}>{tag}</span>
))}
</div>
</td>
<td>
<div style={{ fontWeight: 600 }}>{record.name}</div>
{record.comment && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{record.comment}</div>}
</td>
<td
className="inline-edit-cell truncate-mobile"
style={{ color: 'var(--text-muted)', fontSize: '0.8125rem' }}
onDoubleClick={() => startInlineEdit(record, 'content')}
title={t('inlineEditHint')}
>
{renderInlineEditCell(record, 'content', record.content)}
</td>
<td
className="inline-edit-cell"
style={{ fontSize: '0.8125rem' }}
onDoubleClick={() => startInlineEdit(record, 'ttl')}
title={t('inlineEditHint')}
>
{renderInlineEditCell(record, 'ttl', record.ttl === 1 ? t('ttlAuto') : record.ttl)}
</td>
<td>
{['A', 'AAAA', 'CNAME'].includes(record.type) ? (
<label className="toggle-switch" style={savingRecords.has(record.id) ? { opacity: 0.5, pointerEvents: 'none' } : undefined}>
<input
type="checkbox"
checked={record.proxied}
onChange={() => toggleProxied(record)}
disabled={savingRecords.has(record.id)}
/>
<span className="slider"></span>
</label>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', opacity: 0.5 }}>---</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: '4px' }}>
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => onOpenEditRecord(record)} aria-label={`Edit ${record.name}`}>
<Edit2 size={16} color="var(--primary)" />
</button>
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => deleteRecord(record.id)} aria-label={`Delete ${record.name}`}>
<Trash2 size={16} color="var(--error)" />
</button>
</div>
</td>
</tr>
);
const renderMobileCard = (record) => (
<div key={record.id} className="record-card">
<div className="record-type-row" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', alignItems: 'center' }}>
<span className="dns-type-label">{record.type}</span>
{(record.tags || []).map((tag, idx) => (
<span key={idx} className="badge badge-green" style={{ fontSize: '0.6rem', padding: '1px 5px' }}>{tag}</span>
))}
</div>
<div className="record-header" role="button" tabIndex={0} onClick={() => toggleExpand(record.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(record.id); } }} aria-expanded={expandedRecords.has(record.id)}>
<div className="record-header-main">
<div className="dns-selection" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedRecords.has(record.id)}
onChange={() => toggleSelect(record.id)}
className="record-checkbox"
/>
</div>
<div className="dns-name-wrapper">
<div className="dns-name">{record.name}</div>
{record.comment && <div className="dns-comment">{record.comment}</div>}
</div>
</div>
<div className="record-actions-inline" onClick={e => e.stopPropagation()}>
{['A', 'AAAA', 'CNAME'].includes(record.type) && (
<label className="toggle-switch" style={{ transform: 'scale(0.8)', margin: 0 }}>
<input
type="checkbox"
checked={record.proxied}
onChange={() => toggleProxied(record)}
/>
<span className="slider"></span>
</label>
)}
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => onOpenEditRecord(record)} aria-label={`Edit ${record.name}`}>
<Edit2 size={16} color="var(--primary)" />
</button>
<button className="btn btn-outline" style={{ padding: '0.4rem', border: 'none' }} onClick={() => deleteRecord(record.id)} aria-label={`Delete ${record.name}`}>
<Trash2 size={16} color="var(--error)" />
</button>
</div>
</div>
{expandedRecords.has(record.id) && (
<div className="record-details">
<div className="detail-row" style={{ alignItems: 'stretch' }}>
<div className="record-content-cell" title={record.content} role="button" tabIndex={0} onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(record.content);
showToast(t('copied'));
}} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); navigator.clipboard.writeText(record.content); showToast(t('copied')); } }} aria-label={`Copy content: ${record.content}`}>
{record.content}
</div>
<div className="ttl-box">
<span className="ttl-label">TTL</span>
<span className="ttl-value">{record.ttl === 1 ? t('ttlAuto') : record.ttl}</span>
</div>
</div>
</div>
)}
</div>
);
const renderGroupHeader = (groupKey, count, isDraggable) => {
const isCollapsed = collapsedGroups.has(groupKey);
return (
<div
key={`group-header-${groupKey}`}
role="button"
tabIndex={0}
onClick={() => toggleGroupCollapse(groupKey)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroupCollapse(groupKey); } }}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0.6rem 0.75rem',
background: 'var(--hover-bg, #f9fafb)',
borderRadius: '6px',
cursor: 'pointer',
marginBottom: '4px',
marginTop: '8px',
userSelect: 'none',
border: '1px solid var(--border)',
}}
>
{isCollapsed ? <ChevronRight size={14} color="var(--text-muted)" /> : <ChevronDown size={14} color="var(--text-muted)" />}
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: 'var(--text)' }}>
{formatGroupLabel(groupKey, count)}
</span>
{isDraggable && !isCollapsed && (
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', fontWeight: 400, marginLeft: '4px' }}>
{t('dragReorderHint')}
</span>
)}
</div>
);
};
return (
<>
{/* Group By Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>{t('groupBy')}:</span>
{['none', 'type', 'subdomain'].map(option => (
<button
key={option}
onClick={() => handleGroupByChange(option)}
style={{
padding: '3px 10px',
borderRadius: '12px',
fontSize: '0.7rem',
fontWeight: 600,
border: '1px solid',
borderColor: groupBy === option ? 'var(--primary)' : 'var(--border)',
background: groupBy === option ? 'var(--select-active-bg, #fff7ed)' : 'transparent',
color: groupBy === option ? 'var(--primary)' : 'var(--text-muted)',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{t('groupBy' + option.charAt(0).toUpperCase() + option.slice(1))}
</button>
))}
</div>
{/* Desktop Table */}
{groupBy === 'none' ? (
<table className="data-table desktop-only">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
checked={filteredRecords.length > 0 && selectedRecords.size === filteredRecords.length}
onChange={toggleSelectAll}
className="record-checkbox"
/>
</th>
<th>{t('type')}</th>
<th>{t('name')} / {t('comment')}</th>
<th>{t('content')}</th>
<th>{t('ttl')}</th>
<th>{t('proxied')}</th>
<th>{t('actions')}</th>
</tr>
</thead>
<tbody>
{filteredRecords.map(record => renderDesktopRow(record))}
</tbody>
</table>
) : (
<div className="desktop-only">
{groupedRecords && groupedRecords.map(([groupKey, groupRecords]) => {
const draggable = isDraggableGroup(groupKey);
return (
<div key={groupKey}>
{renderGroupHeader(groupKey, groupRecords.length, draggable)}
{!collapsedGroups.has(groupKey) && (
<table className="data-table" style={{ marginBottom: '0.5rem' }}>
<thead>
<tr>
<th style={{ width: '40px' }}>
<input type="checkbox" className="record-checkbox" onChange={() => {
const ids = groupRecords.map(r => r.id);
const allSelected = ids.every(id => selectedRecords.has(id));
setSelectedRecords(prev => {
const next = new Set(prev);
ids.forEach(id => allSelected ? next.delete(id) : next.add(id));
return next;
});
}} checked={groupRecords.length > 0 && groupRecords.every(r => selectedRecords.has(r.id))} />
</th>
<th>{t('type')}</th>
<th>{t('name')} / {t('comment')}</th>
<th>{t('content')}</th>
<th>{t('ttl')}</th>
<th>{t('proxied')}</th>
<th>{t('actions')}</th>
</tr>
</thead>
<tbody>
{groupRecords.map(record => renderDesktopRow(record, { draggable, groupRecords }))}
</tbody>
</table>
)}
</div>
);
})}
</div>
)}
{/* Mobile Cards */}
{groupBy === 'none' ? (
<div className="mobile-only">
{filteredRecords.map(renderMobileCard)}
</div>
) : (
<div className="mobile-only">
{groupedRecords && groupedRecords.map(([groupKey, groupRecords]) => (
<div key={groupKey}>
{renderGroupHeader(groupKey, groupRecords.length, isDraggableGroup(groupKey))}
{!collapsedGroups.has(groupKey) && groupRecords.map(renderMobileCard)}
</div>
))}
</div>
)}
{/* Hidden file input for .txt import */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImport}
accept=".txt"
/>
</>
);
};
export default DnsRecordsTab;