main
tsx 548 lines 18.1 KB
Raw
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,
8 DialogDescription,
9 DialogFooter,
10 DialogHeader,
11 DialogTitle,
12 } from "@/components/ui/dialog";
13 import { Button } from "@/components/ui/button";
14
15 interface ServerCardProps {
16 serverId: string;
17 name: string;
18 description: string;
19 tags: string[];
20 thumbnail: string;
21 owner: string;
22 online: boolean;
23 firstSeen?: string;
24 dns: string;
25 navigationPath: string;
26 navigationState: any;
27 isFavorite?: boolean;
28 onToggleFavorite?: (serverId: string) => void;
29 showAdminControls?: boolean;
30 identityKey?: string;
31 address?: string;
32 isBanned?: boolean;
33 isApproved?: boolean;
34 isDenied?: boolean;
35 bps?: number;
36 ip?: string;
37 displayIP?: string;
38 isIPBanned?: boolean;
39 paymentEnabled?: boolean;
40 paymentLabel?: string;
41 onBanStatusChange?: (
42 identityKey: string,
43 isBan: boolean
44 ) => void | Promise<void>;
45 onBPSChange?: (identityKey: string, bps: number) => void | Promise<void>;
46 onApproveStatusChange?: (
47 identityKey: string,
48 approve: boolean
49 ) => void | Promise<void>;
50 onDenyStatusChange?: (identityKey: string, deny: boolean) => void | Promise<void>;
51 onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
52 isSelected?: boolean;
53 onToggleSelect?: (identityKey: string) => void;
54 }
55
56 export function ServerCard({
57 serverId,
58 name,
59 description,
60 tags,
61 thumbnail,
62 owner,
63 online,
64 firstSeen,
65 navigationPath,
66 navigationState,
67 isFavorite = false,
68 onToggleFavorite,
69 showAdminControls = false,
70 identityKey,
71 isBanned = false,
72 isApproved = false,
73 isDenied = false,
74 bps = 0,
75 ip = "",
76 displayIP,
77 isIPBanned = false,
78 paymentEnabled = false,
79 paymentLabel = "",
80 onBanStatusChange,
81 onBPSChange,
82 onApproveStatusChange,
83 onDenyStatusChange,
84 onIPBanStatusChange,
85 isSelected = false,
86 onToggleSelect,
87 }: ServerCardProps) {
88 const [showBPSModal, setShowBPSModal] = useState(false);
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);
98 }, [thumbnail]);
99
100 const bpsSteps = [0, 10, 100, 1000, 10000, 100000, 1000000, 10000000];
101
102 const bpsToSliderIndex = (value: number): number => {
103 if (value === 0) return 0;
104 const idx = bpsSteps.findIndex((step) => step >= value);
105 return idx === -1 ? bpsSteps.length - 1 : idx;
106 };
107
108 const [sliderIndex, setSliderIndex] = useState(bpsToSliderIndex(bps));
109
110 const runAsyncAdminAction = (action?: () => void | Promise<void>) => {
111 if (!action) {
112 return;
113 }
114
115 try {
116 const result = action();
117 if (result instanceof Promise) {
118 void result.catch((error) => {
119 console.error("Failed admin action", error);
120 });
121 }
122 } catch (error) {
123 console.error("Failed admin action", error);
124 }
125 };
126
127 const handleSliderChange = (idx: number) => {
128 setSliderIndex(idx);
129 setBpsInput(bpsSteps[idx].toString());
130 };
131
132 const syncSliderFromInput = (value: number) => {
133 const idx = bpsToSliderIndex(value);
134 setSliderIndex(idx);
135 };
136
137 const handleFavoriteClick = (event: React.MouseEvent) => {
138 event.preventDefault();
139 event.stopPropagation();
140 onToggleFavorite?.(serverId);
141 };
142
143 const handleSelectClick = (event: React.MouseEvent) => {
144 event.preventDefault();
145 event.stopPropagation();
146 if (identityKey && onToggleSelect) {
147 onToggleSelect(identityKey);
148 }
149 };
150
151 const handleBanClick = (event: React.MouseEvent) => {
152 event.preventDefault();
153 event.stopPropagation();
154 if (identityKey) {
155 runAsyncAdminAction(() => onBanStatusChange?.(identityKey, !isBanned));
156 }
157 };
158
159 const handleApproveClick = (event: React.MouseEvent) => {
160 event.preventDefault();
161 event.stopPropagation();
162 if (identityKey) {
163 runAsyncAdminAction(() => onApproveStatusChange?.(identityKey, !isApproved));
164 }
165 };
166
167 const handleDenyClick = (event: React.MouseEvent) => {
168 event.preventDefault();
169 event.stopPropagation();
170 if (identityKey) {
171 runAsyncAdminAction(() => onDenyStatusChange?.(identityKey, !isDenied));
172 }
173 };
174
175 const handleIPBanClick = (event: React.MouseEvent) => {
176 event.preventDefault();
177 event.stopPropagation();
178 if (ip) {
179 runAsyncAdminAction(() => onIPBanStatusChange?.(ip, !isIPBanned));
180 }
181 };
182
183 const handleBPSSettingsClick = (event: React.MouseEvent) => {
184 event.preventDefault();
185 event.stopPropagation();
186 setSliderIndex(bpsToSliderIndex(bps));
187 setBpsInput(bps.toString());
188 setShowBPSModal(true);
189 };
190
191 const handleBPSSave = () => {
192 if (identityKey) {
193 const newBps = parseInt(bpsInput, 10) || 0;
194 runAsyncAdminAction(() => onBPSChange?.(identityKey, newBps));
195 }
196 setShowBPSModal(false);
197 };
198
199 const formatSliderLabel = (value: number): string => {
200 if (value === 0) return "Unlimited";
201 if (value >= 1000000) return `${value / 1000000} MB/s`;
202 if (value >= 1000) return `${value / 1000} KB/s`;
203 return `${value} B/s`;
204 };
205
206 const formatStepLabel = (value: number): string => {
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();
211 };
212
213 const formatBPS = (value: number): string => {
214 if (value === 0) return "Unlimited";
215 if (value >= 1_000_000_000)
216 return `${(value / 1_000_000_000).toFixed(1)} GB/s`;
217 if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} MB/s`;
218 if (value >= 1_000) return `${(value / 1_000).toFixed(1)} KB/s`;
219 return `${value} B/s`;
220 };
221
222 const formattedDuration = useMemo(() => {
223 if (!firstSeen) return "";
224 const start = new Date(firstSeen).getTime();
225 const now = Date.now();
226 const diff = Math.max(0, now - start);
227
228 const seconds = Math.floor(diff / 1000);
229 const minutes = Math.floor(seconds / 60);
230 const hours = Math.floor(minutes / 60);
231 const days = Math.floor(hours / 24);
232
233 if (days > 0) return `${days}d ${hours % 24}h`;
234 if (hours > 0) return `${hours}h ${minutes % 60}m`;
235 if (minutes > 0) return `${minutes}m`;
236 return `${seconds}s`;
237 }, [firstSeen]);
238
239 const cardBody = (
240 <article
241 data-hero-key={`server-bg-${serverId}`}
242 className={clsx(
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 >
247 <div
248 className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
249 style={{
250 backgroundImage: effectiveThumbnail
251 ? `url(${effectiveThumbnail})`
252 : "linear-gradient(135deg, var(--card) 0%, var(--background) 100%)",
253 }}
254 />
255 {effectiveThumbnail && (
256 <img
257 alt=""
258 aria-hidden="true"
259 className="hidden"
260 src={effectiveThumbnail}
261 onError={() => setThumbnailFailed(true)}
262 />
263 )}
264
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">
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(
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 )}
307 aria-label={isSelected ? "Deselect" : "Select"}
308 >
309 <svg
310 xmlns="http://www.w3.org/2000/svg"
311 viewBox="0 0 24 24"
312 className="w-4.5 h-4.5"
313 fill="none"
314 stroke="currentColor"
315 strokeWidth="3"
316 strokeLinecap="round"
317 strokeLinejoin="round"
318 >
319 {isSelected && <polyline points="20 6 9 17 4 12" />}
320 </svg>
321 </button>
322 ) : (
323 <button
324 onClick={handleFavoriteClick}
325 className={clsx(
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"
330 )}
331 aria-label={
332 isFavorite ? "Remove from favorites" : "Add to favorites"
333 }
334 >
335 <svg
336 xmlns="http://www.w3.org/2000/svg"
337 viewBox="0 0 24 24"
338 className="w-4.5 h-4.5"
339 fill={isFavorite ? "currentColor" : "none"}
340 stroke="currentColor"
341 strokeWidth="2"
342 strokeLinecap="round"
343 strokeLinejoin="round"
344 >
345 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
346 </svg>
347 </button>
348 )}
349 </div>
350
351 <div className="flex flex-col gap-3">
352 <div className="flex items-end justify-between gap-3">
353 <div className="flex flex-col gap-1.5 flex-1 min-w-0">
354 <h3 className="font-display text-xl font-bold leading-tight text-white truncate">
355 {name}
356 </h3>
357
358 {description && (
359 <p className="text-xs text-white/70 line-clamp-1 font-medium">
360 {description}
361 </p>
362 )}
363
364 {tags && tags.length > 0 && (
365 <div className="mt-1 w-full overflow-x-auto overflow-y-hidden overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
366 <div className="flex min-w-max gap-1.5">
367 {tags.map((tag, index) => (
368 <span
369 key={index}
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>
374 ))}
375 </div>
376 </div>
377 )}
378
379 {owner && (
380 <span className="text-[10px] font-medium text-white/50">
381 by {owner}
382 </span>
383 )}
384 </div>
385
386 {!showAdminControls && effectiveThumbnail && (
387 <div className="shrink-0">
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"
392 src={effectiveThumbnail}
393 onError={() => setThumbnailFailed(true)}
394 />
395 </div>
396 </div>
397 )}
398 </div>
399
400 {showAdminControls && identityKey && (
401 <div className="flex flex-col gap-2 w-full mt-2">
402 {onBPSChange && (
403 <div className="flex items-center justify-between w-full">
404 <span className="text-xs text-white/60">
405 BPS: <span className="font-medium text-white">{formatBPS(bps)}</span>
406 </span>
407 <button
408 onClick={handleBPSSettingsClick}
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>
413 </div>
414 )}
415
416 {isApproved && ip && (
417 <div className="text-[10px] text-white/50">
418 IP: <span className="font-mono">{displayIP || ip}</span>
419 {isIPBanned && (
420 <span className="ml-2 text-red-400">(Banned)</span>
421 )}
422 </div>
423 )}
424
425 {!isApproved && !isDenied ? (
426 <div className="flex gap-2 w-full">
427 <button
428 onClick={handleApproveClick}
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}
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>
439 </div>
440 ) : (
441 <button
442 onClick={ip ? handleIPBanClick : handleBanClick}
443 className={clsx(
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"
448 )}
449 >
450 {ip
451 ? isIPBanned
452 ? "Unban IP"
453 : "Ban IP"
454 : isBanned
455 ? "Unban"
456 : "Ban"}
457 </button>
458 )}
459 </div>
460 )}
461 </div>
462 </div>
463 </article>
464 );
465
466 return (
467 <>
468 {showAdminControls ? (
469 <div className="relative">{cardBody}</div>
470 ) : (
471 <Link
472 to={navigationPath}
473 state={navigationState}
474 className="relative cursor-pointer block"
475 >
476 {cardBody}
477 </Link>
478 )}
479
480 <Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
481 <DialogContent className="max-w-sm rounded-lg">
482 <DialogHeader>
483 <DialogTitle>BPS Settings</DialogTitle>
484 <DialogDescription>
485 Set bytes-per-second limit (0 = unlimited)
486 </DialogDescription>
487 </DialogHeader>
488 <div className="text-center text-xl font-bold text-primary">
489 {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
490 </div>
491 <input
492 type="range"
493 min="0"
494 max={bpsSteps.length - 1}
495 value={sliderIndex}
496 onChange={(event) => {
497 const idx = parseInt(event.target.value, 10);
498 handleSliderChange(idx);
499 }}
500 className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer"
501 />
502 <div className="flex justify-between text-xs text-text-muted">
503 {bpsSteps.map((step, idx) => (
504 <span
505 key={idx}
506 className={clsx(
507 "cursor-pointer hover:text-foreground transition-colors",
508 sliderIndex === idx && "text-primary font-medium"
509 )}
510 onClick={() => handleSliderChange(idx)}
511 >
512 {formatStepLabel(step)}
513 </span>
514 ))}
515 </div>
516 <div>
517 <label className="text-xs text-text-muted mb-1 block">
518 Custom value (B/s)
519 </label>
520 <input
521 type="number"
522 value={bpsInput}
523 onChange={(event) => {
524 setBpsInput(event.target.value);
525 syncSliderFromInput(parseInt(event.target.value, 10) || 0);
526 }}
527 className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
528 placeholder="Enter BPS limit"
529 min="0"
530 />
531 </div>
532 <DialogFooter className="gap-2 sm:gap-0">
533 <Button
534 className="cursor-pointer"
535 variant="secondary"
536 onClick={() => setShowBPSModal(false)}
537 >
538 Cancel
539 </Button>
540 <Button className="cursor-pointer" onClick={handleBPSSave}>
541 Save
542 </Button>
543 </DialogFooter>
544 </DialogContent>
545 </Dialog>
546 </>
547 );
548 }