feat: enhance server list and detail views with payment information

- Removed unused state for relay discovery message in ServerListView. - Added payment app count and display in ServerListView. - Updated ServerListView to include paymentEnabled and paymentLabel in server props. - Improved styling consistency across various components including ThemeToggleButton, TunnelCommandForm, and TunnelCommandModal. - Introduced payment resolution logic in metadata handling to determine payment status and labels. - Updated server detail view to conditionally display payment badges for paid apps. - Adjusted CSS variables for improved design consistency and visual hierarchy.

rabbitprincess committed Jun 7, 2026 at 08:48 UTC ff4cc560d00e27ca033a87c7b3db316f0d60411f
15 files changed +284 -557
frontend/src/components/FloatingActionBar.tsx
+2 -2
@@ -58,10 +58,10 @@ export const FloatingActionBar = ({
58
59 return (
60 <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
61 - <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-xl shadow-2xl border border-foreground/20">
61 + <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-lg shadow-lg border border-foreground/20">
62 <button
63 onClick={onSelectAll}
64 - className={`px-3 h-10 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
64 + className={`px-3 h-10 text-sm font-medium rounded-md transition-colors whitespace-nowrap ${
65 isAllSelected
66 ? "bg-primary text-primary-foreground"
67 : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
frontend/src/components/Header.tsx
+7 -7
@@ -106,16 +106,16 @@ export function Header({
106 </div>
107
108 <div className="flex min-w-0 flex-wrap items-center gap-2.5">
109 - <h2 className="min-w-0 wrap-break-word text-xl leading-none font-extrabold tracking-tight text-foreground sm:text-2xl">
109 + <h2 className="min-w-0 wrap-break-word text-xl leading-none font-extrabold tracking-normal text-foreground sm:text-2xl">
110 {title}
111 </h2>
112 {releaseVersion && (
113 - <span className="inline-flex h-6 items-center rounded-full bg-secondary px-2.5 text-xs font-semibold text-text-muted">
113 + <span className="inline-flex h-6 items-center rounded-md bg-secondary px-2.5 text-xs font-semibold text-text-muted">
114 {releaseVersion}
115 </span>
116 )}
117 {ensVerified && (
118 - <span className="inline-flex h-6 items-center rounded-full bg-primary/12 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/20">
118 + <span className="inline-flex h-6 items-center rounded-md bg-primary/12 px-2.5 text-xs font-semibold text-primary ring-1 ring-primary/20">
119 ENS verified
120 </span>
121 )}
@@ -154,7 +154,7 @@ export function Header({
154 href={repoURL}
155 target="_blank"
156 rel="noopener noreferrer"
157 - className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary"
157 + className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:text-primary"
158 aria-label="View source on GitHub"
159 >
160 <svg
@@ -193,14 +193,14 @@ export function Header({
193 onChange={(event) => setAdminToken(event.target.value)}
194 placeholder="Admin token"
195 autoComplete="current-password"
196 - className="h-12 w-44 rounded-full border border-border/70 bg-background/90 px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/50 sm:w-56"
196 + className="h-12 w-44 rounded-md border border-border/70 bg-background/90 px-4 text-sm text-foreground shadow-sm outline-none transition focus:border-primary/50 sm:w-56"
197 disabled={isLoading}
198 />
199 <Button
200 type="submit"
201 variant="outline"
202 disabled={isLoading}
203 - className="h-12 rounded-full border-border/70 bg-background/90 px-4 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:text-primary disabled:cursor-not-allowed"
203 + className="h-12 rounded-md border-border/70 bg-background/90 px-4 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:text-primary disabled:cursor-not-allowed"
204 >
205 {isLoading ? (
206 <Loader2 className="h-5 w-5 animate-spin" />
@@ -218,7 +218,7 @@ export function Header({
218 variant="outline"
219 size="icon"
220 onClick={handleLogout}
221 - className="h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-destructive/40 hover:bg-background hover:text-destructive"
221 + className="h-12 w-12 cursor-pointer rounded-md border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-destructive/40 hover:bg-background hover:text-destructive"
222 aria-label="Logout"
223 >
224 <LogOut className="h-5 w-5" />
frontend/src/components/LandingHero.tsx
+70 -410
@@ -1,304 +1,53 @@
1 -import {
2 - startTransition,
3 - useEffect,
4 - useMemo,
5 - useRef,
6 - useState,
7 - type PointerEvent as ReactPointerEvent,
8 -} from "react";
1 import { TunnelCommandForm } from "@/components/TunnelCommandForm";
2
11 -const heroDifferentiatorCards = [
3 +const coreFeatures = [
4 {
13 - key: "login",
14 - title: "No Login",
15 - description: "Run the command immediately without accounts or auth flows.",
16 - },
17 - {
18 - key: "billing",
19 - title: "No Billing",
20 - description: "No credit card, no plan gate, and no billing step before go-live.",
21 - },
22 - {
23 - key: "cloud",
24 - title: "No Cloud SaaS",
25 - description: "No dashboard, region picker, or managed cloud setup to get started.",
26 - },
27 - {
28 - key: "permissionless",
29 - title: "Permissionless",
30 - description: "Use the public registry or attach your own relay. No approval required.",
31 - },
32 -] as const;
33 -
34 -const heroFeatures = [
35 - {
36 - eyebrow: "HTTPS",
5 + eyebrow: "Ingress",
6 title: "Public HTTPS for localhost",
7 description:
39 - "Publish local apps through TCP passthrough without opening inbound ports.",
8 + "Publish local services through public relays without opening inbound ports.",
9 },
10 {
11 eyebrow: "TLS",
43 - title: "End-to-end TLS on your side",
12 + title: "Keyless end-to-end tenant TLS",
13 description:
45 - "Tenant TLS terminates locally with MITM detection, so relays cannot access plaintext.",
14 + "Relays sign handshakes without session keys; ECH hides SNI and self-probes flag suspected MITM.",
15 },
16 {
48 - eyebrow: "Setup",
49 - title: "One-command setup",
17 + eyebrow: "Relays",
18 + title: "Self-hosted anonymous relays",
19 description:
51 - "Start relays and tunnels with minimal setup and a short copy-paste path.",
20 + "Use discovered public relays or run your own without a central account or operator.",
21 },
22 {
54 - eyebrow: "Relay",
55 - title: "Self-hosted relays and pools",
23 + eyebrow: "Multi-hop",
24 + title: "Multihop Routing (like Tor)",
25 description:
57 - "Connect to public relays, use discovered relays as a pool with failover, or run your own.",
26 + "Route through multiple relays so each hop sees only its neighbors, not the full path.",
27 },
28 {
60 - eyebrow: "Transport",
61 - title: "Raw TCP with optional UDP",
29 + eyebrow: "Payments",
30 + title: "x402 for the agentic web",
31 description:
63 - "Carry web traffic and arbitrary protocols without SSH or WebSocket overlays.",
32 + "Agents and browsers pay with Sui USDC in-flow; the tunnel enforces access to protected routes.",
33 },
34 {
66 - eyebrow: "Identity",
67 - title: "SIWE ownership with ENS support",
35 + eyebrow: "Transport",
36 + title: "Web traffic and raw protocols",
37 description:
69 - "Authenticate ownership with SIWE and keep identity portable with ENS-based naming support.",
38 + "Carry HTTPS, raw TCP, and UDP workloads without SSH or WebSocket overlays.",
39 },
40 ] as const;
41
42 export function LandingHero() {
74 - const carouselCardCount = heroDifferentiatorCards.length;
75 - const carouselLoopBoundaryIndex = carouselCardCount + 1;
76 - const carouselTransitionDurationMs = 700;
77 - const [reduceMotion, setReduceMotion] = useState(false);
78 - const [isDragging, setIsDragging] = useState(false);
79 - const [trackIndex, setTrackIndex] = useState(1);
80 - const [transitionEnabled, setTransitionEnabled] = useState(true);
81 - const [slideSize, setSlideSize] = useState(328);
82 - const [dragOffset, setDragOffset] = useState(0);
83 - const dragStartXRef = useRef<number | null>(null);
84 - const dragOffsetRef = useRef(0);
85 - const pointerIdRef = useRef<number | null>(null);
86 -
87 - const slideGap = 16;
88 - const carouselSlides = useMemo(
89 - () => [
90 - heroDifferentiatorCards[carouselCardCount - 1],
91 - ...heroDifferentiatorCards,
92 - heroDifferentiatorCards[0],
93 - ],
94 - [carouselCardCount]
95 - );
96 - const renderedTrackIndex = Math.min(
97 - Math.max(trackIndex, 0),
98 - carouselSlides.length - 1
99 - );
100 - const trackTranslateX = `calc(50% - ${slideSize / 2}px - ${
101 - renderedTrackIndex * (slideSize + slideGap)
102 - }px ${dragOffset >= 0 ? "+" : "-"} ${Math.abs(dragOffset)}px)`;
103 -
104 - useEffect(() => {
105 - if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
106 - return;
107 - }
108 -
109 - const media = window.matchMedia("(prefers-reduced-motion: reduce)");
110 - const syncReduceMotion = () => {
111 - setReduceMotion(media.matches);
112 - };
113 -
114 - syncReduceMotion();
115 -
116 - if (typeof media.addEventListener === "function") {
117 - media.addEventListener("change", syncReduceMotion);
118 - return () => media.removeEventListener("change", syncReduceMotion);
119 - }
120 -
121 - media.addListener(syncReduceMotion);
122 - return () => media.removeListener(syncReduceMotion);
123 - }, []);
124 -
125 - useEffect(() => {
126 - if (typeof window === "undefined") {
127 - return;
128 - }
129 -
130 - const updateSlideSize = () => {
131 - if (window.innerWidth >= 1024) {
132 - setSlideSize(560);
133 - return;
134 - }
135 -
136 - if (window.innerWidth >= 640) {
137 - setSlideSize(472);
138 - return;
139 - }
140 -
141 - const maxMobileWidth = Math.min(window.innerWidth - 48, 368);
142 - setSlideSize(Math.max(maxMobileWidth, 288));
143 - };
144 -
145 - updateSlideSize();
146 - window.addEventListener("resize", updateSlideSize);
147 -
148 - return () => {
149 - window.removeEventListener("resize", updateSlideSize);
150 - };
151 - }, []);
152 -
153 - useEffect(() => {
154 - if (reduceMotion || isDragging) {
155 - return;
156 - }
157 -
158 - const interval = window.setInterval(() => {
159 - startTransition(() => {
160 - setTransitionEnabled(true);
161 - setTrackIndex((current) =>
162 - current >= carouselLoopBoundaryIndex
163 - ? carouselLoopBoundaryIndex
164 - : current + 1
165 - );
166 - });
167 - }, 2200);
168 -
169 - return () => {
170 - window.clearInterval(interval);
171 - };
172 - }, [carouselLoopBoundaryIndex, isDragging, reduceMotion]);
173 -
174 - useEffect(() => {
175 - if (transitionEnabled) {
176 - return;
177 - }
178 -
179 - if (typeof window === "undefined") {
180 - return;
181 - }
182 -
183 - const frame = window.requestAnimationFrame(() => {
184 - window.requestAnimationFrame(() => {
185 - setTransitionEnabled(true);
186 - });
187 - });
188 -
189 - return () => {
190 - window.cancelAnimationFrame(frame);
191 - };
192 - }, [transitionEnabled]);
193 -
194 - useEffect(() => {
195 - if (isDragging) {
196 - return;
197 - }
198 -
199 - if (trackIndex !== 0 && trackIndex !== carouselLoopBoundaryIndex) {
200 - return;
201 - }
202 -
203 - const timer = window.setTimeout(() => {
204 - setTransitionEnabled(false);
205 - setTrackIndex(trackIndex === 0 ? carouselCardCount : 1);
206 - }, carouselTransitionDurationMs);
207 -
208 - return () => {
209 - window.clearTimeout(timer);
210 - };
211 - }, [
212 - carouselCardCount,
213 - carouselLoopBoundaryIndex,
214 - carouselTransitionDurationMs,
215 - isDragging,
216 - trackIndex,
217 - ]);
218 -
219 - const finishDrag = (shouldAdvance: boolean, direction: "next" | "prev" | null) => {
220 - dragStartXRef.current = null;
221 - dragOffsetRef.current = 0;
222 - pointerIdRef.current = null;
223 - setIsDragging(false);
224 - setTransitionEnabled(true);
225 - setDragOffset(0);
226 -
227 - if (!shouldAdvance || !direction) {
228 - return;
229 - }
230 -
231 - setTrackIndex((current) => {
232 - if (direction === "next") {
233 - return current >= carouselLoopBoundaryIndex
234 - ? carouselLoopBoundaryIndex
235 - : current + 1;
236 - }
237 -
238 - return current <= 0 ? 0 : current - 1;
239 - });
240 - };
241 -
242 - const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
243 - if (event.pointerType === "mouse" && event.button !== 0) {
244 - return;
245 - }
246 -
247 - dragStartXRef.current = event.clientX;
248 - dragOffsetRef.current = 0;
249 - pointerIdRef.current = event.pointerId;
250 - setIsDragging(true);
251 - setTransitionEnabled(false);
252 - setDragOffset(0);
253 - event.currentTarget.setPointerCapture(event.pointerId);
254 - };
255 -
256 - const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
257 - if (
258 - !isDragging ||
259 - dragStartXRef.current === null ||
260 - pointerIdRef.current !== event.pointerId
261 - ) {
262 - return;
263 - }
264 -
265 - const nextOffset = event.clientX - dragStartXRef.current;
266 - dragOffsetRef.current = nextOffset;
267 - setDragOffset(nextOffset);
268 - };
269 -
270 - const handlePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
271 - if (!isDragging || pointerIdRef.current !== event.pointerId) {
272 - return;
273 - }
274 -
275 - if (event.currentTarget.hasPointerCapture(event.pointerId)) {
276 - event.currentTarget.releasePointerCapture(event.pointerId);
277 - }
278 -
279 - const threshold = Math.min(88, slideSize * 0.16);
280 - const shouldAdvance = Math.abs(dragOffsetRef.current) > threshold;
281 - const direction =
282 - dragOffsetRef.current < 0
283 - ? "next"
284 - : dragOffsetRef.current > 0
285 - ? "prev"
286 - : null;
287 -
288 - finishDrag(shouldAdvance, direction);
289 - };
290 -
43 return (
44 <section
45 aria-labelledby="landing-title"
294 - className="relative pt-10 sm:pt-12 lg:pt-16"
46 + className="relative pt-10 sm:pt-12 lg:pt-14"
47 >
296 - <div
297 - aria-hidden="true"
298 - className="pointer-events-none absolute inset-0"
299 - >
48 + <div aria-hidden="true" className="pointer-events-none absolute inset-0">
49 <div
301 - className="absolute inset-0 opacity-45 bg-size-[14px_14px] mask-[linear-gradient(to_bottom,white,transparent_82%)]"
50 + className="absolute inset-0 opacity-45 bg-size-[14px_14px] mask-[linear-gradient(to_bottom,white,transparent_80%)]"
51 style={{
52 backgroundImage:
53 "radial-gradient(var(--hero-grid-dot) 0.8px, transparent 0.8px)",
@@ -308,130 +57,81 @@ export function LandingHero() {
57
58 <a
59 href="#live-servers"
311 - className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-full focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
60 + className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-6 focus:z-20 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground"
61 >
62 Skip to live servers
63 </a>
64
316 - <div className="relative mx-auto max-w-4xl px-2 text-center sm:px-4">
317 - <h1
318 - id="landing-title"
319 - className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-7xl"
320 - style={{ lineHeight: 0.96 }}
321 - >
322 - <span className="block">Expose Local Apps</span>
323 - <span
324 - className="mt-2 block bg-clip-text text-transparent"
65 + <div className="relative mx-auto max-w-6xl">
66 + <div className="mx-auto max-w-4xl text-center">
67 + <h1
68 + id="landing-title"
69 + className="text-4xl font-extrabold tracking-normal text-foreground sm:text-5xl lg:text-7xl"
70 + style={{ lineHeight: 0.96 }}
71 + >
72 + <span className="block">Expose Local Apps</span>
73 + <span className="mt-2 block text-primary">
74 + To The Public Internet
75 + </span>
76 + </h1>
77 + <p className="mx-auto mt-5 max-w-2xl text-base leading-7 text-text-muted sm:text-lg">
78 + Use relays as the public edge while your tunnel remains the app
79 + endpoint and owns routing behavior.
80 + </p>
81 + </div>
82 +
83 + <div id="quick-start" className="relative mt-9 scroll-mt-24 sm:mt-10">
84 + <div
85 + className="relative mx-auto w-full max-w-150 rounded-lg border px-4 py-5 sm:px-5 sm:py-6"
86 style={{
326 - backgroundImage:
327 - "linear-gradient(90deg, var(--hero-gradient-start) 0%, var(--hero-gradient-mid) 46%, var(--hero-gradient-end) 100%)",
87 + background: "var(--hero-terminal-bg)",
88 + borderColor: "var(--hero-terminal-border)",
89 + color: "var(--hero-terminal-foreground)",
90 + boxShadow: "0 18px 44px var(--hero-terminal-shadow)",
91 }}
92 >
330 - To The Public Internet
331 - </span>
332 - </h1>
333 - </div>
334 -
335 - <div className="relative mt-10 -mx-4 w-auto sm:-mx-6 md:-mx-8">
336 - <div className="overflow-hidden border-b border-border/80 bg-transparent">
337 - <div className="relative mx-auto max-w-7xl px-3 py-6 sm:px-6 sm:py-8">
338 - <div className="pointer-events-none absolute inset-x-0 top-6 flex justify-center sm:top-8">
339 - <div className="h-28 w-28 rounded-full bg-primary/16 blur-3xl dark:bg-primary/22" />
340 - </div>
341 - <div className="pointer-events-none absolute inset-y-0 left-0 z-40 w-12 bg-linear-to-r from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
342 - <div className="pointer-events-none absolute inset-y-0 right-0 z-40 w-12 bg-linear-to-l from-background via-background/74 to-transparent dark:from-background dark:via-background/60 sm:w-24" />
343 -
344 - <div className="relative h-82 sm:h-90">
345 - <div
346 - onPointerDown={handlePointerDown}
347 - onPointerMove={handlePointerMove}
348 - onPointerUp={handlePointerEnd}
349 - onPointerCancel={handlePointerEnd}
350 - className={`flex h-full items-start gap-4 px-1 pt-6 sm:px-4 sm:pt-8 ${
351 - transitionEnabled && !isDragging
352 - ? "transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
353 - : "transition-none"
354 - } ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
355 - style={{
356 - transform: `translateX(${trackTranslateX})`,
357 - touchAction: "pan-y",
358 - }}
359 - >
360 - {carouselSlides.map((card, index) => {
361 - const distance = Math.abs(index - trackIndex);
362 - const isActive = index === trackIndex;
363 -
364 - return (
365 - <article
366 - key={`${card.key}-${index}`}
367 - className={`relative h-61 shrink-0 overflow-hidden rounded-[1.65rem] border px-5 py-5 text-left transition-[opacity,transform,box-shadow] duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] sm:h-67 sm:px-7 sm:py-6 ${
368 - isActive
369 - ? "border-primary/24 bg-white/92 opacity-100 shadow-[0_24px_54px_rgba(15,23,42,0.08)] dark:bg-white/8 dark:shadow-[0_26px_60px_rgba(0,0,0,0.22)]"
370 - : distance === 1
371 - ? "border-border/70 bg-background/78 opacity-62 shadow-[0_14px_32px_rgba(15,23,42,0.04)] dark:bg-white/4"
372 - : "border-border/60 bg-background/70 opacity-30 shadow-none dark:bg-white/3"
373 - } ${isActive ? "translate-y-0 scale-100" : "translate-y-5 scale-[0.95]"}`}
374 - style={{ width: `${slideSize}px` }}
375 - aria-hidden={!isActive}
376 - >
377 - <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.12),transparent_36%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(75,195,230,0.14),transparent_36%)]" />
378 - <div className="relative flex h-full flex-col">
379 - <div className="flex items-center gap-4">
380 - <span className="inline-flex rounded-full bg-primary/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
381 - Portal
382 - </span>
383 - </div>
384 - <div className="mt-10 space-y-3">
385 - <h3 className="max-w-[12ch] text-[1.9rem] leading-[0.92] font-semibold tracking-tight text-foreground sm:text-[2.2rem]">
386 - {card.title}
387 - </h3>
388 - <p className="max-w-[34ch] text-[0.98rem] leading-6 text-text-muted sm:text-[1rem]">
389 - {card.description}
390 - </p>
391 - </div>
392 - <div className="mt-auto pt-8">
393 - <div className="h-px w-16 bg-linear-to-r from-primary/55 to-transparent" />
394 - </div>
395 - </div>
396 - </article>
397 - );
398 - })}
93 + <div className="mb-5 flex min-w-0 items-center justify-between gap-3">
94 + <div className="flex min-w-0 items-center gap-3">
95 + <span
96 + aria-hidden="true"
97 + className="shrink-0 font-mono text-lg leading-none"
98 + style={{ color: "var(--hero-terminal-accent)" }}
99 + >
100 + {">"}
101 + </span>
102 + <h2
103 + id="tunnel-preview"
104 + className="min-w-0 text-xl font-bold tracking-normal sm:text-2xl"
105 + >
106 + Start a tunnel
107 + </h2>
108 </div>
109 </div>
110 + <TunnelCommandForm theme="terminal" mode="hero" />
111 </div>
112 </div>
113 </div>
114
405 - <div className="relative -mx-4 w-auto sm:-mx-6 md:-mx-8">
406 - <div className="mx-auto max-w-6xl px-4 pb-5 text-left sm:px-6">
407 - <div className="space-y-2">
408 - <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
409 - Core features
410 - </p>
411 - <h2 className="text-3xl font-semibold tracking-tight text-foreground">
412 - Built for real localhost publishing
413 - </h2>
414 - </div>
415 - </div>
416 - <div className="overflow-hidden border-t border-border/80 bg-border/70">
115 + <div className="relative mt-10 -mx-4 w-auto sm:-mx-6 md:-mx-8">
116 + <div className="overflow-hidden border-y border-border/80 bg-border/80">
117 <div className="grid gap-px sm:grid-cols-2 lg:grid-cols-3">
418 - {heroFeatures.map(({ eyebrow, title, description }) => (
118 + {coreFeatures.map(({ eyebrow, title, description }) => (
119 <article
120 key={title}
421 - className="flex min-h-52 bg-background/88 p-6 text-left transition-colors duration-200 hover:bg-background/92 sm:min-h-56 sm:p-7"
121 + className="flex min-h-48 bg-background p-6 text-left transition-colors duration-200 hover:bg-secondary/35 sm:min-h-52 sm:p-7"
122 >
123 <div className="flex h-full flex-col space-y-3">
424 - <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-primary/80">
124 + <p className="text-[11px] font-semibold uppercase tracking-normal text-primary/80">
125 {eyebrow}
126 </p>
427 - <h3 className="text-[1.2rem] font-semibold tracking-tight text-foreground sm:text-[1.32rem] sm:leading-tight">
127 + <h3 className="text-[1.2rem] font-semibold tracking-normal text-foreground sm:text-[1.32rem] sm:leading-tight">
128 {title}
129 </h3>
430 - <p className="max-w-[28ch] text-[0.95rem] leading-6 text-text-muted">
130 + <p className="max-w-[30ch] text-[0.95rem] leading-6 text-text-muted">
131 {description}
132 </p>
133 <div className="mt-auto pt-4">
434 - <div className="h-px w-12 bg-linear-to-r from-primary/55 to-transparent" />
134 + <div className="h-px w-10 bg-primary/45" />
135 </div>
136 </div>
137 </article>
@@ -439,46 +139,6 @@ export function LandingHero() {
139 </div>
140 </div>
141 </div>
442 -
443 - <div id="quick-start" className="relative mt-8 scroll-mt-24 sm:mt-10">
444 - <div className="mx-auto w-full max-w-6xl text-left">
445 - <div className="space-y-2">
446 - <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
447 - Quick Start
448 - </p>
449 - <h2 className="text-3xl font-semibold tracking-tight text-foreground">
450 - Expose service
451 - </h2>
452 - </div>
453 -
454 - <div
455 - className="relative mx-auto mt-4 w-full max-w-130 rounded-xl border px-4 py-5 sm:px-5 sm:py-6"
456 - style={{
457 - background: "var(--hero-terminal-bg)",
458 - borderColor: "var(--hero-terminal-border)",
459 - color: "var(--hero-terminal-foreground)",
460 - boxShadow: "0 30px 72px var(--hero-terminal-shadow)",
461 - }}
462 - >
463 - <div className="mb-5 flex min-w-0 items-center gap-3">
464 - <span
465 - aria-hidden="true"
466 - className="shrink-0 font-mono text-lg leading-none"
467 - style={{ color: "var(--hero-terminal-accent)" }}
468 - >
469 - {">"}
470 - </span>
471 - <h3
472 - id="tunnel-preview"
473 - className="min-w-0 text-xl font-bold tracking-tight sm:text-2xl"
474 - >
475 - Run this command
476 - </h3>
477 - </div>
478 - <TunnelCommandForm theme="terminal" mode="hero" />
479 - </div>
480 - </div>
481 - </div>
142 </section>
143 );
144 }
frontend/src/components/ServerCard.tsx
+48 -31
@@ -1,6 +1,7 @@
1 import { useEffect, useMemo, useState } from "react";
2 import { Link } from "react-router-dom";
3 import clsx from "clsx";
4 +import { BadgeDollarSign } from "lucide-react";
5 import {
6 Dialog,
7 DialogContent,
@@ -35,6 +36,8 @@ interface ServerCardProps {
36 ip?: string;
37 displayIP?: string;
38 isIPBanned?: boolean;
39 + paymentEnabled?: boolean;
40 + paymentLabel?: string;
41 onBanStatusChange?: (
42 identityKey: string,
43 isBan: boolean
@@ -72,6 +75,8 @@ export function ServerCard({
75 ip = "",
76 displayIP,
77 isIPBanned = false,
78 + paymentEnabled = false,
79 + paymentLabel = "",
80 onBanStatusChange,
81 onBPSChange,
82 onApproveStatusChange,
@@ -84,6 +89,9 @@ export function ServerCard({
89 const [bpsInput, setBpsInput] = useState(bps.toString());
90 const [thumbnailFailed, setThumbnailFailed] = useState(false);
91 const effectiveThumbnail = thumbnailFailed ? "" : thumbnail;
92 + const normalizedPaymentLabel = paymentLabel.trim();
93 + const showPaymentBadge = paymentEnabled || normalizedPaymentLabel !== "";
94 + const effectivePaymentLabel = normalizedPaymentLabel || "Paid app";
95
96 useEffect(() => {
97 setThumbnailFailed(false);
@@ -196,7 +204,7 @@ export function ServerCard({
204 };
205
206 const formatStepLabel = (value: number): string => {
199 - if (value === 0) return "∞";
207 + if (value === 0) return "No cap";
208 if (value >= 1000000) return `${value / 1000000}M`;
209 if (value >= 1000) return `${value / 1000}K`;
210 return value.toString();
@@ -232,7 +240,7 @@ export function ServerCard({
240 <article
241 data-hero-key={`server-bg-${serverId}`}
242 className={clsx(
235 - "relative w-full overflow-hidden rounded-3xl group border border-white/10 shadow-lg",
243 + "relative w-full overflow-hidden rounded-lg group border border-border bg-card shadow-sm transition-shadow hover:shadow-md dark:border-white/10",
244 showAdminControls ? "h-71.5" : "h-[174.5px]"
245 )}
246 >
@@ -254,35 +262,44 @@ export function ServerCard({
262 />
263 )}
264
257 - <div className="absolute inset-0 bg-linear-to-t from-black via-black/60 to-transparent" />
265 + <div className="absolute inset-0 bg-linear-to-t from-black/86 via-black/58 to-black/18" />
266
267 <div className="relative z-10 flex h-full flex-col justify-between p-5">
260 - <div className="flex items-start justify-between">
261 - <div className="flex items-center gap-2 rounded-full bg-black/40 px-3 py-1 backdrop-blur-sm border border-white/5">
262 - <div
263 - className={clsx(
264 - "size-2 rounded-full",
265 - online
266 - ? "bg-primary shadow-[0_0_8px_rgba(0,219,219,0.8)] animate-pulse"
267 - : "bg-gray-500"
268 - )}
269 - />
270 - <span
271 - className={clsx(
272 - "text-[10px] font-bold uppercase tracking-wider",
273 - online ? "text-white" : "text-white/60"
274 - )}
275 - >
276 - {online ? "Online" : "Offline"}
277 - {formattedDuration && online && ` · ${formattedDuration}`}
278 - </span>
268 + <div className="flex items-start justify-between gap-3">
269 + <div className="flex min-w-0 flex-wrap items-center gap-2">
270 + <div className="flex items-center gap-2 rounded-md bg-black/45 px-2.5 py-1 backdrop-blur-sm border border-white/8">
271 + <div
272 + className={clsx(
273 + "size-2 rounded-full",
274 + online
275 + ? "bg-primary"
276 + : "bg-gray-500"
277 + )}
278 + />
279 + <span
280 + className={clsx(
281 + "text-[10px] font-bold uppercase tracking-wider",
282 + online ? "text-white" : "text-white/60"
283 + )}
284 + >
285 + {online ? "Online" : "Offline"}
286 + {formattedDuration && online && ` - ${formattedDuration}`}
287 + </span>
288 + </div>
289 +
290 + {showPaymentBadge && (
291 + <div className="inline-flex max-w-[8.5rem] items-center gap-1.5 rounded-md border border-amber-300/25 bg-amber-300/18 px-2.5 py-1 text-[10px] font-bold uppercase tracking-normal text-amber-100 backdrop-blur-sm">
292 + <BadgeDollarSign className="size-3 shrink-0" />
293 + <span className="min-w-0 truncate">{effectivePaymentLabel}</span>
294 + </div>
295 + )}
296 </div>
297
298 {showAdminControls ? (
299 <button
300 onClick={handleSelectClick}
301 className={clsx(
285 - "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
302 + "flex size-8 items-center justify-center rounded-md backdrop-blur-md transition-colors border border-white/8 cursor-pointer",
303 isSelected
304 ? "bg-primary text-black"
305 : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
@@ -306,7 +323,7 @@ export function ServerCard({
323 <button
324 onClick={handleFavoriteClick}
325 className={clsx(
309 - "flex size-8 items-center justify-center rounded-full backdrop-blur-md transition-colors border border-white/5 cursor-pointer",
326 + "flex size-8 items-center justify-center rounded-md backdrop-blur-md transition-colors border border-white/8 cursor-pointer",
327 isFavorite
328 ? "bg-primary text-black"
329 : "bg-black/40 text-white/70 hover:bg-primary hover:text-black"
@@ -350,7 +367,7 @@ export function ServerCard({
367 {tags.map((tag, index) => (
368 <span
369 key={index}
353 - className="rounded bg-primary/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary border border-primary/30 whitespace-nowrap"
370 + className="rounded-sm bg-primary/16 px-2 py-0.5 text-[10px] font-bold uppercase tracking-normal text-primary border border-primary/24 whitespace-nowrap"
371 >
372 #{tag}
373 </span>
@@ -368,7 +385,7 @@ export function ServerCard({
385
386 {!showAdminControls && effectiveThumbnail && (
387 <div className="shrink-0">
371 - <div className="size-10 overflow-hidden rounded-xl border border-white/20 shadow-lg">
388 + <div className="size-10 overflow-hidden rounded-md border border-white/20 shadow-sm">
389 <img
390 alt={`${name} avatar`}
391 className="h-full w-full object-cover"
@@ -389,7 +406,7 @@ export function ServerCard({
406 </span>
407 <button
408 onClick={handleBPSSettingsClick}
392 - className="px-3 py-1 text-[10px] rounded-full bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
409 + className="px-3 py-1 text-[10px] rounded-md bg-white/10 hover:bg-white/20 text-white/80 transition-colors cursor-pointer border border-white/10"
410 >
411 Settings
412 </button>
@@ -409,13 +426,13 @@ export function ServerCard({
426 <div className="flex gap-2 w-full">
427 <button
428 onClick={handleApproveClick}
412 - className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
429 + className="flex-1 px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white bg-green-600/80 hover:bg-green-600 backdrop-blur-sm"
430 >
431 Approve
432 </button>
433 <button
434 onClick={handleDenyClick}
418 - className="flex-1 px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
435 + className="flex-1 px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white bg-red-600/80 hover:bg-red-600 backdrop-blur-sm"
436 >
437 Deny
438 </button>
@@ -424,7 +441,7 @@ export function ServerCard({
441 <button
442 onClick={ip ? handleIPBanClick : handleBanClick}
443 className={clsx(
427 - "w-full px-4 py-2 rounded-lg font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
444 + "w-full px-4 py-2 rounded-md font-medium text-xs transition-colors cursor-pointer text-white backdrop-blur-sm",
445 (ip ? isIPBanned : isBanned)
446 ? "bg-green-600/80 hover:bg-green-600"
447 : "bg-red-600/80 hover:bg-red-600"
@@ -461,7 +478,7 @@ export function ServerCard({
478 )}
479
480 <Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
464 - <DialogContent className="max-w-sm rounded-xl">
481 + <DialogContent className="max-w-sm rounded-lg">
482 <DialogHeader>
483 <DialogTitle>BPS Settings</DialogTitle>
484 <DialogDescription>
frontend/src/components/ServerListView.tsx
+24 -21
@@ -208,7 +208,6 @@ export function ServerListView({
208 const [relayDiscoveryLoading, setRelayDiscoveryLoading] = useState(
209 () => !isAdmin
210 );
211 - const [relayDiscoveryMessage, setRelayDiscoveryMessage] = useState("");
211 const [selectedIdentityKeys, setSelectedIdentityKeys] = useState<Set<string>>(
212 new Set()
213 );
@@ -216,6 +215,10 @@ export function ServerListView({
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) => {
@@ -293,10 +296,8 @@ export function ServerListView({
296 setRelayDiscoveryLoading(true);
297 setRelayReleaseVersions({});
298 setKnownRelays([]);
296 - setRelayDiscoveryMessage("");
299
300 void (async () => {
299 - let discoveryMessage = "";
301 let nextKnownRelays = normalizeKnownRelays(undefined, currentRelayURL);
302
303 try {
@@ -307,7 +308,7 @@ export function ServerListView({
308 currentRelayURL
309 );
310 } catch {
310 - discoveryMessage = "Known relay data is unavailable on this relay.";
311 + // Keep the current relay fallback when discovery is unavailable.
312 }
313
314 if (cancelled) {
@@ -316,7 +317,6 @@ export function ServerListView({
317
318 setKnownRelays(nextKnownRelays);
319 setRelayDiscoveryLoading(false);
319 - setRelayDiscoveryMessage(discoveryMessage);
320
321 const relayURLs = nextKnownRelays.map((relay) => relay.relayURL);
322 const uniqueRelayURLs = [...new Set(relayURLs)];
@@ -614,10 +614,14 @@ export function ServerListView({
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}
@@ -672,7 +676,7 @@ export function ServerListView({
676 <div className="space-y-1.5">
677 <a
678 href={ROUTE_PATHS.home}
675 - className="inline-block text-lg font-bold tracking-tight text-foreground transition-colors hover:text-primary"
679 + className="inline-block text-lg font-bold tracking-normal text-foreground transition-colors hover:text-primary"
680 >
681 PORTAL
682 </a>
@@ -798,12 +802,12 @@ export function ServerListView({
802 >
803 <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
804 <div className="space-y-2">
801 - <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
805 + <p className="text-sm font-semibold uppercase tracking-normal text-primary">
806 Live apps
807 </p>
808 <h2
809 id="live-servers-title"
806 - className="text-3xl font-semibold tracking-tight text-foreground"
810 + className="text-3xl font-semibold tracking-normal text-foreground"
811 >
812 Browse live apps
813 </h2>
@@ -816,6 +820,10 @@ export function ServerListView({
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>
@@ -839,12 +847,12 @@ export function ServerListView({
847 >
848 <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
849 <div className="space-y-2">
842 - <p className="text-sm font-semibold uppercase tracking-[0.3em] text-primary">
850 + <p className="text-sm font-semibold uppercase tracking-normal text-primary">
851 Relays
852 </p>
853 <h2
854 id="public-relays-title"
847 - className="text-3xl font-semibold tracking-tight text-foreground"
855 + className="text-3xl font-semibold tracking-normal text-foreground"
856 >
857 Public relays
858 </h2>
@@ -853,19 +861,19 @@ export function ServerListView({
861 href={OFFICIAL_REGISTRY_SOURCE_URL}
862 target="_blank"
863 rel="noopener noreferrer"
856 - className="inline-flex h-10 items-center justify-center rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/20"
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
862 - <div className="mt-6 rounded-xl border border-border/80 bg-secondary/35 p-5 sm:p-6">
870 + <div className="mt-6 rounded-lg border border-border/80 bg-secondary/25 p-5 sm:p-6">
871 {relayDiscoveryLoading ? (
864 - <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
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 ? (
868 - <div className="rounded-2xl border border-border/70 bg-background/90 px-4 py-3 text-sm text-text-muted">
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 ) : (
@@ -873,7 +881,7 @@ export function ServerListView({
881 {knownRelays.map((relay) => (
882 <div
883 key={relay.relayURL}
876 - className="flex min-w-0 items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/90 px-4 py-3"
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}
@@ -884,7 +892,7 @@ export function ServerListView({
892 {relay.relayURL}
893 </a>
894 <div className="flex shrink-0 items-center gap-2">
887 - <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
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
@@ -895,11 +903,6 @@ export function ServerListView({
903 ))}
904 </div>
905 )}
898 - {relayDiscoveryMessage ? (
899 - <div className="mt-3 text-sm text-text-muted">
900 - {relayDiscoveryMessage}
901 - </div>
902 - ) : null}
906 </div>
907 </section>
908 </main>
frontend/src/components/ThemeToggleButton.tsx
+1 -1
@@ -18,7 +18,7 @@ export function ThemeToggleButton({ className }: ThemeToggleButtonProps) {
18 size="icon"
19 onClick={toggleTheme}
20 className={clsx(
21 - "h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:bg-background hover:text-primary",
21 + "h-12 w-12 cursor-pointer rounded-md border-border/70 bg-background/90 text-foreground shadow-sm transition-colors hover:border-primary/40 hover:bg-background hover:text-primary",
22 className
23 )}
24 aria-label={`Switch to ${nextTheme} theme`}
frontend/src/components/TunnelCommandForm.tsx
+24 -24
@@ -131,7 +131,7 @@ function HeroTunnelCommandForm({
131 }[serviceStatus];
132 const isPreviewURLDisabled = serviceStatus === "waiting";
133 const heroSectionLabelClass = cn(
134 - "text-[13px] font-semibold tracking-[0.04em] sm:text-sm",
134 + "text-[13px] font-semibold tracking-normal sm:text-sm",
135 isTerminal ? "text-slate-100" : "text-foreground/85"
136 );
137 const heroURLClass = cn(
@@ -139,14 +139,14 @@ function HeroTunnelCommandForm({
139 isTerminal ? "text-sky-300" : "text-primary"
140 );
141 const platformButtonGroupClass = cn(
142 - "flex shrink-0 rounded-lg border p-0.5",
142 + "flex shrink-0 rounded-md border p-0.5",
143 isTerminal
144 ? "border-white/6 bg-white/[0.035]"
145 : "border-border bg-border"
146 );
147 const platformButtonClass = (selected: boolean) =>
148 cn(
149 - "min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
149 + "min-w-[72px] whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[11px] font-semibold transition-colors",
150 selected
151 ? isTerminal
152 ? "bg-white/[0.08] text-slate-200"
@@ -156,7 +156,7 @@ function HeroTunnelCommandForm({
156 : "text-text-muted hover:text-foreground"
157 );
158 const heroControlLabelClass = cn(
159 - "shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em]",
159 + "shrink-0 text-[9px] font-semibold uppercase tracking-normal",
160 isTerminal ? "text-slate-500" : "text-text-muted"
161 );
162 const heroControlInputClass = cn(
@@ -166,7 +166,7 @@ function HeroTunnelCommandForm({
166 : "text-foreground/85 placeholder:text-muted-foreground"
167 );
168 const heroShuffleButtonClass = cn(
169 - "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors",
169 + "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm transition-colors",
170 isTerminal
171 ? "text-slate-500 hover:bg-white/[0.06] hover:text-slate-200"
172 : "text-text-muted hover:bg-foreground/5 hover:text-foreground"
@@ -227,7 +227,7 @@ function HeroTunnelCommandForm({
227 onChange={(event) => setTarget(event.target.value)}
228 placeholder={DEFAULT_HOST}
229 aria-label="Local port or address"
230 - className={cn(heroControlInputClass, "w-[19 font-mono")}
230 + className={cn(heroControlInputClass, "w-20 font-mono")}
231 />
232 </div>
233 <div className="ml-auto flex min-w-0 items-center justify-end gap-2 sm:w-88">
@@ -252,7 +252,7 @@ function HeroTunnelCommandForm({
252 </div>
253 <div
254 className={cn(
255 - "relative min-h-37 rounded-xl border px-4 py-4 pr-14 font-mono text-sm leading-7",
255 + "relative min-h-37 rounded-lg border px-4 py-4 pr-14 font-mono text-sm leading-7",
256 isTerminal
257 ? "border-white/10 bg-black/55 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]"
258 : "border-border/80 bg-border/90 text-foreground"
@@ -262,7 +262,7 @@ function HeroTunnelCommandForm({
262 type="button"
263 onClick={handleCopy}
264 className={cn(
265 - "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-lg transition-colors",
265 + "absolute top-4 right-4 inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors",
266 isTerminal
267 ? "text-emerald-300/75 hover:bg-emerald-400/10 hover:text-emerald-200"
268 : "text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700"
@@ -287,7 +287,7 @@ function HeroTunnelCommandForm({
287 <p className={heroSectionLabelClass}>3. Open this public URL</p>
288 <div
289 className={cn(
290 - "space-y-3 rounded-xl border px-3.5 py-3",
290 + "space-y-3 rounded-lg border px-3.5 py-3",
291 isTerminal
292 ? "border-white/8 bg-white/4.5"
293 : "border-border bg-white"
@@ -419,29 +419,29 @@ function FullTunnelCommandForm({
419 ? "text-xs text-slate-400"
420 : "text-xs leading-5 text-muted-foreground dark:text-zinc-500";
421 const inlineFieldClass = isTerminal
422 - ? "flex items-center overflow-hidden rounded-xl border border-white/10 bg-white/5"
423 - : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-[#272727]";
422 + ? "flex items-center overflow-hidden rounded-lg border border-white/10 bg-white/5"
423 + : "flex items-center overflow-hidden rounded-md border border-border/80 bg-secondary/55 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/30 dark:border-white/10 dark:bg-secondary/60";
424 const inlinePrefixClass = isTerminal
425 - ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-[0.12em] text-slate-400"
426 - : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-[0.08em] text-text-muted dark:border-black/25 dark:text-zinc-400";
425 + ? "shrink-0 border-r border-white/10 px-3 text-xs font-medium uppercase tracking-normal text-slate-400"
426 + : "shrink-0 border-r border-border px-3 text-[0.82rem] font-medium uppercase tracking-normal text-text-muted dark:border-border dark:text-zinc-400";
427 const inlineInputClass = isTerminal
428 ? "h-10 border-0 bg-transparent px-3 text-sm text-white placeholder:text-slate-500 shadow-none focus-visible:ring-0"
429 : "h-9 border-0 bg-transparent px-3 text-sm font-semibold text-foreground placeholder:text-text-muted shadow-none focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-500";
430 const relayFieldClass = isTerminal
431 - ? "flex min-h-12 flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-2.5 py-2"
431 + ? "flex min-h-12 flex-wrap items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-2.5 py-2"
432 : "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-border bg-background px-2 py-2 shadow-sm focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/25 dark:border-white/10 dark:bg-transparent";
433 const relayChipClass = isTerminal
434 ? "inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 py-1.5 text-xs font-medium text-slate-100"
435 - : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-[#22312c] dark:text-[#dceee4]";
435 + : "inline-flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground dark:bg-primary/14 dark:text-primary";
436 const relayChipRemoveClass = isTerminal
437 ? "hover:bg-white/10"
438 - : "text-text-muted hover:bg-background/80 dark:text-[#cfe3d8] dark:hover:bg-black/20";
438 + : "text-text-muted hover:bg-background/80 dark:text-primary dark:hover:bg-black/20";
439 const advancedInputClass = isTerminal
440 ? "h-10 rounded-lg border-white/10 bg-white/5 text-white placeholder:text-slate-500"
441 - : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-[#171717] dark:text-zinc-100 dark:placeholder:text-zinc-500";
441 + : "h-10 rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground shadow-sm dark:border-white/10 dark:bg-background dark:text-zinc-100 dark:placeholder:text-zinc-500";
442 const osGroupClass = isTerminal
443 - ? "flex rounded-xl bg-white/8 p-1"
444 - : "flex rounded-md bg-border p-1 dark:bg-[#272727]";
443 + ? "flex rounded-lg bg-white/8 p-1"
444 + : "flex rounded-md bg-border p-1 dark:bg-secondary/60";
445 const osButtonClass = (selected: boolean) =>
446 cn(
447 "flex-1 rounded-sm px-3 py-2 text-sm font-semibold transition-colors",
@@ -450,12 +450,12 @@ function FullTunnelCommandForm({
450 ? "bg-white/8 text-slate-200"
451 : "text-slate-400 hover:text-slate-300"
452 : selected
453 - ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:ring-transparent"
453 + ? "bg-card text-foreground shadow-sm ring-1 ring-border/80 dark:bg-background dark:text-zinc-100 dark:ring-transparent"
454 : "text-muted-foreground hover:text-foreground dark:text-zinc-500 dark:hover:text-zinc-300"
455 );
456 const commandPanelClass = isTerminal
457 - ? "relative rounded-xl border border-white/10 bg-black/30"
458 - : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-[#272727] dark:ring-transparent";
457 + ? "relative rounded-lg border border-white/10 bg-black/30"
458 + : "relative rounded-md bg-secondary/55 shadow-sm ring-1 ring-border/70 dark:bg-secondary/60 dark:ring-transparent";
459 const commandPreClass = isTerminal
460 ? "overflow-x-auto whitespace-pre-wrap break-all p-4 pr-12 font-mono text-sm leading-7 text-white"
461 : "overflow-x-auto whitespace-pre-wrap break-all p-3 pr-12 font-mono text-sm leading-7 text-foreground dark:text-zinc-100";
@@ -610,7 +610,7 @@ function FullTunnelCommandForm({
610 {showAdvanced ? "Hide advanced options" : "Advanced options"}
611 </button>
612 {showAdvanced && (
613 - <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-[#101010]">
613 + <div className="mt-3 space-y-4 rounded-md border border-border bg-muted/45 px-4 py-4 dark:border-white/8 dark:bg-background/70">
614 <label className="flex items-center gap-2 text-sm text-foreground dark:text-zinc-300">
615 <input
616 type="checkbox"
@@ -676,7 +676,7 @@ function FullTunnelCommandForm({
676 className={advancedInputClass}
677 />
678 {normalizedThumbnailURL && (
679 - <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-[#171717]">
679 + <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-border bg-background dark:border-white/10 dark:bg-background">
680 <img
681 src={normalizedThumbnailURL}
682 alt="Thumbnail preview"
frontend/src/components/TunnelCommandModal.tsx
+3 -3
@@ -16,14 +16,14 @@ export function TunnelCommandModal() {
16 <DialogTrigger asChild>
17 <Button
18 type="button"
19 - className="h-10 cursor-pointer rounded-full bg-primary/12 px-4 text-sm font-semibold text-primary shadow-none transition-colors hover:bg-primary/20"
19 + className="h-10 cursor-pointer rounded-md bg-primary/10 px-4 text-sm font-semibold text-primary shadow-none transition-colors hover:bg-primary/16"
20 >
21 Add Your Server
22 </Button>
23 </DialogTrigger>
24 - <DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto rounded-sm border border-border bg-card p-0 text-card-foreground shadow-[0_24px_64px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-[#0b0b0b] dark:text-zinc-100 dark:shadow-[0_28px_80px_rgba(0,0,0,0.5)] [&>button]:right-5 [&>button]:top-5 [&>button]:rounded-md [&>button]:text-text-muted [&>button]:ring-offset-card [&>button]:hover:bg-foreground/5 [&>button]:hover:text-foreground [&>button]:focus:ring-ring/30 [&>button]:data-[state=open]:bg-transparent [&>button]:data-[state=open]:text-text-muted dark:[&>button]:text-zinc-500 dark:[&>button]:ring-offset-[#0b0b0b] dark:[&>button]:hover:bg-white/5 dark:[&>button]:hover:text-zinc-300 dark:[&>button]:focus:ring-white/20 dark:[&>button]:data-[state=open]:text-zinc-500">
24 + <DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto rounded-md border border-border bg-card p-0 text-card-foreground shadow-[0_18px_48px_rgba(15,23,42,0.14)] dark:border-white/10 dark:bg-card dark:text-zinc-100 dark:shadow-[0_22px_56px_rgba(0,0,0,0.42)] [&>button]:right-5 [&>button]:top-5 [&>button]:rounded-md [&>button]:text-text-muted [&>button]:ring-offset-card [&>button]:hover:bg-foreground/5 [&>button]:hover:text-foreground [&>button]:focus:ring-ring/30 [&>button]:data-[state=open]:bg-transparent [&>button]:data-[state=open]:text-text-muted dark:[&>button]:text-zinc-500 dark:[&>button]:ring-offset-card dark:[&>button]:hover:bg-white/5 dark:[&>button]:hover:text-zinc-300 dark:[&>button]:focus:ring-white/20 dark:[&>button]:data-[state=open]:text-zinc-500">
25 <DialogHeader className="px-6 pt-6 pb-4 text-left">
26 - <DialogTitle className="flex items-center gap-2 text-[1.05rem] font-semibold tracking-tight text-foreground dark:text-zinc-100">
26 + <DialogTitle className="flex items-center gap-2 text-[1.05rem] font-semibold tracking-normal text-foreground dark:text-zinc-100">
27 <Terminal className="h-4 w-4" />
28 Tunnel Setup Command
29 </DialogTitle>
frontend/src/components/ui/dialog.tsx
+1 -1
@@ -85,7 +85,7 @@ const DialogTitle = React.forwardRef<
85 <DialogPrimitive.Title
86 ref={ref}
87 className={cn(
88 - "text-lg font-semibold leading-none tracking-tight",
88 + "text-lg font-semibold leading-none tracking-normal",
89 className
90 )}
91 {...props}
frontend/src/hooks/useAdmin.ts
+8 -1
@@ -3,7 +3,11 @@ import { useList, type BaseServer } from "@/hooks/useList";
3 import type { BanFilter } from "@/types/filters";
4 import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5 import { APIClientError, apiClient } from "@/lib/apiClient";
6 -import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
6 +import {
7 + parseLeaseMetadata,
8 + resolveLeasePayment,
9 + resolveLeaseThumbnail,
10 +} from "@/lib/metadata";
11 import type {
12 ApprovalMode,
13 IPPolicyUpdate,
@@ -87,6 +91,7 @@ function toAdminServer(
91 row: PolicyLease,
92 ): AdminServer {
93 const metadata = parseLeaseMetadata(row.metadata);
94 + const payment = resolveLeasePayment(metadata);
95 const hostname = row.hostname || "";
96 const serviceName = row.name || "";
97 const address = row.address.trim();
@@ -103,6 +108,8 @@ function toAdminServer(
108 link: hostname ? `https://${hostname}/` : "",
109 lastUpdated: row.last_seen_at || undefined,
110 firstSeen: row.first_seen_at || undefined,
111 + paymentEnabled: payment.enabled,
112 + paymentLabel: payment.label,
113 identityKey: row.identity_key.trim(),
114 address,
115 isBanned: row.is_banned,
frontend/src/hooks/useList.ts
+8 -5
@@ -1,4 +1,4 @@
1 -import { useCallback, useEffect, useMemo, useState } from "react";
1 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import type { SortOption, StatusFilter } from "@/types/filters";
3
4 export interface BaseServer {
@@ -13,6 +13,8 @@ export interface BaseServer {
13 link: string;
14 lastUpdated?: string;
15 firstSeen?: string;
16 + paymentEnabled?: boolean;
17 + paymentLabel?: string;
18 }
19
20 export interface UseListOptions<T extends BaseServer> {
@@ -95,7 +97,7 @@ export function useList<T extends BaseServer>({
97 const [favorites, setFavorites] = useState<string[]>(() =>
98 readStoredFavorites(storageKey)
99 );
98 - const [dataLoaded, setDataLoaded] = useState(false);
100 + const hasLoadedServersRef = useRef(false);
101
102 useEffect(() => {
103 setFavorites(readStoredFavorites(storageKey));
@@ -128,9 +130,9 @@ export function useList<T extends BaseServer>({
130
131 useEffect(() => {
132 if (servers.length > 0) {
131 - setDataLoaded(true);
133 + hasLoadedServersRef.current = true;
134 }
133 - if (!dataLoaded && servers.length === 0) {
135 + if (!hasLoadedServersRef.current && servers.length === 0) {
136 return;
137 }
138 const validIDs = new Set(servers.map((server) => server.id));
@@ -138,7 +140,7 @@ export function useList<T extends BaseServer>({
140 const next = prev.filter((id) => validIDs.has(id));
141 return next.length === prev.length ? prev : next;
142 });
141 - }, [servers, dataLoaded]);
143 + }, [servers]);
144
145 useEffect(() => {
146 const availableTagSet = new Set(availableTags);
@@ -170,6 +172,7 @@ export function useList<T extends BaseServer>({
172 return (
173 server.name.toLowerCase().includes(query) ||
174 server.description.toLowerCase().includes(query) ||
175 + (server.paymentLabel || "").toLowerCase().includes(query) ||
176 server.tags.some((tag) => tag.toLowerCase().includes(query))
177 );
178 };
frontend/src/hooks/useServerList.ts
+8 -1
@@ -2,7 +2,11 @@ import { useEffect, useMemo, useState } from "react";
2 import { useList, type BaseServer } from "@/hooks/useList";
3 import { apiClient } from "@/lib/apiClient";
4 import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5 -import { parseLeaseMetadata, resolveLeaseThumbnail } from "@/lib/metadata";
5 +import {
6 + parseLeaseMetadata,
7 + resolveLeasePayment,
8 + resolveLeaseThumbnail,
9 +} from "@/lib/metadata";
10 import type { Lease, PublicStateResponse } from "@/types/api";
11
12 type PublicState = {
@@ -13,6 +17,7 @@ type PublicState = {
17 function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
18 return leases.map((row) => {
19 const metadata = parseLeaseMetadata(row.metadata);
20 + const payment = resolveLeasePayment(metadata);
21 const hostname = row.hostname || "";
22 const serviceName = row.name || "";
23
@@ -28,6 +33,8 @@ function convertPublicLeasesToServers(leases: Lease[]): BaseServer[] {
33 link: hostname ? `https://${hostname}/` : "",
34 lastUpdated: row.last_seen_at || undefined,
35 firstSeen: row.first_seen_at || undefined,
36 + paymentEnabled: payment.enabled,
37 + paymentLabel: payment.label,
38 };
39 });
40 }
frontend/src/index.css
+20 -48
@@ -7,9 +7,9 @@
7 --font-family-display: "Manrope", sans-serif;
8 --font-family-sans: "Open Sans", sans-serif;
9
10 - --radius: 0.875rem;
11 - --radius-lg: 1.25rem;
12 - --radius-xl: 1.75rem;
10 + --radius: 0.5rem;
11 + --radius-lg: 0.75rem;
12 + --radius-xl: 1rem;
13
14 --color-background: var(--background);
15 --color-foreground: var(--foreground);
@@ -42,7 +42,7 @@
42 --card-foreground: oklch(25% 0.02 250);
43 --popover: oklch(100% 0 0);
44 --popover-foreground: oklch(25% 0.02 250);
45 - --primary: oklch(75% 0.14 195);
45 + --primary: oklch(60% 0.11 195);
46 --primary-foreground: oklch(100% 0 0);
47 --secondary: oklch(92% 0.01 250);
48 --secondary-foreground: oklch(45% 0.02 250);
@@ -54,30 +54,18 @@
54 --destructive-foreground: oklch(100% 0 0);
55 --border: oklch(90% 0.01 250);
56 --input: oklch(95% 0.005 250);
57 - --ring: oklch(75% 0.14 195);
57 + --ring: oklch(60% 0.11 195);
58
59 --green-status: oklch(55% 0.16 150);
60 --text-muted: oklch(55% 0.03 250);
61 - --accent-orange: oklch(60% 0.2 35);
62 - --accent-magenta: oklch(50% 0.22 350);
63 - --neon-cyan: oklch(75% 0.14 195);
64 -
65 - --hero-grid-dot: oklch(75% 0.14 195 / 0.18);
66 - --hero-gradient-start: var(--accent-orange);
67 - --hero-gradient-mid: var(--neon-cyan);
68 - --hero-gradient-end: var(--accent-magenta);
61 +
62 + --hero-grid-dot: oklch(60% 0.11 195 / 0.14);
63 --hero-terminal-bg:
70 - linear-gradient(160deg, oklch(19% 0.005 250 / 0.98), oklch(14% 0.008 250 / 0.98));
71 - --hero-terminal-border: oklch(75% 0.14 195 / 0.22);
64 + linear-gradient(180deg, oklch(19% 0.005 250 / 0.98), oklch(15% 0.006 250 / 0.98));
65 + --hero-terminal-border: oklch(60% 0.11 195 / 0.22);
66 --hero-terminal-foreground: oklch(98% 0.01 0);
73 - --hero-terminal-accent: var(--neon-cyan);
74 - --hero-terminal-shadow: oklch(15% 0 0 / 0.18);
75 -
76 - --card-media-fallback-start: oklch(97% 0.015 220);
77 - --card-media-fallback-end: oklch(91% 0.04 195);
78 - --card-media-overlay:
79 - radial-gradient(circle at top left, oklch(75% 0.14 195 / 0.18), transparent 36%),
80 - linear-gradient(180deg, transparent, oklch(100% 0 0 / 0.72));
67 + --hero-terminal-accent: var(--primary);
68 + --hero-terminal-shadow: oklch(15% 0 0 / 0.12);
69 }
70
71 .dark {
@@ -87,7 +75,7 @@
75 --card-foreground: oklch(98% 0.01 0);
76 --popover: oklch(20% 0 0);
77 --popover-foreground: oklch(98% 0.01 0);
90 - --primary: oklch(80% 0.14 195);
78 + --primary: oklch(78% 0.1 195);
79 --primary-foreground: oklch(10% 0 0);
80 --secondary: oklch(25% 0.02 195);
81 --secondary-foreground: oklch(98% 0.01 0);
@@ -99,30 +87,18 @@
87 --destructive-foreground: oklch(98% 0 0);
88 --border: oklch(100% 0 0 / 0.1);
89 --input: oklch(22% 0.01 0);
102 - --ring: oklch(80% 0.14 195);
90 + --ring: oklch(78% 0.1 195);
91
92 --green-status: oklch(75% 0.16 150);
93 --text-muted: oklch(65% 0.02 0);
106 - --accent-orange: oklch(65% 0.2 35);
107 - --accent-magenta: oklch(55% 0.25 350);
108 - --neon-cyan: oklch(80% 0.14 195);
109 -
110 - --hero-grid-dot: oklch(80% 0.14 195 / 0.22);
111 - --hero-gradient-start: var(--neon-cyan);
112 - --hero-gradient-mid: var(--accent-magenta);
113 - --hero-gradient-end: var(--accent-orange);
94 +
95 + --hero-grid-dot: oklch(78% 0.1 195 / 0.18);
96 --hero-terminal-bg:
115 - linear-gradient(160deg, oklch(19% 0 0 / 0.78), oklch(12% 0.01 250 / 0.88));
116 - --hero-terminal-border: oklch(80% 0.14 195 / 0.3);
97 + linear-gradient(180deg, oklch(18% 0 0 / 0.9), oklch(12% 0.01 250 / 0.94));
98 + --hero-terminal-border: oklch(78% 0.1 195 / 0.28);
99 --hero-terminal-foreground: oklch(98% 0.01 0);
118 - --hero-terminal-accent: var(--neon-cyan);
100 + --hero-terminal-accent: var(--primary);
101 --hero-terminal-shadow: oklch(0% 0 0 / 0.28);
120 -
121 - --card-media-fallback-start: oklch(26% 0.03 235);
122 - --card-media-fallback-end: oklch(18% 0.02 260);
123 - --card-media-overlay:
124 - radial-gradient(circle at top left, oklch(80% 0.14 195 / 0.26), transparent 36%),
125 - linear-gradient(180deg, transparent, oklch(10% 0 0 / 0.78));
102 }
103
104 * {
@@ -143,9 +119,7 @@
119 body {
120 min-height: 100vh;
121 background:
146 - radial-gradient(circle at top center, oklch(75% 0.14 195 / 0.16), transparent 36%),
147 - radial-gradient(circle at 12% 18%, oklch(60% 0.2 35 / 0.08), transparent 22%),
148 - linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 54%, oklch(95% 0.005 250) 100%);
122 + linear-gradient(180deg, oklch(100% 0 0) 0%, oklch(97% 0.005 250) 62%, oklch(95% 0.005 250) 100%);
123 color: var(--foreground);
124 font-family: var(--font-family-sans);
125 }
@@ -162,8 +136,6 @@
136
137 .dark body {
138 background:
165 - radial-gradient(circle at top center, oklch(80% 0.14 195 / 0.16), transparent 32%),
166 - radial-gradient(circle at 82% 10%, oklch(55% 0.25 350 / 0.12), transparent 20%),
139 linear-gradient(180deg, oklch(16% 0 0) 0%, oklch(13% 0.01 250) 54%, oklch(11% 0.01 250) 100%);
140 }
141
@@ -174,7 +146,7 @@
146 h5,
147 h6 {
148 font-family: var(--font-family-display);
177 - letter-spacing: -0.03em;
149 + letter-spacing: 0;
150 }
151
152 button {
frontend/src/lib/metadata.ts
+35
@@ -7,6 +7,11 @@ interface Metadata {
7 owner: string;
8 }
9
10 +export interface PaymentDisplay {
11 + enabled: boolean;
12 + label: string;
13 +}
14 +
15 const EMPTY_METADATA: Metadata = {
16 description: "",
17 tags: [],
@@ -68,3 +73,33 @@ export function resolveLeaseThumbnail(metadata: Metadata, hostname: string): str
73 }
74 return `${BROWSER_API_PATHS.thumbnail.prefix}${encodeURIComponent(normalizedHostname)}`;
75 }
76 +
77 +export function resolveLeasePayment(metadata: Metadata): PaymentDisplay {
78 + const normalizedTags = new Set(
79 + metadata.tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
80 + );
81 + const description = metadata.description.toLowerCase();
82 + const enabled =
83 + normalizedTags.has("x402") ||
84 + normalizedTags.has("payment") ||
85 + normalizedTags.has("paid") ||
86 + normalizedTags.has("usdc") ||
87 + /\bx402\b|\bpaid\b|\bpayment\b|\busdc\b/.test(description);
88 +
89 + if (!enabled) {
90 + return { enabled: false, label: "" };
91 + }
92 +
93 + const labelParts: string[] = [];
94 + if (normalizedTags.has("x402") || description.includes("x402")) {
95 + labelParts.push("x402");
96 + }
97 + if (normalizedTags.has("usdc") || description.includes("usdc")) {
98 + labelParts.push("USDC");
99 + }
100 +
101 + return {
102 + enabled: true,
103 + label: labelParts.length > 0 ? labelParts.join(" ") : "Paid app",
104 + };
105 +}
frontend/src/pages/ServerDetail.tsx
+25 -2
@@ -1,6 +1,7 @@
1 import { SsgoiTransition } from "@ssgoi/react";
2 import { useEffect } from "react";
3 import { useLocation, useNavigate } from "react-router-dom";
4 +import { BadgeDollarSign } from "lucide-react";
5
6 interface ServerDetailState {
7 id: string;
@@ -11,6 +12,8 @@ interface ServerDetailState {
12 owner: string;
13 online: boolean;
14 serverUrl: string;
15 + paymentEnabled?: boolean;
16 + paymentLabel?: string;
17 }
18
19 export function ServerDetail() {
@@ -70,7 +73,19 @@ export function ServerDetail() {
73 return null;
74 }
75
73 - const { id, thumbnail, name, online, description, tags, owner } = server;
76 + const {
77 + id,
78 + thumbnail,
79 + name,
80 + online,
81 + description,
82 + tags,
83 + owner,
84 + paymentEnabled,
85 + paymentLabel = "",
86 + } = server;
87 + const normalizedPaymentLabel = paymentLabel.trim();
88 + const showPaymentBadge = paymentEnabled || normalizedPaymentLabel !== "";
89
90 // Base size multiplier (1 = default, 2 = 2x size)
91 const basicSize = 2.5;
@@ -84,7 +99,7 @@ export function ServerDetail() {
99 >
100 {/* Content overlay - Full screen */}
101 <div className="absolute inset-0 flex items-center justify-center p-6 md:p-12">
87 - <div className="w-full h-full max-w-7xl bg-background/70 backdrop-blur-sm rounded-xl flex flex-col gap-6 p-8 md:p-12 items-start text-start">
102 + <div className="w-full h-full max-w-7xl bg-background/78 backdrop-blur-sm rounded-lg flex flex-col gap-6 p-8 md:p-12 items-start text-start">
103 <div className="w-full h-full flex flex-col justify-center gap-6 md:gap-8">
104 <div
105 className="flex flex-col"
@@ -112,6 +127,14 @@ export function ServerDetail() {
127 {online ? "Online" : "Offline"}
128 </p>
129 </div>
130 + {showPaymentBadge && (
131 + <div className="inline-flex w-fit max-w-full items-center gap-2 rounded-md border border-amber-400/25 bg-amber-400/10 px-4 py-2 font-display text-amber-700 dark:text-amber-100">
132 + <BadgeDollarSign className="h-5 w-5 shrink-0" />
133 + <span className="truncate text-base font-bold uppercase tracking-wide">
134 + {normalizedPaymentLabel || "Paid app"}
135 + </span>
136 + </div>
137 + )}
138 <p
139 className="text-foreground font-bold leading-tight"
140 style={{ fontSize: `${6 * basicSize}vw` }}