| 1 | import { useEffect, useMemo, useState } from "react"; |
| 2 | import { Header } from "@/components/Header"; |
| 3 | import { LandingHero } from "@/components/LandingHero"; |
| 4 | import { SearchBar } from "@/components/SearchBar"; |
| 5 | import { ServerCard } from "@/components/ServerCard"; |
| 6 | import { TagCombobox } from "@/components/TagCombobox"; |
| 7 | import { TunnelCommandModal } from "@/components/TunnelCommandModal"; |
| 8 | import type { BaseServer } from "@/hooks/useList"; |
| 9 | import type { AdminServer, ApprovalMode, UDPSettings, TCPPortSettings } from "@/hooks/useAdmin"; |
| 10 | import type { BanFilter, SortOption, StatusFilter } from "@/types/filters"; |
| 11 | import { StatusSelect } from "@/components/select/StatusSelect"; |
| 12 | import { BanStatusButtons } from "@/components/button/BanStatusButtons"; |
| 13 | import { SortbySelect } from "@/components/select/SortbySelect"; |
| 14 | import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle"; |
| 15 | import { FloatingActionBar } from "@/components/FloatingActionBar"; |
| 16 | import { readCurrentOrigin } from "@/hooks/useTunnelCommand"; |
| 17 | import { apiClient } from "@/lib/apiClient"; |
| 18 | import { BROWSER_API_PATHS, ROUTE_PATHS } from "@/lib/apiPaths"; |
| 19 | import type { DiscoveryResponse, DomainResponse, RelayDescriptor } from "@/types/api"; |
| 20 | import { |
| 21 | Dialog, |
| 22 | DialogContent, |
| 23 | DialogHeader, |
| 24 | DialogTitle, |
| 25 | } from "@/components/ui/dialog"; |
| 26 | |
| 27 | type ListServer = BaseServer | AdminServer; |
| 28 | |
| 29 | interface KnownRelay { |
| 30 | relayURL: string; |
| 31 | isCurrent: boolean; |
| 32 | } |
| 33 | |
| 34 | type RelayReleaseVersions = Record<string, string | null>; |
| 35 | |
| 36 | const OFFICIAL_REGISTRY_SOURCE_URL = |
| 37 | "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"; |
| 38 | const REPOSITORY_URL = "https://github.com/gosuda/portal-tunnel"; |
| 39 | |
| 40 | async function loadRelayReleaseVersion( |
| 41 | relayURL: string, |
| 42 | timeoutMs: number = 5000 |
| 43 | ): Promise<string> { |
| 44 | const domainURL = new URL(BROWSER_API_PATHS.sdk.domain, relayURL).toString(); |
| 45 | |
| 46 | const timeoutPromise = new Promise<never>((_, reject) => { |
| 47 | setTimeout(() => reject(new Error("timeout")), timeoutMs); |
| 48 | }); |
| 49 | |
| 50 | try { |
| 51 | const domain = await Promise.race([ |
| 52 | apiClient.get<DomainResponse>(domainURL), |
| 53 | timeoutPromise, |
| 54 | ]); |
| 55 | return typeof domain?.release_version === "string" |
| 56 | ? domain.release_version.trim() |
| 57 | : ""; |
| 58 | } catch { |
| 59 | return ""; |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | function normalizeRelayURL(relayURL: string | undefined): string { |
| 64 | return typeof relayURL === "string" ? relayURL.trim() : ""; |
| 65 | } |
| 66 | |
| 67 | function normalizeKnownRelays( |
| 68 | relays: RelayDescriptor[] | undefined, |
| 69 | currentRelayURL: string |
| 70 | ): KnownRelay[] { |
| 71 | const seen = new Set<string>(); |
| 72 | const knownRelays: KnownRelay[] = []; |
| 73 | |
| 74 | relays?.forEach((relay) => { |
| 75 | const relayURL = normalizeRelayURL(relay.api_https_addr); |
| 76 | if (relayURL === "" || seen.has(relayURL)) { |
| 77 | return; |
| 78 | } |
| 79 | |
| 80 | seen.add(relayURL); |
| 81 | knownRelays.push({ |
| 82 | relayURL, |
| 83 | isCurrent: relayURL === currentRelayURL, |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | if (currentRelayURL !== "" && !seen.has(currentRelayURL)) { |
| 88 | knownRelays.push({ |
| 89 | relayURL: currentRelayURL, |
| 90 | isCurrent: true, |
| 91 | }); |
| 92 | } |
| 93 | |
| 94 | knownRelays.sort((a, b) => { |
| 95 | if (a.isCurrent !== b.isCurrent) { |
| 96 | return a.isCurrent ? -1 : 1; |
| 97 | } |
| 98 | return a.relayURL.localeCompare(b.relayURL); |
| 99 | }); |
| 100 | |
| 101 | return knownRelays; |
| 102 | } |
| 103 | |
| 104 | function relayReleaseLabel( |
| 105 | versions: RelayReleaseVersions, |
| 106 | relayURL: string |
| 107 | ): string { |
| 108 | const version = versions[relayURL]; |
| 109 | if (version === undefined || version === null) { |
| 110 | return "loading..."; |
| 111 | } |
| 112 | return version || "offline"; |
| 113 | } |
| 114 | |
| 115 | interface ServerListViewProps { |
| 116 | title?: string; |
| 117 | searchQuery: string; |
| 118 | status: StatusFilter; |
| 119 | sortBy: SortOption; |
| 120 | selectedTags: string[]; |
| 121 | availableTags: string[]; |
| 122 | filteredServers: BaseServer[] | AdminServer[]; |
| 123 | favorites: string[]; |
| 124 | onSearchChange: (value: string) => void; |
| 125 | onStatusChange: (value: StatusFilter) => void; |
| 126 | onSortByChange: (value: SortOption) => void; |
| 127 | onTagToggle: (tag: string) => void; |
| 128 | onToggleFavorite: (serverId: string) => void; |
| 129 | isAdmin?: boolean; |
| 130 | banFilter?: BanFilter; |
| 131 | approvalMode?: ApprovalMode; |
| 132 | landingPageEnabled?: boolean; |
| 133 | onBanFilterChange?: (value: BanFilter) => void; |
| 134 | onBanStatusChange?: ( |
| 135 | identityKey: string, |
| 136 | isBan: boolean |
| 137 | ) => void | Promise<void>; |
| 138 | onBPSChange?: (identityKey: string, bps: number) => void | Promise<void>; |
| 139 | onApprovalModeChange?: (mode: ApprovalMode) => void; |
| 140 | onLandingPageEnabledChange?: (enabled: boolean) => void | Promise<void>; |
| 141 | udpSettings?: UDPSettings; |
| 142 | onUDPSettingsChange?: (settings: UDPSettings) => void | Promise<void>; |
| 143 | tcpPortSettings?: TCPPortSettings; |
| 144 | onTCPPortSettingsChange?: (settings: TCPPortSettings) => void | Promise<void>; |
| 145 | onApproveStatusChange?: ( |
| 146 | identityKey: string, |
| 147 | approve: boolean |
| 148 | ) => void | Promise<void>; |
| 149 | onDenyStatusChange?: ( |
| 150 | identityKey: string, |
| 151 | deny: boolean |
| 152 | ) => void | Promise<void>; |
| 153 | onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>; |
| 154 | onBulkApprove?: (identityKeys: string[]) => void | Promise<void>; |
| 155 | onBulkDeny?: (identityKeys: string[]) => void | Promise<void>; |
| 156 | onBulkBan?: (identityKeys: string[]) => void | Promise<void>; |
| 157 | onAuthChange?: () => void | Promise<void>; |
| 158 | } |
| 159 | |
| 160 | function isAdminServer(server: ListServer): server is AdminServer { |
| 161 | return "address" in server; |
| 162 | } |
| 163 | |
| 164 | function toAdminServer(server: ListServer): AdminServer | undefined { |
| 165 | return isAdminServer(server) ? server : undefined; |
| 166 | } |
| 167 | |
| 168 | export function ServerListView({ |
| 169 | title = "PORTAL", |
| 170 | searchQuery, |
| 171 | status, |
| 172 | sortBy, |
| 173 | selectedTags, |
| 174 | availableTags, |
| 175 | filteredServers, |
| 176 | favorites, |
| 177 | onSearchChange, |
| 178 | onStatusChange, |
| 179 | onSortByChange, |
| 180 | onTagToggle, |
| 181 | onToggleFavorite, |
| 182 | isAdmin = false, |
| 183 | banFilter = "all", |
| 184 | approvalMode = "auto", |
| 185 | landingPageEnabled = false, |
| 186 | onBanFilterChange, |
| 187 | onBanStatusChange, |
| 188 | onBPSChange, |
| 189 | onApprovalModeChange, |
| 190 | onLandingPageEnabledChange, |
| 191 | udpSettings, |
| 192 | onUDPSettingsChange, |
| 193 | tcpPortSettings, |
| 194 | onTCPPortSettingsChange, |
| 195 | onApproveStatusChange, |
| 196 | onDenyStatusChange, |
| 197 | onIPBanStatusChange, |
| 198 | onBulkApprove, |
| 199 | onBulkDeny, |
| 200 | onBulkBan, |
| 201 | onAuthChange, |
| 202 | }: ServerListViewProps) { |
| 203 | const [showFilterModal, setShowFilterModal] = useState(false); |
| 204 | const [relayReleaseVersions, setRelayReleaseVersions] = useState< |
| 205 | RelayReleaseVersions |
| 206 | >({}); |
| 207 | const [knownRelays, setKnownRelays] = useState<KnownRelay[]>([]); |
| 208 | const [relayDiscoveryLoading, setRelayDiscoveryLoading] = useState( |
| 209 | () => !isAdmin |
| 210 | ); |
| 211 | const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>( |
| 212 | new Set() |
| 213 | ); |
| 214 | const serverItems = filteredServers as ListServer[]; |
| 215 | const favoriteIds = useMemo(() => new Set(favorites), [favorites]); |
| 216 | const showLandingHero = !isAdmin && landingPageEnabled; |
| 217 | const currentRelayURL = useMemo(() => readCurrentOrigin(), []); |
| 218 | const paymentAppCount = useMemo( |
| 219 | () => serverItems.filter((server) => server.paymentEnabled).length, |
| 220 | [serverItems] |
| 221 | ); |
| 222 | |
| 223 | const handleToggleSelect = (identityKey: string) => { |
| 224 | setSelectedIdentityKeys((prev) => { |
| 225 | const next = new Set(prev); |
| 226 | if (next.has(identityKey)) { |
| 227 | next.delete(identityKey); |
| 228 | } else { |
| 229 | next.add(identityKey); |
| 230 | } |
| 231 | return next; |
| 232 | }); |
| 233 | }; |
| 234 | |
| 235 | const handleClearSelection = () => { |
| 236 | setSelectedIdentityKeys(new Set()); |
| 237 | }; |
| 238 | |
| 239 | const serverRows = useMemo( |
| 240 | () => |
| 241 | serverItems.map((server) => ({ |
| 242 | server, |
| 243 | adminServer: toAdminServer(server), |
| 244 | })), |
| 245 | [serverItems] |
| 246 | ); |
| 247 | |
| 248 | const allIdentityKeys = useMemo( |
| 249 | () => [ |
| 250 | ...new Set( |
| 251 | serverRows |
| 252 | .map(({ adminServer }) => adminServer?.identityKey) |
| 253 | .filter( |
| 254 | (identityKey): identityKey is string => |
| 255 | typeof identityKey === "string" && identityKey.trim().length > 0 |
| 256 | ) |
| 257 | ), |
| 258 | ], |
| 259 | [serverRows] |
| 260 | ); |
| 261 | |
| 262 | useEffect(() => { |
| 263 | const validIdentityKeys = new Set(allIdentityKeys); |
| 264 | setSelectedIdentityKeys((prev) => { |
| 265 | if (prev.size === 0) { |
| 266 | return prev; |
| 267 | } |
| 268 | |
| 269 | const next = new Set<string>(); |
| 270 | prev.forEach((identityKey) => { |
| 271 | if (validIdentityKeys.has(identityKey)) { |
| 272 | next.add(identityKey); |
| 273 | } |
| 274 | }); |
| 275 | |
| 276 | if (next.size === prev.size) { |
| 277 | return prev; |
| 278 | } |
| 279 | |
| 280 | return next; |
| 281 | }); |
| 282 | }, [allIdentityKeys]); |
| 283 | |
| 284 | useEffect(() => { |
| 285 | if (isAdmin) { |
| 286 | return; |
| 287 | } |
| 288 | setSelectedIdentityKeys((prev) => (prev.size === 0 ? prev : new Set())); |
| 289 | }, [isAdmin]); |
| 290 | |
| 291 | useEffect(() => { |
| 292 | if (isAdmin) { |
| 293 | return; |
| 294 | } |
| 295 | let cancelled = false; |
| 296 | setRelayDiscoveryLoading(true); |
| 297 | setRelayReleaseVersions({}); |
| 298 | setKnownRelays([]); |
| 299 | |
| 300 | void (async () => { |
| 301 | let nextKnownRelays = normalizeKnownRelays(undefined, currentRelayURL); |
| 302 | |
| 303 | try { |
| 304 | const discovery = |
| 305 | await apiClient.get<DiscoveryResponse>(BROWSER_API_PATHS.discovery); |
| 306 | nextKnownRelays = normalizeKnownRelays( |
| 307 | discovery?.relays, |
| 308 | currentRelayURL |
| 309 | ); |
| 310 | } catch { |
| 311 | // Keep the current relay fallback when discovery is unavailable. |
| 312 | } |
| 313 | |
| 314 | if (cancelled) { |
| 315 | return; |
| 316 | } |
| 317 | |
| 318 | setKnownRelays(nextKnownRelays); |
| 319 | setRelayDiscoveryLoading(false); |
| 320 | |
| 321 | const relayURLs = nextKnownRelays.map((relay) => relay.relayURL); |
| 322 | const uniqueRelayURLs = [...new Set(relayURLs)]; |
| 323 | setRelayReleaseVersions( |
| 324 | Object.fromEntries(uniqueRelayURLs.map((relayURL) => [relayURL, null])) |
| 325 | ); |
| 326 | |
| 327 | uniqueRelayURLs.forEach((relayURL) => { |
| 328 | void (async () => { |
| 329 | const version = await loadRelayReleaseVersion(relayURL); |
| 330 | if (cancelled) { |
| 331 | return; |
| 332 | } |
| 333 | setRelayReleaseVersions((prev) => ({ |
| 334 | ...prev, |
| 335 | [relayURL]: version, |
| 336 | })); |
| 337 | })(); |
| 338 | }); |
| 339 | })(); |
| 340 | |
| 341 | return () => { |
| 342 | cancelled = true; |
| 343 | }; |
| 344 | }, [currentRelayURL, isAdmin]); |
| 345 | const isAllSelected = |
| 346 | allIdentityKeys.length > 0 && |
| 347 | allIdentityKeys.every((identityKey) => selectedIdentityKeys.has(identityKey)); |
| 348 | |
| 349 | const handleSelectAll = () => { |
| 350 | if (isAllSelected) { |
| 351 | setSelectedIdentityKeys(new Set()); |
| 352 | } else { |
| 353 | setSelectedIdentityKeys(new Set(allIdentityKeys)); |
| 354 | } |
| 355 | }; |
| 356 | |
| 357 | const runBulkAction = async ( |
| 358 | handler?: (identityKeys: string[]) => void | Promise<void> |
| 359 | ) => { |
| 360 | if (!handler || selectedIdentityKeys.size === 0) { |
| 361 | return; |
| 362 | } |
| 363 | |
| 364 | try { |
| 365 | await handler(Array.from(selectedIdentityKeys)); |
| 366 | handleClearSelection(); |
| 367 | } catch (err) { |
| 368 | console.error("Failed bulk admin action", err); |
| 369 | } |
| 370 | }; |
| 371 | |
| 372 | const handleBulkApprove = () => { |
| 373 | void runBulkAction(onBulkApprove); |
| 374 | }; |
| 375 | const handleBulkDeny = () => { |
| 376 | void runBulkAction(onBulkDeny); |
| 377 | }; |
| 378 | const handleBulkBan = () => { |
| 379 | void runBulkAction(onBulkBan); |
| 380 | }; |
| 381 | |
| 382 | const [maxLeasesInput, setMaxLeasesInput] = useState( |
| 383 | String(udpSettings?.maxLeases ?? 0) |
| 384 | ); |
| 385 | const [tcpPortMaxLeasesInput, setTCPPortMaxLeasesInput] = useState( |
| 386 | String(tcpPortSettings?.maxLeases ?? 0) |
| 387 | ); |
| 388 | |
| 389 | useEffect(() => { |
| 390 | setMaxLeasesInput(String(udpSettings?.maxLeases ?? 0)); |
| 391 | }, [udpSettings?.maxLeases]); |
| 392 | |
| 393 | useEffect(() => { |
| 394 | setTCPPortMaxLeasesInput(String(tcpPortSettings?.maxLeases ?? 0)); |
| 395 | }, [tcpPortSettings?.maxLeases]); |
| 396 | |
| 397 | const handleUDPToggle = (enabled: boolean) => { |
| 398 | if (onUDPSettingsChange && udpSettings) { |
| 399 | void onUDPSettingsChange({ ...udpSettings, enabled }); |
| 400 | } |
| 401 | }; |
| 402 | |
| 403 | const handleTCPPortToggle = (enabled: boolean) => { |
| 404 | if (onTCPPortSettingsChange && tcpPortSettings) { |
| 405 | void onTCPPortSettingsChange({ ...tcpPortSettings, enabled }); |
| 406 | } |
| 407 | }; |
| 408 | |
| 409 | const handleLandingPageToggle = (enabled: boolean) => { |
| 410 | if (onLandingPageEnabledChange) { |
| 411 | void onLandingPageEnabledChange(enabled); |
| 412 | } |
| 413 | }; |
| 414 | |
| 415 | const handleMaxLeasesSave = () => { |
| 416 | if (onUDPSettingsChange && udpSettings) { |
| 417 | const value = Math.max(0, parseInt(maxLeasesInput, 10) || 0); |
| 418 | setMaxLeasesInput(String(value)); |
| 419 | void onUDPSettingsChange({ ...udpSettings, maxLeases: value }); |
| 420 | } |
| 421 | }; |
| 422 | |
| 423 | const handleTCPPortMaxLeasesSave = () => { |
| 424 | if (onTCPPortSettingsChange && tcpPortSettings) { |
| 425 | const value = Math.max(0, parseInt(tcpPortMaxLeasesInput, 10) || 0); |
| 426 | setTCPPortMaxLeasesInput(String(value)); |
| 427 | void onTCPPortSettingsChange({ ...tcpPortSettings, maxLeases: value }); |
| 428 | } |
| 429 | }; |
| 430 | |
| 431 | const adminFilterControls = ( |
| 432 | <> |
| 433 | {onBanFilterChange && ( |
| 434 | <div className="flex items-center gap-3"> |
| 435 | <span className="text-sm font-medium text-text-muted"> |
| 436 | Ban Status |
| 437 | </span> |
| 438 | <BanStatusButtons |
| 439 | banFilter={banFilter} |
| 440 | onBanFilterChange={onBanFilterChange} |
| 441 | /> |
| 442 | </div> |
| 443 | )} |
| 444 | {onApprovalModeChange && ( |
| 445 | <div className="flex items-center gap-3"> |
| 446 | <span className="text-sm font-medium text-text-muted">Approval</span> |
| 447 | <ApprovalModeToggle |
| 448 | approvalMode={approvalMode} |
| 449 | onApprovalModeChange={onApprovalModeChange} |
| 450 | /> |
| 451 | </div> |
| 452 | )} |
| 453 | {onLandingPageEnabledChange && ( |
| 454 | <div className="flex items-center gap-3"> |
| 455 | <span className="text-sm font-medium text-text-muted">Landing</span> |
| 456 | <div className="flex overflow-hidden rounded-lg border border-foreground/20"> |
| 457 | <button |
| 458 | onClick={() => handleLandingPageToggle(true)} |
| 459 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${ |
| 460 | landingPageEnabled |
| 461 | ? "bg-primary text-primary-foreground" |
| 462 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 463 | }`} |
| 464 | > |
| 465 | Shown |
| 466 | </button> |
| 467 | <button |
| 468 | onClick={() => handleLandingPageToggle(false)} |
| 469 | className={`cursor-pointer border-l border-foreground/20 px-4 h-10 text-sm font-medium transition-colors ${ |
| 470 | !landingPageEnabled |
| 471 | ? "bg-primary text-primary-foreground" |
| 472 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 473 | }`} |
| 474 | > |
| 475 | Hidden |
| 476 | </button> |
| 477 | </div> |
| 478 | </div> |
| 479 | )} |
| 480 | {onUDPSettingsChange && udpSettings && ( |
| 481 | <> |
| 482 | <div className="flex items-center gap-3"> |
| 483 | <span className="text-sm font-medium text-text-muted">UDP</span> |
| 484 | <div className="flex rounded-lg overflow-hidden border border-foreground/20"> |
| 485 | <button |
| 486 | onClick={() => handleUDPToggle(false)} |
| 487 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${ |
| 488 | !udpSettings.enabled |
| 489 | ? "bg-primary text-primary-foreground" |
| 490 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 491 | }`} |
| 492 | > |
| 493 | Disabled |
| 494 | </button> |
| 495 | <button |
| 496 | onClick={() => handleUDPToggle(true)} |
| 497 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${ |
| 498 | udpSettings.enabled |
| 499 | ? "bg-primary text-primary-foreground" |
| 500 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 501 | }`} |
| 502 | > |
| 503 | Enabled |
| 504 | </button> |
| 505 | </div> |
| 506 | </div> |
| 507 | <div className="flex items-center gap-3"> |
| 508 | <span className="text-sm font-medium text-text-muted">Max UDP</span> |
| 509 | <div className="flex items-center gap-2"> |
| 510 | <input |
| 511 | type="number" |
| 512 | min="0" |
| 513 | value={maxLeasesInput} |
| 514 | onChange={(e) => setMaxLeasesInput(e.target.value)} |
| 515 | onKeyDown={(e) => { |
| 516 | if (e.key === "Enter") handleMaxLeasesSave(); |
| 517 | }} |
| 518 | className="w-20 h-10 px-3 text-sm border border-foreground/20 rounded-lg bg-secondary text-foreground" |
| 519 | placeholder="0" |
| 520 | /> |
| 521 | <button |
| 522 | onClick={handleMaxLeasesSave} |
| 523 | className="cursor-pointer h-10 px-4 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" |
| 524 | > |
| 525 | Save |
| 526 | </button> |
| 527 | </div> |
| 528 | </div> |
| 529 | </> |
| 530 | )} |
| 531 | {onTCPPortSettingsChange && tcpPortSettings && ( |
| 532 | <> |
| 533 | <div className="flex items-center gap-3"> |
| 534 | <span className="text-sm font-medium text-text-muted">TCP</span> |
| 535 | <div className="flex rounded-lg overflow-hidden border border-foreground/20"> |
| 536 | <button |
| 537 | onClick={() => handleTCPPortToggle(false)} |
| 538 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${ |
| 539 | !tcpPortSettings.enabled |
| 540 | ? "bg-primary text-primary-foreground" |
| 541 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 542 | }`} |
| 543 | > |
| 544 | Disabled |
| 545 | </button> |
| 546 | <button |
| 547 | onClick={() => handleTCPPortToggle(true)} |
| 548 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${ |
| 549 | tcpPortSettings.enabled |
| 550 | ? "bg-primary text-primary-foreground" |
| 551 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 552 | }`} |
| 553 | > |
| 554 | Enabled |
| 555 | </button> |
| 556 | </div> |
| 557 | </div> |
| 558 | <div className="flex items-center gap-3"> |
| 559 | <span className="text-sm font-medium text-text-muted">Max TCP</span> |
| 560 | <div className="flex items-center gap-2"> |
| 561 | <input |
| 562 | type="number" |
| 563 | min="0" |
| 564 | value={tcpPortMaxLeasesInput} |
| 565 | onChange={(e) => setTCPPortMaxLeasesInput(e.target.value)} |
| 566 | onKeyDown={(e) => { |
| 567 | if (e.key === "Enter") handleTCPPortMaxLeasesSave(); |
| 568 | }} |
| 569 | className="w-20 h-10 px-3 text-sm border border-foreground/20 rounded-lg bg-secondary text-foreground" |
| 570 | placeholder="0" |
| 571 | /> |
| 572 | <button |
| 573 | onClick={handleTCPPortMaxLeasesSave} |
| 574 | className="cursor-pointer h-10 px-4 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" |
| 575 | > |
| 576 | Save |
| 577 | </button> |
| 578 | </div> |
| 579 | </div> |
| 580 | </> |
| 581 | )} |
| 582 | </> |
| 583 | ); |
| 584 | |
| 585 | const renderServerCard = ({ |
| 586 | server, |
| 587 | adminServer, |
| 588 | }: { |
| 589 | server: ListServer; |
| 590 | adminServer?: AdminServer; |
| 591 | }) => { |
| 592 | const isSelected = adminServer |
| 593 | ? selectedIdentityKeys.has(adminServer.identityKey) |
| 594 | : false; |
| 595 | |
| 596 | return ( |
| 597 | <ServerCard |
| 598 | key={server.id} |
| 599 | serverId={server.id} |
| 600 | name={server.name} |
| 601 | description={server.description} |
| 602 | tags={server.tags} |
| 603 | thumbnail={server.thumbnail} |
| 604 | owner={server.owner} |
| 605 | online={server.online} |
| 606 | dns={server.dns} |
| 607 | navigationPath={server.link || "#"} |
| 608 | navigationState={{ |
| 609 | id: server.id, |
| 610 | name: server.name, |
| 611 | description: server.description, |
| 612 | tags: server.tags, |
| 613 | thumbnail: server.thumbnail, |
| 614 | owner: server.owner, |
| 615 | online: server.online, |
| 616 | serverUrl: server.link, |
| 617 | paymentEnabled: server.paymentEnabled, |
| 618 | paymentLabel: server.paymentLabel, |
| 619 | }} |
| 620 | firstSeen={server.firstSeen} |
| 621 | isFavorite={favoriteIds.has(server.id)} |
| 622 | onToggleFavorite={onToggleFavorite} |
| 623 | paymentEnabled={server.paymentEnabled} |
| 624 | paymentLabel={server.paymentLabel} |
| 625 | showAdminControls={isAdmin && !!adminServer} |
| 626 | identityKey={adminServer?.identityKey} |
| 627 | address={adminServer?.address} |
| 628 | isBanned={adminServer?.isBanned} |
| 629 | isApproved={adminServer?.isApproved} |
| 630 | isDenied={adminServer?.isDenied} |
| 631 | bps={adminServer?.bps} |
| 632 | ip={adminServer?.ip} |
| 633 | displayIP={adminServer?.displayIP} |
| 634 | isIPBanned={adminServer?.isIPBanned} |
| 635 | onBanStatusChange={onBanStatusChange} |
| 636 | onBPSChange={onBPSChange} |
| 637 | onApproveStatusChange={onApproveStatusChange} |
| 638 | onDenyStatusChange={onDenyStatusChange} |
| 639 | onIPBanStatusChange={onIPBanStatusChange} |
| 640 | isSelected={isSelected} |
| 641 | onToggleSelect={handleToggleSelect} |
| 642 | /> |
| 643 | ); |
| 644 | }; |
| 645 | |
| 646 | const gridClasses = |
| 647 | `grid grid-cols-1 gap-6 ${isAdmin ? "p-4 min-[500px]:p-6" : "py-4 min-[500px]:py-6"} min-[500px]:grid-cols-2 md:grid-cols-3`; |
| 648 | const serverCards = serverRows.map(renderServerCard); |
| 649 | const serverGrid = |
| 650 | serverCards.length > 0 ? ( |
| 651 | <div className={gridClasses}>{serverCards}</div> |
| 652 | ) : null; |
| 653 | const noMatchingServersMessage = ( |
| 654 | <p className="text-lg text-text-muted">No servers match these filters</p> |
| 655 | ); |
| 656 | |
| 657 | const searchBar = ( |
| 658 | <SearchBar |
| 659 | searchQuery={searchQuery} |
| 660 | onSearchChange={onSearchChange} |
| 661 | status={status} |
| 662 | onStatusChange={onStatusChange} |
| 663 | sortBy={sortBy} |
| 664 | onSortByChange={onSortByChange} |
| 665 | availableTags={availableTags} |
| 666 | selectedTags={selectedTags} |
| 667 | onAddTag={onTagToggle} |
| 668 | onRemoveTag={onTagToggle} |
| 669 | hideFiltersOnMobile={isAdmin} |
| 670 | setShowFilterModal={isAdmin ? setShowFilterModal : undefined} |
| 671 | /> |
| 672 | ); |
| 673 | const publicFooter = ( |
| 674 | <footer className="w-full bg-secondary/35"> |
| 675 | <div className="flex w-full flex-col gap-6 px-6 py-8 sm:px-8 md:flex-row md:items-end md:justify-between lg:px-10"> |
| 676 | <div className="space-y-1.5"> |
| 677 | <a |
| 678 | href={ROUTE_PATHS.home} |
| 679 | className="inline-block text-lg font-bold tracking-normal text-foreground transition-colors hover:text-primary" |
| 680 | > |
| 681 | PORTAL |
| 682 | </a> |
| 683 | <p className="text-sm text-text-muted"> |
| 684 | Public relay index and localhost tunnel launcher. |
| 685 | </p> |
| 686 | </div> |
| 687 | |
| 688 | <nav |
| 689 | aria-label="Footer" |
| 690 | className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-text-muted md:justify-end" |
| 691 | > |
| 692 | <a href={ROUTE_PATHS.admin} className="transition-colors hover:text-foreground"> |
| 693 | Admin |
| 694 | </a> |
| 695 | <a |
| 696 | href={REPOSITORY_URL} |
| 697 | target="_blank" |
| 698 | rel="noopener noreferrer" |
| 699 | className="transition-colors hover:text-foreground" |
| 700 | > |
| 701 | Source |
| 702 | </a> |
| 703 | </nav> |
| 704 | </div> |
| 705 | </footer> |
| 706 | ); |
| 707 | |
| 708 | return ( |
| 709 | <div className="relative flex h-auto min-h-screen w-full flex-col"> |
| 710 | <div className="flex h-full grow flex-col"> |
| 711 | {isAdmin ? ( |
| 712 | <> |
| 713 | <div className="sticky top-0 z-10 w-full bg-background pb-4 pt-5"> |
| 714 | <div className="flex w-full flex-col px-4 sm:px-6 lg:px-8"> |
| 715 | <Header |
| 716 | title={title} |
| 717 | isAdmin={isAdmin} |
| 718 | onAuthChange={onAuthChange} |
| 719 | /> |
| 720 | <div className="flex items-center gap-2"> |
| 721 | <div className="flex-1">{searchBar}</div> |
| 722 | </div> |
| 723 | <div className="mt-4 hidden flex-wrap items-center gap-6 sm:flex"> |
| 724 | {adminFilterControls} |
| 725 | </div> |
| 726 | {onApprovalModeChange && ( |
| 727 | <div className="mt-4 flex items-center gap-3 sm:hidden"> |
| 728 | <span className="text-sm font-medium text-text-muted"> |
| 729 | Approval |
| 730 | </span> |
| 731 | <ApprovalModeToggle |
| 732 | approvalMode={approvalMode} |
| 733 | onApprovalModeChange={onApprovalModeChange} |
| 734 | /> |
| 735 | </div> |
| 736 | )} |
| 737 | </div> |
| 738 | {onLandingPageEnabledChange && ( |
| 739 | <div className="mt-4 flex items-center gap-3 px-4 sm:hidden"> |
| 740 | <span className="text-sm font-medium text-text-muted"> |
| 741 | Landing |
| 742 | </span> |
| 743 | <div className="flex overflow-hidden rounded-lg border border-foreground/20"> |
| 744 | <button |
| 745 | onClick={() => handleLandingPageToggle(true)} |
| 746 | className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${ |
| 747 | landingPageEnabled |
| 748 | ? "bg-primary text-primary-foreground" |
| 749 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 750 | }`} |
| 751 | > |
| 752 | Shown |
| 753 | </button> |
| 754 | <button |
| 755 | onClick={() => handleLandingPageToggle(false)} |
| 756 | className={`cursor-pointer border-l border-foreground/20 px-4 h-10 text-sm font-medium transition-colors ${ |
| 757 | !landingPageEnabled |
| 758 | ? "bg-primary text-primary-foreground" |
| 759 | : "bg-secondary text-secondary-foreground hover:bg-secondary/80" |
| 760 | }`} |
| 761 | > |
| 762 | Hidden |
| 763 | </button> |
| 764 | </div> |
| 765 | </div> |
| 766 | )} |
| 767 | </div> |
| 768 | <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-0"> |
| 769 | <main className="z-0 flex-1"> |
| 770 | {serverGrid ?? ( |
| 771 | <div className="py-12 text-center"> |
| 772 | {noMatchingServersMessage} |
| 773 | </div> |
| 774 | )} |
| 775 | </main> |
| 776 | </div> |
| 777 | </> |
| 778 | ) : ( |
| 779 | <> |
| 780 | <div className="sticky top-0 z-20 w-full bg-background/95 py-5 backdrop-blur supports-backdrop-filter:bg-background/80"> |
| 781 | <div className="flex w-full flex-col px-6 sm:px-8 lg:px-10"> |
| 782 | <Header |
| 783 | title={title} |
| 784 | isAdmin={isAdmin} |
| 785 | onAuthChange={onAuthChange} |
| 786 | showQuickStartLink={landingPageEnabled} |
| 787 | /> |
| 788 | </div> |
| 789 | </div> |
| 790 | <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col border-x border-border/80"> |
| 791 | <main className="z-0 flex-1 pb-14"> |
| 792 | {showLandingHero && ( |
| 793 | <section className="border-b border-border/80 px-4 pt-6 pb-8 sm:px-6 sm:pb-10 md:px-8"> |
| 794 | <LandingHero /> |
| 795 | </section> |
| 796 | )} |
| 797 | |
| 798 | <section |
| 799 | id="live-servers" |
| 800 | aria-labelledby="live-servers-title" |
| 801 | className="scroll-mt-24 min-h-136 border-b border-border/80 px-4 py-8 sm:min-h-144 sm:px-6 md:px-8" |
| 802 | > |
| 803 | <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> |
| 804 | <div className="space-y-2"> |
| 805 | <p className="text-sm font-semibold uppercase tracking-normal text-primary"> |
| 806 | Live apps |
| 807 | </p> |
| 808 | <h2 |
| 809 | id="live-servers-title" |
| 810 | className="text-3xl font-semibold tracking-normal text-foreground" |
| 811 | > |
| 812 | Browse live apps |
| 813 | </h2> |
| 814 | </div> |
| 815 | <TunnelCommandModal /> |
| 816 | </div> |
| 817 | |
| 818 | {serverRows.length > 0 ? ( |
| 819 | <div className="mt-6"> |
| 820 | {searchBar} |
| 821 | <div className="px-1 pt-3 text-sm text-text-muted"> |
| 822 | {filteredServers.length.toLocaleString()} services visible |
| 823 | {paymentAppCount > 0 && |
| 824 | `, including ${paymentAppCount.toLocaleString()} paid app${ |
| 825 | paymentAppCount === 1 ? "" : "s" |
| 826 | }`} |
| 827 | </div> |
| 828 | {serverGrid} |
| 829 | </div> |
| 830 | ) : ( |
| 831 | <div className="mt-6 flex min-h-88 flex-col"> |
| 832 | {searchBar} |
| 833 | <div className="px-1 pt-3 text-sm text-text-muted"> |
| 834 | 0 services visible |
| 835 | </div> |
| 836 | <div className="flex flex-1 items-center justify-center py-12 text-center"> |
| 837 | {noMatchingServersMessage} |
| 838 | </div> |
| 839 | </div> |
| 840 | )} |
| 841 | </section> |
| 842 | |
| 843 | <section |
| 844 | id="public-relays" |
| 845 | aria-labelledby="public-relays-title" |
| 846 | className="scroll-mt-24 px-4 py-8 sm:px-6 md:px-8" |
| 847 | > |
| 848 | <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> |
| 849 | <div className="space-y-2"> |
| 850 | <p className="text-sm font-semibold uppercase tracking-normal text-primary"> |
| 851 | Relays |
| 852 | </p> |
| 853 | <h2 |
| 854 | id="public-relays-title" |
| 855 | className="text-3xl font-semibold tracking-normal text-foreground" |
| 856 | > |
| 857 | Public relays |
| 858 | </h2> |
| 859 | </div> |
| 860 | <a |
| 861 | href={OFFICIAL_REGISTRY_SOURCE_URL} |
| 862 | target="_blank" |
| 863 | rel="noopener noreferrer" |
| 864 | className="inline-flex h-10 items-center justify-center rounded-md bg-primary/10 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/16" |
| 865 | > |
| 866 | Open registry.json |
| 867 | </a> |
| 868 | </div> |
| 869 | |
| 870 | <div className="mt-6 rounded-lg border border-border/80 bg-secondary/25 p-5 sm:p-6"> |
| 871 | {relayDiscoveryLoading ? ( |
| 872 | <div className="rounded-md border border-border/70 bg-background px-4 py-3 text-sm text-text-muted"> |
| 873 | Loading known relays... |
| 874 | </div> |
| 875 | ) : knownRelays.length === 0 ? ( |
| 876 | <div className="rounded-md border border-border/70 bg-background px-4 py-3 text-sm text-text-muted"> |
| 877 | No known relays discovered from this relay. |
| 878 | </div> |
| 879 | ) : ( |
| 880 | <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> |
| 881 | {knownRelays.map((relay) => ( |
| 882 | <div |
| 883 | key={relay.relayURL} |
| 884 | className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border/70 bg-background px-4 py-3" |
| 885 | > |
| 886 | <a |
| 887 | href={relay.relayURL} |
| 888 | target="_blank" |
| 889 | rel="noopener noreferrer" |
| 890 | className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] text-foreground underline-offset-4 hover:underline sm:text-sm" |
| 891 | > |
| 892 | {relay.relayURL} |
| 893 | </a> |
| 894 | <div className="flex shrink-0 items-center gap-2"> |
| 895 | <span className="rounded-sm bg-secondary/70 px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border"> |
| 896 | {relayReleaseLabel( |
| 897 | relayReleaseVersions, |
| 898 | relay.relayURL |
| 899 | )} |
| 900 | </span> |
| 901 | </div> |
| 902 | </div> |
| 903 | ))} |
| 904 | </div> |
| 905 | )} |
| 906 | </div> |
| 907 | </section> |
| 908 | </main> |
| 909 | </div> |
| 910 | {publicFooter} |
| 911 | </> |
| 912 | )} |
| 913 | </div> |
| 914 | |
| 915 | {isAdmin && ( |
| 916 | <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}> |
| 917 | <DialogContent className="sm:hidden max-w-sm rounded-sm"> |
| 918 | <DialogHeader> |
| 919 | <DialogTitle>Filters</DialogTitle> |
| 920 | </DialogHeader> |
| 921 | <div className="flex flex-col gap-4"> |
| 922 | <div className="flex flex-col gap-2"> |
| 923 | <span className="text-sm font-medium text-text-muted"> |
| 924 | Status |
| 925 | </span> |
| 926 | <StatusSelect |
| 927 | status={status} |
| 928 | onStatusChange={onStatusChange} |
| 929 | className="w-full!" |
| 930 | /> |
| 931 | </div> |
| 932 | {onBanFilterChange && ( |
| 933 | <div className="flex flex-col gap-2"> |
| 934 | <span className="text-sm font-medium text-text-muted"> |
| 935 | Ban Status |
| 936 | </span> |
| 937 | <BanStatusButtons |
| 938 | className="[&>button]:w-full" |
| 939 | banFilter={banFilter} |
| 940 | onBanFilterChange={onBanFilterChange} |
| 941 | /> |
| 942 | </div> |
| 943 | )} |
| 944 | <div className="flex flex-col gap-2"> |
| 945 | <span className="text-sm font-medium text-text-muted">Sort</span> |
| 946 | <SortbySelect |
| 947 | className="w-full!" |
| 948 | sortBy={sortBy} |
| 949 | onSortByChange={onSortByChange} |
| 950 | /> |
| 951 | </div> |
| 952 | <div className="flex flex-col gap-2"> |
| 953 | <span className="text-sm font-medium text-text-muted">Tags</span> |
| 954 | <TagCombobox |
| 955 | availableTags={availableTags} |
| 956 | selectedTags={selectedTags} |
| 957 | onAdd={onTagToggle} |
| 958 | onRemove={onTagToggle} |
| 959 | /> |
| 960 | </div> |
| 961 | </div> |
| 962 | </DialogContent> |
| 963 | </Dialog> |
| 964 | )} |
| 965 | |
| 966 | {isAdmin && ( |
| 967 | <FloatingActionBar |
| 968 | selectedCount={selectedIdentityKeys.size} |
| 969 | totalCount={allIdentityKeys.length} |
| 970 | isAllSelected={isAllSelected} |
| 971 | onSelectAll={handleSelectAll} |
| 972 | onApprove={handleBulkApprove} |
| 973 | onDeny={handleBulkDeny} |
| 974 | onBan={handleBulkBan} |
| 975 | /> |
| 976 | )} |
| 977 | </div> |
| 978 | ); |
| 979 | } |