import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Check, ChevronDown, ChevronUp, ExternalLink, Pencil, Plus, RefreshCw, Save, Trash2, X } from 'lucide-preact'; import LoadingState from '@/components/LoadingState'; import { t } from '@/lib/i18n'; import type { CustomEquivalentDomain, DomainRules } from '@/lib/types'; import { normalizeEquivalentDomain } from '@shared/domain-normalize'; const CUSTOM_GLOBAL_DOMAINS_PR_URL = 'https://github.com/shuaiplus/nodewarden/edit/main/src/static/global_domains.custom.json'; interface DomainRulesPageProps { rules: DomainRules | null; loading: boolean; error: string; onRefresh: () => void; onSave: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; } interface DomainRuleSummaryProps { text: string; expanded: boolean; onToggle: () => void; } function normalizeDomain(value: string): string { return normalizeEquivalentDomain(value); } function normalizeDomainList(domains: string[]): string[] { return Array.from(new Set(domains.map(normalizeDomain).filter(Boolean))); } function isValidDomainName(value: string): boolean { return !!normalizeEquivalentDomain(value); } function getInvalidDomainIndexes(domains: string[]): Set { const invalid = new Set(); domains.forEach((domain, index) => { if (!isValidDomainName(domain)) invalid.add(index); }); return invalid; } function createDraftId(): string { return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function createEmptyDomains(): string[] { return ['', '']; } function DomainRuleSummary(props: DomainRuleSummaryProps) { const textRef = useRef(null); const [canExpand, setCanExpand] = useState(false); useEffect(() => { const node = textRef.current; if (!node) return undefined; const measure = () => { const width = node.getBoundingClientRect().width; if (!width || typeof document === 'undefined') { setCanExpand(false); return; } const probe = document.createElement('span'); const styles = window.getComputedStyle(node); probe.textContent = props.text; probe.style.position = 'absolute'; probe.style.visibility = 'hidden'; probe.style.whiteSpace = 'nowrap'; probe.style.font = styles.font; probe.style.letterSpacing = styles.letterSpacing; probe.style.left = '-9999px'; probe.style.top = '-9999px'; document.body.appendChild(probe); const fullWidth = probe.getBoundingClientRect().width; probe.remove(); setCanExpand(fullWidth > width + 1); }; measure(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); } const observer = new ResizeObserver(measure); observer.observe(node); return () => observer.disconnect(); }, [props.text]); return ( <> {props.text} {canExpand && ( )} ); } function toEditableCustomRules(rules: DomainRules | null): CustomEquivalentDomain[] { const source = rules?.customEquivalentDomains?.length ? rules.customEquivalentDomains : (rules?.equivalentDomains || []).map((domains, index) => ({ id: `custom-${index}`, domains, excluded: false, })); return source.map((rule, index) => ({ id: String(rule.id || `custom-${index}`), domains: rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains(), excluded: !!rule.excluded, })); } export default function DomainRulesPage(props: DomainRulesPageProps) { const [customRules, setCustomRules] = useState([]); const [newRuleDomains, setNewRuleDomains] = useState(null); const [editingRuleId, setEditingRuleId] = useState(null); const [editingDomains, setEditingDomains] = useState(createEmptyDomains); const [newRuleInvalidIndexes, setNewRuleInvalidIndexes] = useState>(new Set()); const [editingInvalidIndexes, setEditingInvalidIndexes] = useState>(new Set()); const [excludedTypes, setExcludedTypes] = useState>(new Set()); const [expandedCustomRules, setExpandedCustomRules] = useState>(new Set()); const [expandedGlobalRules, setExpandedGlobalRules] = useState>(new Set()); const [saving, setSaving] = useState(false); const [filter, setFilter] = useState(''); useEffect(() => { setCustomRules(toEditableCustomRules(props.rules)); setNewRuleDomains(null); setEditingRuleId(null); setEditingDomains(createEmptyDomains()); setNewRuleInvalidIndexes(new Set()); setEditingInvalidIndexes(new Set()); setExpandedCustomRules(new Set()); setExpandedGlobalRules(new Set()); setExcludedTypes(new Set((props.rules?.globalEquivalentDomains || []).filter((entry) => entry.excluded).map((entry) => entry.type))); }, [props.rules]); const sortedGlobals = useMemo(() => { return [...(props.rules?.globalEquivalentDomains || [])].sort((a, b) => { const aKey = a.domains[0] || ''; const bKey = b.domains[0] || ''; return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' }); }); }, [props.rules]); const filteredGlobals = useMemo(() => { const needle = filter.trim().toLowerCase(); if (!needle) return sortedGlobals; return sortedGlobals.filter((entry) => entry.domains.some((domain) => domain.includes(needle))); }, [filter, sortedGlobals]); function setCustomRuleEnabled(index: number, enabled: boolean): void { setCustomRules((rules) => rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, excluded: !enabled } : rule)); } function beginEditCustomRule(rule: CustomEquivalentDomain): void { setNewRuleDomains(null); setEditingRuleId(rule.id); setEditingDomains(rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains()); setEditingInvalidIndexes(new Set()); } function confirmEditCustomRule(): void { if (!editingRuleId) return; const invalidIndexes = getInvalidDomainIndexes(editingDomains); setEditingInvalidIndexes(invalidIndexes); if (invalidIndexes.size) { props.onNotify('warning', t('txt_domain_rule_invalid_domains')); return; } const domains = normalizeDomainList(editingDomains); if (domains.length < 2) { props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); return; } setCustomRules((rules) => rules.map((rule) => rule.id === editingRuleId ? { ...rule, domains } : rule)); setEditingRuleId(null); setEditingDomains(createEmptyDomains()); } function cancelEditCustomRule(): void { setEditingRuleId(null); setEditingDomains(createEmptyDomains()); setEditingInvalidIndexes(new Set()); } function addNewRule(): void { const invalidIndexes = getInvalidDomainIndexes(newRuleDomains || []); setNewRuleInvalidIndexes(invalidIndexes); if (invalidIndexes.size) { props.onNotify('warning', t('txt_domain_rule_invalid_domains')); return; } const domains = normalizeDomainList(newRuleDomains || []); if (domains.length < 2) { props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); return; } setCustomRules((rules) => [ { id: createDraftId(), domains, excluded: false, }, ...rules, ]); setNewRuleDomains(null); setNewRuleInvalidIndexes(new Set()); } function removeCustomRule(index: number): void { setCustomRules((rules) => rules.filter((_, currentIndex) => currentIndex !== index)); } function toggleGlobal(type: number): void { setExcludedTypes((current) => { const next = new Set(current); if (next.has(type)) { next.delete(type); } else { next.add(type); } return next; }); } function toggleExpandedCustomRule(id: string): void { setExpandedCustomRules((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggleExpandedGlobalRule(type: number): void { setExpandedGlobalRules((current) => { const next = new Set(current); if (next.has(type)) next.delete(type); else next.add(type); return next; }); } async function save(): Promise { const normalizedCustomRules = customRules.map((rule) => ({ ...rule, domains: normalizeDomainList(rule.domains), })); if (normalizedCustomRules.some((rule) => rule.domains.some((domain) => !isValidDomainName(domain)))) { props.onNotify('warning', t('txt_domain_rule_invalid_domains')); return; } if (normalizedCustomRules.some((rule) => rule.domains.length < 2)) { props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); return; } const excludedGlobalEquivalentDomains = (props.rules?.globalEquivalentDomains || []) .filter((entry) => excludedTypes.has(entry.type)) .map((entry) => entry.type); setSaving(true); try { await props.onSave(normalizedCustomRules, excludedGlobalEquivalentDomains); props.onNotify('success', t('txt_domain_rules_saved')); } catch (error) { props.onNotify('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed')); } finally { setSaving(false); } } function renderDomainInputs(domains: string[], invalidIndexes: Set, onChange: (index: number, value: string) => void, onAdd: () => void, onRemove?: (index: number) => void) { return (
{domains.map((domain, index) => (
onChange(index, (event.currentTarget as HTMLInputElement).value)} /> {domains.length > 2 && onRemove && ( )} {index < domains.length - 1 && ,}
))}
); } if (props.loading && !props.rules) { return ; } return (
{t('nav_domain_rules')}

{t('txt_domain_rules_description')}

{t('txt_submit_pr')}

{t('txt_custom_equivalent_domains')}

{props.error &&
{props.error}
} {newRuleDomains && (
{renderDomainInputs( newRuleDomains, newRuleInvalidIndexes, (index, value) => { setNewRuleDomains((domains) => (domains || createEmptyDomains()).map((domain, currentIndex) => currentIndex === index ? value : domain)); setNewRuleInvalidIndexes((current) => { const next = new Set(current); next.delete(index); return next; }); }, () => { setNewRuleDomains((domains) => [...(domains || createEmptyDomains()), '']); setNewRuleInvalidIndexes(new Set()); }, (index) => setNewRuleDomains((domains) => { const current = domains || createEmptyDomains(); setNewRuleInvalidIndexes(new Set()); return current.length > 2 ? current.filter((_, currentIndex) => currentIndex !== index) : current; }) )}
)}
{customRules.map((rule, ruleIndex) => ( editingRuleId === rule.id ? (
{renderDomainInputs( editingDomains, editingInvalidIndexes, (domainIndex, value) => { setEditingDomains((domains) => domains.map((domain, currentIndex) => currentIndex === domainIndex ? value : domain)); setEditingInvalidIndexes((current) => { const next = new Set(current); next.delete(domainIndex); return next; }); }, () => { setEditingDomains((domains) => [...domains, '']); setEditingInvalidIndexes(new Set()); }, (domainIndex) => { setEditingInvalidIndexes(new Set()); setEditingDomains((domains) => domains.length > 2 ? domains.filter((_, currentIndex) => currentIndex !== domainIndex) : domains); } )}
) : (
setCustomRuleEnabled(ruleIndex, (event.currentTarget as HTMLInputElement).checked)} /> toggleExpandedCustomRule(rule.id)} />
) ))} {!customRules.length && !newRuleDomains &&
{t('txt_no_custom_domain_rules')}
}

{t('txt_global_equivalent_domains')}

setFilter((event.currentTarget as HTMLInputElement).value)} />
{filteredGlobals.map((entry) => (
toggleGlobal(entry.type)} /> toggleExpandedGlobalRule(entry.type)} />
))} {!filteredGlobals.length &&
{t('txt_no_domain_rules_found')}
}
); }