mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: tabbed CommunikeyViewer with Chat, content sections, and Info
Redesigned CommunikeyViewer to use tabs instead of a static preview: - Chat tab (default): Embeds ChatViewer for community chat - Content section tabs: Shows feed of events matching section kinds with h-tag - Info tab: Shows community configuration (relays, blossom, mints, ToS) Features: - Compact header with profile picture, name, and description - Content section feeds query events by kind + #h tag from community relays - Lazy loads ChatViewer to avoid circular dependencies
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, lazy, Suspense } from "react";
|
||||
import { useEventStore, use$ } from "applesauce-react/hooks";
|
||||
import { addressLoader } from "@/services/loaders";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { getDisplayName } from "@/lib/nostr-utils";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
getCommunikeyTos,
|
||||
} from "@/lib/communikeys-helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Users,
|
||||
Radio,
|
||||
@@ -29,12 +29,22 @@ import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
User as UserIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { getKindName, getKindIcon } from "@/constants/kinds";
|
||||
import { KindRenderer } from "./nostr/kinds";
|
||||
import { EventErrorBoundary } from "./EventErrorBoundary";
|
||||
import type { ContentSection } from "@/lib/communikeys-helpers";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
// Lazy load ChatViewer to avoid circular dependency
|
||||
const ChatViewer = lazy(() =>
|
||||
import("./ChatViewer").then((m) => ({ default: m.ChatViewer })),
|
||||
);
|
||||
|
||||
const COMMUNIKEY_KIND = 10222;
|
||||
|
||||
@@ -45,13 +55,14 @@ export interface CommunikeyViewerProps {
|
||||
|
||||
/**
|
||||
* CommunikeyViewer - View a Communikey community
|
||||
* Shows community profile, configuration, and content sections
|
||||
* Shows community profile with tabbed content sections and chat
|
||||
*/
|
||||
export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
|
||||
const { state, addWindow } = useGrimoire();
|
||||
const { state } = useGrimoire();
|
||||
const accountPubkey = state.activeAccount?.pubkey;
|
||||
const eventStore = useEventStore();
|
||||
const { copy, copied } = useCopy();
|
||||
const [activeTab, setActiveTab] = useState("chat");
|
||||
|
||||
// Resolve $me alias
|
||||
const resolvedPubkey = pubkey === "$me" ? accountPubkey : pubkey;
|
||||
@@ -112,27 +123,6 @@ export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
|
||||
// Generate npub for copying
|
||||
const npub = resolvedPubkey ? nip19.npubEncode(resolvedPubkey) : "";
|
||||
|
||||
// Open chat for this community
|
||||
const openChat = () => {
|
||||
if (resolvedPubkey) {
|
||||
addWindow("chat", {
|
||||
protocol: "communikeys",
|
||||
identifier: {
|
||||
type: "communikey",
|
||||
value: resolvedPubkey,
|
||||
relays: communityRelays,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// View community profile
|
||||
const viewProfile = () => {
|
||||
if (resolvedPubkey) {
|
||||
addWindow("profile", { pubkey: resolvedPubkey });
|
||||
}
|
||||
};
|
||||
|
||||
if (pubkey === "$me" && !accountPubkey) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
|
||||
@@ -157,225 +147,424 @@ export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Compact Header */}
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
|
||||
{/* Left: npub */}
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between gap-3">
|
||||
{/* Left: Profile info */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-8 rounded-full object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold truncate">{displayName}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{description?.slice(0, 60)}
|
||||
{description && description.length > 60 ? "..." : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: npub copy button */}
|
||||
<button
|
||||
onClick={() => copy(npub)}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 font-mono"
|
||||
title={npub}
|
||||
aria-label="Copy community ID"
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3 flex-shrink-0" />
|
||||
<CopyCheck className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3 flex-shrink-0" />
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
<code className="truncate">
|
||||
{npub.slice(0, 16)}...{npub.slice(-8)}
|
||||
</code>
|
||||
<code className="hidden sm:inline">{npub.slice(0, 12)}...</code>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: Community icon */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Users className="size-3" />
|
||||
<span>Communikey</span>
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto">
|
||||
{/* Chat tab - always first */}
|
||||
<TabsTrigger
|
||||
value="chat"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 gap-1.5"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Content section tabs */}
|
||||
{contentSections.map((section) => {
|
||||
const FirstKindIcon = section.kinds[0]
|
||||
? getKindIcon(section.kinds[0])
|
||||
: FileText;
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={section.name}
|
||||
value={`section-${section.name}`}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 gap-1.5"
|
||||
>
|
||||
<FirstKindIcon className="size-4" />
|
||||
{section.name}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Info tab - always last */}
|
||||
<TabsTrigger
|
||||
value="info"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 gap-1.5"
|
||||
>
|
||||
<Info className="size-4" />
|
||||
Info
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Chat content */}
|
||||
<TabsContent
|
||||
value="chat"
|
||||
className="flex-1 overflow-hidden mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatViewer
|
||||
protocol="communikeys"
|
||||
identifier={{
|
||||
type: "communikey",
|
||||
value: resolvedPubkey,
|
||||
relays: communityRelays,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
{/* Content section tabs */}
|
||||
{contentSections.map((section) => (
|
||||
<TabsContent
|
||||
key={section.name}
|
||||
value={`section-${section.name}`}
|
||||
className="flex-1 overflow-auto mt-0"
|
||||
>
|
||||
<ContentSectionFeed
|
||||
section={section}
|
||||
communityPubkey={resolvedPubkey}
|
||||
relays={communityRelays}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
{/* Info content */}
|
||||
<TabsContent value="info" className="flex-1 overflow-auto mt-0">
|
||||
<CommunityInfo
|
||||
profile={profile}
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
location={location}
|
||||
communityRelays={communityRelays}
|
||||
contentSections={contentSections}
|
||||
blossomServers={blossomServers}
|
||||
mints={mints}
|
||||
tos={tos}
|
||||
communityEvent={communityEvent}
|
||||
resolvedPubkey={resolvedPubkey}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentSectionFeed - Displays a feed of events for a content section
|
||||
*/
|
||||
function ContentSectionFeed({
|
||||
section,
|
||||
communityPubkey,
|
||||
relays,
|
||||
}: {
|
||||
section: ContentSection;
|
||||
communityPubkey: string;
|
||||
relays: string[];
|
||||
}) {
|
||||
const { events, loading } = useTimeline(
|
||||
`communikey-${communityPubkey}-${section.name}`,
|
||||
{
|
||||
kinds: section.kinds,
|
||||
"#h": [communityPubkey],
|
||||
},
|
||||
relays,
|
||||
{ limit: 50 },
|
||||
);
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
|
||||
<p className="text-sm">No content in this section yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Content types: {section.kinds.map((k) => getKindName(k)).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{events.map((event) => (
|
||||
<FeedEvent key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FeedEvent - Renders a single event with error boundary
|
||||
*/
|
||||
function FeedEvent({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<EventErrorBoundary event={event}>
|
||||
<KindRenderer event={event} />
|
||||
</EventErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommunityInfo - Shows detailed community information
|
||||
*/
|
||||
function CommunityInfo({
|
||||
profile,
|
||||
displayName,
|
||||
description,
|
||||
location,
|
||||
communityRelays,
|
||||
contentSections,
|
||||
blossomServers,
|
||||
mints,
|
||||
tos,
|
||||
communityEvent,
|
||||
resolvedPubkey,
|
||||
}: {
|
||||
profile: any;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
communityRelays: string[];
|
||||
contentSections: ContentSection[];
|
||||
blossomServers: string[];
|
||||
mints: { url: string; protocol?: string }[];
|
||||
tos?: { id: string; relay?: string };
|
||||
communityEvent?: NostrEvent;
|
||||
resolvedPubkey: string;
|
||||
}) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const viewProfile = () => {
|
||||
addWindow("profile", { pubkey: resolvedPubkey });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{displayName}</h1>
|
||||
<button
|
||||
onClick={viewProfile}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<UserName pubkey={resolvedPubkey} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{profile?.picture ? (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt={displayName}
|
||||
className="size-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{displayName}</h1>
|
||||
<button
|
||||
onClick={viewProfile}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
<UserName pubkey={resolvedPubkey} className="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={openChat} className="gap-2">
|
||||
<MessageCircle className="size-4" />
|
||||
Open Chat
|
||||
</Button>
|
||||
</div>
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MapPin className="size-4" />
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Radio className="size-3" />
|
||||
{communityRelays.length}{" "}
|
||||
{communityRelays.length === 1 ? "relay" : "relays"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileText className="size-3" />
|
||||
{contentSections.length}{" "}
|
||||
{contentSections.length === 1 ? "section" : "sections"}
|
||||
</Badge>
|
||||
{blossomServers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Server className="size-3" />
|
||||
{blossomServers.length} blossom{" "}
|
||||
{blossomServers.length === 1 ? "server" : "servers"}
|
||||
</Badge>
|
||||
)}
|
||||
{mints.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Coins className="size-3" />
|
||||
{mints.length} {mints.length === 1 ? "mint" : "mints"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No community config warning */}
|
||||
{!communityEvent && (
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm text-muted-foreground">
|
||||
<p>
|
||||
No community configuration found (kind 10222). This pubkey may
|
||||
not have set up a Communikey community yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
|
||||
<div className="grid gap-4">
|
||||
{contentSections.map((section) => (
|
||||
<ContentSectionCard key={section.name} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{communityRelays.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Radio className="size-5" />
|
||||
Relays
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{communityRelays.map((relay, index) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{relay}</code>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Main
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{/* Quick stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Radio className="size-3" />
|
||||
{communityRelays.length}{" "}
|
||||
{communityRelays.length === 1 ? "relay" : "relays"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<FileText className="size-3" />
|
||||
{contentSections.length}{" "}
|
||||
{contentSections.length === 1 ? "section" : "sections"}
|
||||
</Badge>
|
||||
{blossomServers.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
Blossom Servers
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{blossomServers.map((server) => (
|
||||
<div
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{server}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Server className="size-3" />
|
||||
{blossomServers.length} blossom{" "}
|
||||
{blossomServers.length === 1 ? "server" : "servers"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Ecash Mints */}
|
||||
{mints.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="size-5" />
|
||||
Ecash Mints
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{mints.map((mint) => (
|
||||
<div
|
||||
key={mint.url}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Coins className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{mint.url}</code>
|
||||
{mint.protocol && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mint.protocol}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="p-2 rounded-md bg-muted/50">
|
||||
<code className="text-sm break-all">{tos.id}</code>
|
||||
{tos.relay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Relay: {tos.relay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Coins className="size-3" />
|
||||
{mints.length} {mints.length === 1 ? "mint" : "mints"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No community config warning */}
|
||||
{!communityEvent && (
|
||||
<div className="p-3 rounded-md bg-muted/50 text-sm text-muted-foreground">
|
||||
<p>
|
||||
No community configuration found (kind 10222). This pubkey may not
|
||||
have set up a Communikey community yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content Sections */}
|
||||
{contentSections.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Content Sections</h2>
|
||||
<div className="grid gap-4">
|
||||
{contentSections.map((section) => (
|
||||
<ContentSectionCard key={section.name} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Relays */}
|
||||
{communityRelays.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Radio className="size-5" />
|
||||
Relays
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{communityRelays.map((relay, index) => (
|
||||
<div
|
||||
key={relay}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Radio className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{relay}</code>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Main
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Blossom Servers */}
|
||||
{blossomServers.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
Blossom Servers
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{blossomServers.map((server) => (
|
||||
<div
|
||||
key={server}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{server}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ecash Mints */}
|
||||
{mints.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="size-5" />
|
||||
Ecash Mints
|
||||
</h2>
|
||||
<div className="grid gap-2">
|
||||
{mints.map((mint) => (
|
||||
<div
|
||||
key={mint.url}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Coins className="size-4 text-muted-foreground" />
|
||||
<code className="text-sm flex-1 break-all">{mint.url}</code>
|
||||
{mint.protocol && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{mint.protocol}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terms of Service */}
|
||||
{tos && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="size-5" />
|
||||
Terms of Service
|
||||
</h2>
|
||||
<div className="p-2 rounded-md bg-muted/50">
|
||||
<code className="text-sm break-all">{tos.id}</code>
|
||||
{tos.relay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Relay: {tos.relay}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -385,51 +574,47 @@ export function CommunikeyViewer({ pubkey, relays }: CommunikeyViewerProps) {
|
||||
*/
|
||||
function ContentSectionCard({ section }: { section: ContentSection }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>{section.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{section.exclusive && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Lock className="size-3" />
|
||||
Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
{section.fee && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Coins className="size-3" />
|
||||
{section.fee.amount} {section.fee.unit}
|
||||
</Badge>
|
||||
)}
|
||||
{section.badgeRequirement && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Award className="size-3" />
|
||||
Badge Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.kinds.map((kind) => {
|
||||
const KindIcon = getKindIcon(kind);
|
||||
return (
|
||||
<Badge key={kind} variant="outline" className="gap-1">
|
||||
<KindIcon className="size-3" />
|
||||
{getKindName(kind)}
|
||||
<span className="text-muted-foreground">({kind})</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<div className="p-4 rounded-md border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium">{section.name}</span>
|
||||
<div className="flex gap-1">
|
||||
{section.exclusive && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Lock className="size-3" />
|
||||
Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
{section.fee && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Coins className="size-3" />
|
||||
{section.fee.amount} {section.fee.unit}
|
||||
</Badge>
|
||||
)}
|
||||
{section.badgeRequirement && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Award className="size-3" />
|
||||
Badge Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{section.badgeRequirement && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Requires badge: <code>{section.badgeRequirement}</code>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.kinds.map((kind) => {
|
||||
const KindIcon = getKindIcon(kind);
|
||||
return (
|
||||
<Badge key={kind} variant="outline" className="gap-1">
|
||||
<KindIcon className="size-3" />
|
||||
{getKindName(kind)}
|
||||
<span className="text-muted-foreground">({kind})</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{section.badgeRequirement && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Requires badge: <code>{section.badgeRequirement}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user