main
tsx 979 lines 33.7 KB
Raw
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 }