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:
fuomag9
2026-02-26 01:31:12 +01:00
parent 3442beba19
commit 674e06e3c9
2 changed files with 735 additions and 145 deletions

View File

@@ -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 &ldquo;{search}&rdquo;
</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 &amp; 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."
/>