feat: add Relays page and integrate relay status display (#72)

feat: add Relays link to AvatarDropdown menu
feat: create Badge component for status indicators
fix: update @radix-ui/react-slot version in package.json and package-lock.json

Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-04-18 20:55:00 +02:00
committed by GitHub
parent 495f7a7eeb
commit b9f1ba9568
6 changed files with 344 additions and 6 deletions

186
app/relays/page.tsx Normal file
View File

@@ -0,0 +1,186 @@
"use client";
import { useNostr } from "nostr-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CheckCircle2, XCircle, AlertCircle, SignalHigh, Clock } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
export default function RelaysPage() {
const { connectedRelays } = useNostr();
const [relayStatus, setRelayStatus] = useState<{ [url: string]: 'connected' | 'connecting' | 'disconnected' | 'error' }>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
document.title = `Relays | LUMINA`;
if (connectedRelays) {
const status: { [url: string]: 'connected' | 'connecting' | 'disconnected' | 'error' } = {};
// Get status of each relay
connectedRelays.forEach(relay => {
if (relay.status === 1) {
status[relay.url] = 'connected';
} else if (relay.status === 0) {
status[relay.url] = 'connecting';
} else {
status[relay.url] = 'disconnected';
}
});
setRelayStatus(status);
setLoading(false);
}
}, [connectedRelays]);
// Function to get the appropriate status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected':
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case 'connecting':
return <Clock className="h-5 w-5 text-amber-500 animate-pulse" />;
case 'disconnected':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
default:
return null;
}
};
// Function to get the appropriate status badge
const getStatusBadge = (status: string) => {
switch (status) {
case 'connected':
return <Badge className="bg-green-500">Connected</Badge>;
case 'connecting':
return <Badge className="bg-amber-500">Connecting</Badge>;
case 'disconnected':
return <Badge className="bg-red-500">Disconnected</Badge>;
case 'error':
return <Badge className="bg-red-500">Error</Badge>;
default:
return null;
}
};
return (
<div className="py-4 px-2 md:py-6 md:px-6">
<h2 className="text-2xl font-bold mb-4">Nostr Relays</h2>
<Tabs defaultValue="list">
<TabsList className="mb-4">
<TabsTrigger value="list">List View</TabsTrigger>
<TabsTrigger value="cards">Card View</TabsTrigger>
</TabsList>
<TabsContent value="list">
<Card>
<CardHeader>
<CardTitle>Connected Relays</CardTitle>
<CardDescription>Current active relay connections ({Object.keys(relayStatus).length})</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="">
{loading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : (
<div className="space-y-2">
{Object.entries(relayStatus).map(([url, status]) => (
<div
key={url}
className="flex items-center justify-between p-3 rounded-md border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center space-x-3">
{getStatusIcon(status)}
<div className="overflow-hidden">
<p className="font-medium truncate">{url}</p>
</div>
</div>
<div>
{getStatusBadge(status)}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter className="flex justify-between">
{/* <div className="text-sm text-muted-foreground">
Active subscriptions: {activeSubscriptions?.length || 0}
</div> */}
<Button variant="outline" onClick={() => window.location.reload()}>
Refresh
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="cards">
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(relayStatus).map(([url, status]) => (
<Card key={url} className={`
${status === 'connected' ? 'border-green-500/50' : ''}
${status === 'connecting' ? 'border-amber-500/50' : ''}
${status === 'disconnected' || status === 'error' ? 'border-red-500/50' : ''}
`}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
{getStatusIcon(status)}
<span className="truncate" title={url}>
{url.replace(/^wss:\/\//, '')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<SignalHigh className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{status === 'connected' ? 'Ready for events' : 'Not receiving events'}
</span>
</div>
{getStatusBadge(status)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
<div className="mt-6">
<Card>
<CardHeader>
<CardTitle>About Nostr Relays</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Relays are servers that receive, store, and forward Nostr events. They act as the infrastructure
that makes the decentralized social network possible. You can connect to multiple relays to
increase the reach and resilience of your posts and profile.
</p>
</CardContent>
</Card>
</div>
</div>
);
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -39,6 +39,11 @@ export function AvatarDropdown() {
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/relays">
Relays
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/profile/settings">
Settings

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

121
package-lock.json generated
View File

@@ -18,7 +18,7 @@
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
@@ -2698,6 +2698,25 @@
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.1",
"license": "MIT",
@@ -2765,6 +2784,25 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.0.1",
"license": "MIT",
@@ -2964,6 +3002,25 @@
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.1.4",
"license": "MIT",
@@ -3097,6 +3154,25 @@
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz",
@@ -3636,15 +3712,31 @@
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -3936,6 +4028,25 @@
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.1",
"license": "MIT",

View File

@@ -19,7 +19,7 @@
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",