feat: replace country/continent TagInputs with visual flag pickers
Countries: - Searchable chip grid (flag emoji + name + code) with 249 countries - Instant search by name or ISO code prefix - Select matching / Clear matching when search is active - Select all / Clear all when no search - Selected-count indicator in toolbar - Summary strip showing all selected when search is active - Custom thin scrollbar, 220px viewport Continents: - 7 clickable tiles with emoji + name + code - Select all / Clear all toolbar - Warning/success color theming per block/allow tab Both pickers: - accentColor prop (warning=orange for block, success=green for allow) - Hidden form input for server compatibility - Smooth 120ms transitions Also simplified TagInput to a plain TextField with inline chips (removes Autocomplete dependency for freeform fields like ASNs/CIDRs/IPs) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,8 @@ import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Stack,
|
||||
Switch,
|
||||
Tab,
|
||||
@@ -29,9 +30,12 @@ import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import { useState, useEffect, SyntheticEvent } from "react";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useState, useEffect, useMemo, useCallback, SyntheticEvent } from "react";
|
||||
import { GeoBlockSettings } from "@/src/lib/settings";
|
||||
import { GeoBlockMode } from "@/src/lib/models/proxy-hosts";
|
||||
import { COUNTRIES, flagEmoji } from "./countries";
|
||||
|
||||
// ─── GeoIpStatus ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,6 +85,349 @@ function GeoIpStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CountryPicker ────────────────────────────────────────────────────────────
|
||||
|
||||
const CONTINENTS = [
|
||||
{ code: "AF", name: "Africa", emoji: "🌍" },
|
||||
{ code: "AN", name: "Antarctica", emoji: "🧊" },
|
||||
{ code: "AS", name: "Asia", emoji: "🌏" },
|
||||
{ code: "EU", name: "Europe", emoji: "🌍" },
|
||||
{ code: "NA", name: "N. America", emoji: "🌎" },
|
||||
{ code: "OC", name: "Oceania", emoji: "🌏" },
|
||||
{ code: "SA", name: "S. America", emoji: "🌎" },
|
||||
];
|
||||
|
||||
type CountryPickerProps = {
|
||||
name: string;
|
||||
initialValues?: string[];
|
||||
accentColor?: "warning" | "success";
|
||||
};
|
||||
|
||||
function CountryPicker({ name, initialValues = [], accentColor = "warning" }: CountryPickerProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(initialValues.map((c) => c.toUpperCase()).filter(Boolean))
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
if (!q) return COUNTRIES;
|
||||
return COUNTRIES.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().startsWith(q)
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
const toggle = useCallback((code: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) next.delete(code);
|
||||
else next.add(code);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectFiltered = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((c) => next.add(c.code));
|
||||
return next;
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const clearFiltered = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((c) => next.delete(c.code));
|
||||
return next;
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const selectedInFiltered = filtered.filter((c) => selected.has(c.code)).length;
|
||||
const allFilteredSelected = filtered.length > 0 && selectedInFiltered === filtered.length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input type="hidden" name={name} value={[...selected].join(",")} />
|
||||
|
||||
{/* Search */}
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search by country name or code…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ fontSize: 16, color: "text.disabled" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: search ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => setSearch("")} edge="end" sx={{ p: 0.25 }}>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null,
|
||||
},
|
||||
}}
|
||||
sx={{ mb: 0.75 }}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={0.75}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selected.size > 0 ? (
|
||||
<>{selected.size} selected{search && `, ${selectedInFiltered} shown`}</>
|
||||
) : (
|
||||
<Box component="span" sx={{ opacity: 0.5 }}>None selected</Box>
|
||||
)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.25}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={selectFiltered}
|
||||
disabled={allFilteredSelected}
|
||||
sx={{ fontSize: "0.7rem", py: 0.25, px: 0.75, minWidth: 0, textTransform: "none" }}
|
||||
>
|
||||
{search ? "Select matching" : "Select all"}
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ alignSelf: "center" }}>·</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={clearFiltered}
|
||||
disabled={selectedInFiltered === 0}
|
||||
sx={{ fontSize: "0.7rem", py: 0.25, px: 0.75, minWidth: 0, textTransform: "none" }}
|
||||
>
|
||||
{search ? "Clear matching" : "Clear all"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 220,
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
p: 0.75,
|
||||
borderRadius: 1.5,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: (t) => t.palette.mode === "dark" ? "rgba(255,255,255,0.02)" : "rgba(0,0,0,0.015)",
|
||||
"&::-webkit-scrollbar": { width: 5 },
|
||||
"&::-webkit-scrollbar-thumb": { borderRadius: 3, bgcolor: "divider" },
|
||||
"&::-webkit-scrollbar-track": { bgcolor: "transparent" },
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ p: 0.5 }}>
|
||||
No countries match “{search}”
|
||||
</Typography>
|
||||
) : (
|
||||
filtered.map((country) => {
|
||||
const isSelected = selected.has(country.code);
|
||||
return (
|
||||
<Chip
|
||||
key={country.code}
|
||||
label={
|
||||
<Box component="span" sx={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<Box component="span" sx={{ fontSize: "0.85rem", lineHeight: 1 }}>
|
||||
{flagEmoji(country.code)}
|
||||
</Box>
|
||||
<Box component="span">{country.name}</Box>
|
||||
<Box component="span" sx={{ opacity: 0.55, fontSize: "0.6rem", fontFamily: "monospace" }}>
|
||||
{country.code}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
size="small"
|
||||
onClick={() => toggle(country.code)}
|
||||
color={isSelected ? accentColor : "default"}
|
||||
variant={isSelected ? "filled" : "outlined"}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
fontSize: "0.72rem",
|
||||
height: 26,
|
||||
transition: "all 0.1s ease",
|
||||
"& .MuiChip-label": { px: 0.75 },
|
||||
...(!isSelected && {
|
||||
borderColor: "divider",
|
||||
"&:hover": { borderColor: "text.disabled", bgcolor: "action.hover" },
|
||||
}),
|
||||
...(isSelected && {
|
||||
fontWeight: 600,
|
||||
boxShadow: accentColor === "warning"
|
||||
? "0 0 0 1px rgba(237,108,2,0.3)"
|
||||
: "0 0 0 1px rgba(46,125,50,0.3)",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Selected summary chips (shown when search is active and selected items are hidden) */}
|
||||
{search && selected.size > 0 && (
|
||||
<Box mt={0.75}>
|
||||
<Typography variant="caption" color="text.disabled" display="block" mb={0.5}>
|
||||
All selected ({selected.size}):
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, maxHeight: 72, overflowY: "auto" }}>
|
||||
{[...selected].map((code) => {
|
||||
const country = COUNTRIES.find((c) => c.code === code);
|
||||
return (
|
||||
<Chip
|
||||
key={code}
|
||||
label={
|
||||
<Box component="span" sx={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<Box component="span" sx={{ fontSize: "0.8rem" }}>{flagEmoji(code)}</Box>
|
||||
<Box component="span">{country?.name ?? code}</Box>
|
||||
</Box>
|
||||
}
|
||||
size="small"
|
||||
onDelete={() => toggle(code)}
|
||||
color={accentColor}
|
||||
variant="filled"
|
||||
sx={{ fontSize: "0.7rem", height: 22, fontWeight: 600, "& .MuiChip-label": { px: 0.6 } }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ContinentPicker ──────────────────────────────────────────────────────────
|
||||
|
||||
type ContinentPickerProps = {
|
||||
name: string;
|
||||
initialValues?: string[];
|
||||
accentColor?: "warning" | "success";
|
||||
};
|
||||
|
||||
function ContinentPicker({ name, initialValues = [], accentColor = "warning" }: ContinentPickerProps) {
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(initialValues.map((c) => c.toUpperCase()).filter(Boolean))
|
||||
);
|
||||
|
||||
const toggle = (code: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) next.delete(code);
|
||||
else next.add(code);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isWarning = accentColor === "warning";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<input type="hidden" name={name} value={[...selected].join(",")} />
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={0.75}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{selected.size > 0 ? `${selected.size} selected` : <Box component="span" sx={{ opacity: 0.5 }}>None selected</Box>}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.25}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelected(new Set(CONTINENTS.map((c) => c.code)))}
|
||||
disabled={selected.size === CONTINENTS.length}
|
||||
sx={{ fontSize: "0.7rem", py: 0.25, px: 0.75, minWidth: 0, textTransform: "none" }}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.disabled" sx={{ alignSelf: "center" }}>·</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelected(new Set())}
|
||||
disabled={selected.size === 0}
|
||||
sx={{ fontSize: "0.7rem", py: 0.25, px: 0.75, minWidth: 0, textTransform: "none" }}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
|
||||
{CONTINENTS.map((c) => {
|
||||
const isSelected = selected.has(c.code);
|
||||
return (
|
||||
<Box
|
||||
key={c.code}
|
||||
onClick={() => toggle(c.code)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
borderRadius: 1.5,
|
||||
border: "1.5px solid",
|
||||
borderColor: isSelected
|
||||
? isWarning ? "warning.main" : "success.main"
|
||||
: "divider",
|
||||
bgcolor: isSelected
|
||||
? (t) => t.palette.mode === "dark"
|
||||
? isWarning ? "rgba(237,108,2,0.15)" : "rgba(46,125,50,0.15)"
|
||||
: isWarning ? "rgba(237,108,2,0.08)" : "rgba(46,125,50,0.08)"
|
||||
: "transparent",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
transition: "all 0.12s ease",
|
||||
"&:hover": {
|
||||
borderColor: isSelected
|
||||
? isWarning ? "warning.dark" : "success.dark"
|
||||
: "text.disabled",
|
||||
bgcolor: isSelected
|
||||
? (t) => t.palette.mode === "dark"
|
||||
? isWarning ? "rgba(237,108,2,0.22)" : "rgba(46,125,50,0.22)"
|
||||
: isWarning ? "rgba(237,108,2,0.13)" : "rgba(46,125,50,0.13)"
|
||||
: "action.hover",
|
||||
},
|
||||
...(isSelected && {
|
||||
boxShadow: isWarning
|
||||
? "0 0 0 1px rgba(237,108,2,0.25)"
|
||||
: "0 0 0 1px rgba(46,125,50,0.25)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: "1rem", lineHeight: 1 }}>{c.emoji}</Typography>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={isSelected ? 700 : 400}
|
||||
color={isSelected
|
||||
? isWarning ? "warning.main" : "success.main"
|
||||
: "text.primary"}
|
||||
display="block"
|
||||
lineHeight={1.2}
|
||||
sx={{ transition: "all 0.12s ease" }}
|
||||
>
|
||||
{c.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ fontSize: "0.62rem", fontFamily: "monospace" }}
|
||||
>
|
||||
{c.code}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TagInput ────────────────────────────────────────────────────────────────
|
||||
|
||||
type TagInputProps = {
|
||||
@@ -116,49 +463,43 @@ function TagInput({ name, label, initialValues = [], placeholder, helperText, va
|
||||
return (
|
||||
<Box>
|
||||
<input type="hidden" name={name} value={tags.join(",")} />
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={tags}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === "input") setInputValue(value);
|
||||
<TextField
|
||||
label={label}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={inputValue}
|
||||
placeholder={tags.length === 0 ? placeholder : undefined}
|
||||
helperText={helperText}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "," || e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitInput(inputValue);
|
||||
}
|
||||
if (e.key === "Backspace" && !inputValue && tags.length > 0) {
|
||||
setTags((prev) => prev.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
// Called when user selects/removes a chip via Autocomplete internals
|
||||
const processed = newValue.map((v) => processValue(v as string)).filter((v) => {
|
||||
if (!v) return false;
|
||||
if (validate && !validate(v)) return false;
|
||||
return true;
|
||||
});
|
||||
setTags([...new Set(processed)]);
|
||||
}}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip key={key} label={option} size="small" {...tagProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={label}
|
||||
placeholder={tags.length === 0 ? placeholder : undefined}
|
||||
helperText={helperText}
|
||||
size="small"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "," || e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitInput(inputValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onBlur={() => {
|
||||
if (inputValue.trim()) commitInput(inputValue);
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: tags.length > 0 ? (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.4, mr: 0.5, my: 0.25 }}>
|
||||
{tags.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
size="small"
|
||||
onDelete={() => setTags((prev) => prev.filter((t) => t !== tag))}
|
||||
sx={{ height: 20, fontSize: "0.68rem", "& .MuiChip-label": { px: 0.6 }, "& .MuiChip-deleteIcon": { fontSize: 12 } }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -222,6 +563,86 @@ function ResponseHeadersEditor({ initialHeaders }: { initialHeaders: Record<stri
|
||||
);
|
||||
}
|
||||
|
||||
// ─── RulesPanel ───────────────────────────────────────────────────────────────
|
||||
|
||||
type RulesPanelProps = {
|
||||
prefix: "block" | "allow";
|
||||
initial: GeoBlockSettings | null;
|
||||
};
|
||||
|
||||
function RulesPanel({ prefix, initial }: RulesPanelProps) {
|
||||
const accentColor = prefix === "block" ? "warning" : "success";
|
||||
const countries = prefix === "block" ? (initial?.block_countries ?? []) : (initial?.allow_countries ?? []);
|
||||
const continents = prefix === "block" ? (initial?.block_continents ?? []) : (initial?.allow_continents ?? []);
|
||||
const asns = prefix === "block" ? (initial?.block_asns ?? []) : (initial?.allow_asns ?? []);
|
||||
const cidrs = prefix === "block" ? (initial?.block_cidrs ?? []) : (initial?.allow_cidrs ?? []);
|
||||
const ips = prefix === "block" ? (initial?.block_ips ?? []) : (initial?.allow_ips ?? []);
|
||||
|
||||
return (
|
||||
<Stack spacing={2.5}>
|
||||
{/* Countries */}
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} mb={1} color="text.primary">
|
||||
Countries
|
||||
</Typography>
|
||||
<CountryPicker
|
||||
name={`geoblock_${prefix}_countries`}
|
||||
initialValues={countries}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Continents */}
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} mb={1} color="text.primary">
|
||||
Continents
|
||||
</Typography>
|
||||
<ContinentPicker
|
||||
name={`geoblock_${prefix}_continents`}
|
||||
initialValues={continents}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ASNs */}
|
||||
<TagInput
|
||||
name={`geoblock_${prefix}_asns`}
|
||||
label="ASNs"
|
||||
initialValues={asns.map(String)}
|
||||
placeholder="13335, 15169…"
|
||||
helperText="Autonomous System Numbers — press Enter or comma to add"
|
||||
validate={(v) => /^\d+$/.test(v)}
|
||||
/>
|
||||
|
||||
{/* CIDRs + IPs */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name={`geoblock_${prefix}_cidrs`}
|
||||
label="CIDRs"
|
||||
initialValues={cidrs}
|
||||
placeholder="10.0.0.0/8…"
|
||||
helperText="Press Enter or comma to add"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name={`geoblock_${prefix}_ips`}
|
||||
label="IP Addresses"
|
||||
initialValues={ips}
|
||||
placeholder="1.2.3.4…"
|
||||
helperText="Press Enter or comma to add"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── GeoBlockFields ───────────────────────────────────────────────────────────
|
||||
|
||||
type GeoBlockFieldsProps = {
|
||||
@@ -341,121 +762,27 @@ export function GeoBlockFields({ initialValues, showModeSelector = true }: GeoBl
|
||||
value={rulesTab}
|
||||
onChange={(_: SyntheticEvent, v: number) => setRulesTab(v)}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 2, "& .MuiTab-root": { textTransform: "none", fontWeight: 500 } }}
|
||||
sx={{ mb: 2.5, "& .MuiTab-root": { textTransform: "none", fontWeight: 500 } }}
|
||||
>
|
||||
<Tab label="Block Rules" />
|
||||
<Tab label="Allow Rules" />
|
||||
</Tabs>
|
||||
|
||||
{/* Block Rules */}
|
||||
<Box hidden={rulesTab !== 0}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_block_countries"
|
||||
label="Countries"
|
||||
initialValues={initial?.block_countries ?? []}
|
||||
placeholder="CN, RU..."
|
||||
helperText="ISO 3166-1 alpha-2 codes"
|
||||
uppercase
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_block_continents"
|
||||
label="Continents"
|
||||
initialValues={initial?.block_continents ?? []}
|
||||
placeholder="AS, EU..."
|
||||
helperText="AF AN AS EU NA OC SA"
|
||||
uppercase
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<TagInput
|
||||
name="geoblock_block_asns"
|
||||
label="ASNs"
|
||||
initialValues={(initial?.block_asns ?? []).map(String)}
|
||||
placeholder="13335, 15169..."
|
||||
helperText="Autonomous System Numbers"
|
||||
validate={(v) => /^\d+$/.test(v)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_block_cidrs"
|
||||
label="CIDRs"
|
||||
initialValues={initial?.block_cidrs ?? []}
|
||||
placeholder="10.0.0.0/8..."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_block_ips"
|
||||
label="IPs"
|
||||
initialValues={initial?.block_ips ?? []}
|
||||
placeholder="1.2.3.4..."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<RulesPanel prefix="block" initial={initial} />
|
||||
</Box>
|
||||
|
||||
{/* Allow Rules */}
|
||||
<Box hidden={rulesTab !== 1}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1.5}>
|
||||
Allow rules take precedence over block rules.
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_allow_countries"
|
||||
label="Countries"
|
||||
initialValues={initial?.allow_countries ?? []}
|
||||
placeholder="US, DE..."
|
||||
helperText="ISO 3166-1 alpha-2 codes"
|
||||
uppercase
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_allow_continents"
|
||||
label="Continents"
|
||||
initialValues={initial?.allow_continents ?? []}
|
||||
placeholder="NA, EU..."
|
||||
helperText="AF AN AS EU NA OC SA"
|
||||
uppercase
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<TagInput
|
||||
name="geoblock_allow_asns"
|
||||
label="ASNs"
|
||||
initialValues={(initial?.allow_asns ?? []).map(String)}
|
||||
placeholder="13335, 15169..."
|
||||
helperText="Autonomous System Numbers"
|
||||
validate={(v) => /^\d+$/.test(v)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_allow_cidrs"
|
||||
label="CIDRs"
|
||||
initialValues={initial?.allow_cidrs ?? []}
|
||||
placeholder="192.168.0.0/16..."
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TagInput
|
||||
name="geoblock_allow_ips"
|
||||
label="IPs"
|
||||
initialValues={initial?.allow_ips ?? []}
|
||||
placeholder="5.6.7.8..."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box mb={1.5}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Allow rules take precedence over block rules.
|
||||
</Typography>
|
||||
</Box>
|
||||
<RulesPanel prefix="allow" initial={initial} />
|
||||
</Box>
|
||||
|
||||
{/* Advanced: Trusted Proxies + Block Response */}
|
||||
<Box mt={2}>
|
||||
<Box mt={2.5}>
|
||||
<Accordion disableGutters elevation={0} sx={{ bgcolor: "transparent", border: "1px solid", borderColor: "divider", borderRadius: 1, "&:before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ minHeight: 44, "& .MuiAccordionSummary-content": { my: 0.5 } }}>
|
||||
<Typography variant="body2" fontWeight={500}>Trusted Proxies & Block Response</Typography>
|
||||
@@ -466,7 +793,7 @@ export function GeoBlockFields({ initialValues, showModeSelector = true }: GeoBl
|
||||
name="geoblock_trusted_proxies"
|
||||
label="Trusted Proxies"
|
||||
initialValues={initial?.trusted_proxies ?? []}
|
||||
placeholder="private_ranges, 10.0.0.0/8..."
|
||||
placeholder="private_ranges, 10.0.0.0/8…"
|
||||
helperText="Used to parse X-Forwarded-For. Use private_ranges for all RFC-1918 ranges."
|
||||
/>
|
||||
|
||||
|
||||
263
src/components/proxy-hosts/countries.ts
Normal file
263
src/components/proxy-hosts/countries.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
export type Country = { code: string; name: string };
|
||||
|
||||
export const COUNTRIES: Country[] = [
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "AX", name: "Åland Islands" },
|
||||
{ code: "AL", name: "Albania" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "AS", name: "American Samoa" },
|
||||
{ code: "AD", name: "Andorra" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "AQ", name: "Antarctica" },
|
||||
{ code: "AG", name: "Antigua & Barbuda" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "BY", name: "Belarus" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "BT", name: "Bhutan" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "BQ", name: "Caribbean Netherlands" },
|
||||
{ code: "BA", name: "Bosnia & Herzegovina" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "BV", name: "Bouvet Island" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "IO", name: "British Indian Ocean Territory" },
|
||||
{ code: "VG", name: "British Virgin Islands" },
|
||||
{ code: "BN", name: "Brunei" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "BI", name: "Burundi" },
|
||||
{ code: "CV", name: "Cape Verde" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "CX", name: "Christmas Island" },
|
||||
{ code: "CC", name: "Cocos Islands" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "CG", name: "Congo - Brazzaville" },
|
||||
{ code: "CD", name: "Congo - Kinshasa" },
|
||||
{ code: "CK", name: "Cook Islands" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "CI", name: "Côte d'Ivoire" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "CW", name: "Curaçao" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "CZ", name: "Czechia" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "SZ", name: "Eswatini" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "FK", name: "Falkland Islands" },
|
||||
{ code: "FO", name: "Faroe Islands" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TF", name: "French Southern Territories" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "GI", name: "Gibraltar" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "GL", name: "Greenland" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "GU", name: "Guam" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "GG", name: "Guernsey" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "HM", name: "Heard & McDonald Islands" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "HK", name: "Hong Kong" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "IR", name: "Iran" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "IM", name: "Isle of Man" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "JE", name: "Jersey" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "XK", name: "Kosovo" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "LA", name: "Laos" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "LY", name: "Libya" },
|
||||
{ code: "LI", name: "Liechtenstein" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "MO", name: "Macao" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MW", name: "Malawi" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "MV", name: "Maldives" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "MQ", name: "Martinique" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "YT", name: "Mayotte" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "FM", name: "Micronesia" },
|
||||
{ code: "MD", name: "Moldova" },
|
||||
{ code: "MC", name: "Monaco" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "ME", name: "Montenegro" },
|
||||
{ code: "MS", name: "Montserrat" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "MZ", name: "Mozambique" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "NU", name: "Niue" },
|
||||
{ code: "NF", name: "Norfolk Island" },
|
||||
{ code: "KP", name: "North Korea" },
|
||||
{ code: "MK", name: "North Macedonia" },
|
||||
{ code: "MP", name: "Northern Mariana Islands" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "PS", name: "Palestinian Territories" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "PN", name: "Pitcairn Islands" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "RE", name: "Réunion" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "RU", name: "Russia" },
|
||||
{ code: "RW", name: "Rwanda" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "SM", name: "San Marino" },
|
||||
{ code: "ST", name: "São Tomé & Príncipe" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "RS", name: "Serbia" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "SX", name: "Sint Maarten" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "GS", name: "South Georgia" },
|
||||
{ code: "KR", name: "South Korea" },
|
||||
{ code: "SS", name: "South Sudan" },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "BL", name: "St. Barthélemy" },
|
||||
{ code: "SH", name: "St. Helena" },
|
||||
{ code: "KN", name: "St. Kitts & Nevis" },
|
||||
{ code: "LC", name: "St. Lucia" },
|
||||
{ code: "MF", name: "St. Martin" },
|
||||
{ code: "PM", name: "St. Pierre & Miquelon" },
|
||||
{ code: "VC", name: "St. Vincent & Grenadines" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "SJ", name: "Svalbard & Jan Mayen" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "SY", name: "Syria" },
|
||||
{ code: "TW", name: "Taiwan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "TK", name: "Tokelau" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "TT", name: "Trinidad & Tobago" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "TR", name: "Türkiye" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "TC", name: "Turks & Caicos Islands" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "UM", name: "U.S. Outlying Islands" },
|
||||
{ code: "VI", name: "U.S. Virgin Islands" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "UA", name: "Ukraine" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "VA", name: "Vatican City" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "VN", name: "Vietnam" },
|
||||
{ code: "WF", name: "Wallis & Futuna" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
];
|
||||
|
||||
export function flagEmoji(code: string): string {
|
||||
const offset = 0x1f1e6;
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split("")
|
||||
.map((c) => String.fromCodePoint(offset + c.charCodeAt(0) - 65))
|
||||
.join("");
|
||||
}
|
||||
Reference in New Issue
Block a user