diff --git a/src/components/proxy-hosts/GeoBlockFields.tsx b/src/components/proxy-hosts/GeoBlockFields.tsx index 1d7f434a..12f4c312 100644 --- a/src/components/proxy-hosts/GeoBlockFields.tsx +++ b/src/components/proxy-hosts/GeoBlockFields.tsx @@ -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>( + () => 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 ( + + + + {/* Search */} + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: search ? ( + + setSearch("")} edge="end" sx={{ p: 0.25 }}> + + + + ) : null, + }, + }} + sx={{ mb: 0.75 }} + /> + + {/* Toolbar */} + + + {selected.size > 0 ? ( + <>{selected.size} selected{search && `, ${selectedInFiltered} shown`} + ) : ( + None selected + )} + + + + · + + + + + {/* Grid */} + 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 ? ( + + No countries match “{search}” + + ) : ( + filtered.map((country) => { + const isSelected = selected.has(country.code); + return ( + + + {flagEmoji(country.code)} + + {country.name} + + {country.code} + + + } + 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)", + }), + }} + /> + ); + }) + )} + + + {/* Selected summary chips (shown when search is active and selected items are hidden) */} + {search && selected.size > 0 && ( + + + All selected ({selected.size}): + + + {[...selected].map((code) => { + const country = COUNTRIES.find((c) => c.code === code); + return ( + + {flagEmoji(code)} + {country?.name ?? code} + + } + size="small" + onDelete={() => toggle(code)} + color={accentColor} + variant="filled" + sx={{ fontSize: "0.7rem", height: 22, fontWeight: 600, "& .MuiChip-label": { px: 0.6 } }} + /> + ); + })} + + + )} + + ); +} + +// ─── ContinentPicker ────────────────────────────────────────────────────────── + +type ContinentPickerProps = { + name: string; + initialValues?: string[]; + accentColor?: "warning" | "success"; +}; + +function ContinentPicker({ name, initialValues = [], accentColor = "warning" }: ContinentPickerProps) { + const [selected, setSelected] = useState>( + () => 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 ( + + + + + {selected.size > 0 ? `${selected.size} selected` : None selected} + + + + · + + + + + {CONTINENTS.map((c) => { + const isSelected = selected.has(c.code); + return ( + 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)", + }), + }} + > + {c.emoji} + + + {c.name} + + + {c.code} + + + + ); + })} + + + ); +} + // ─── TagInput ──────────────────────────────────────────────────────────────── type TagInputProps = { @@ -116,49 +463,43 @@ function TagInput({ name, label, initialValues = [], placeholder, helperText, va return ( - { - if (reason === "input") setInputValue(value); + 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 ; - }) - } - renderInput={(params) => ( - { - 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 ? ( + + {tags.map((tag) => ( + setTags((prev) => prev.filter((t) => t !== tag))} + sx={{ height: 20, fontSize: "0.68rem", "& .MuiChip-label": { px: 0.6 }, "& .MuiChip-deleteIcon": { fontSize: 12 } }} + /> + ))} + + ) : undefined, + }, + }} /> ); @@ -222,6 +563,86 @@ function ResponseHeadersEditor({ initialHeaders }: { initialHeaders: Record + {/* Countries */} + + + Countries + + + + + + + {/* Continents */} + + + Continents + + + + + + + {/* ASNs */} + /^\d+$/.test(v)} + /> + + {/* CIDRs + IPs */} + + + + + + + + + + ); +} + // ─── 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 } }} > - {/* Block Rules */} - {/* Allow Rules */} {/* Advanced: Trusted Proxies + Block Response */} - + } sx={{ minHeight: 44, "& .MuiAccordionSummary-content": { my: 0.5 } }}> Trusted Proxies & Block Response @@ -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." /> diff --git a/src/components/proxy-hosts/countries.ts b/src/components/proxy-hosts/countries.ts new file mode 100644 index 00000000..7c68482c --- /dev/null +++ b/src/components/proxy-hosts/countries.ts @@ -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(""); +}