change for more relevant widgets
This commit is contained in:
@@ -303,7 +303,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
showFAB: activeTab === 'comments' || activeTab === 'fundraising',
|
||||
onFabClick: handleFabClick,
|
||||
fabIcon,
|
||||
rightSidebar: communityATag ? <CommunityRightSidebar scopedATag={communityATag} /> : undefined,
|
||||
rightSidebar: communityATag ? (
|
||||
<CommunityRightSidebar
|
||||
scopedATag={communityATag}
|
||||
community={community}
|
||||
memberCount={membership?.members.length}
|
||||
viewerRank={viewerMember?.rank}
|
||||
reportsCount={moderation.allReports.length}
|
||||
activeGoalsCount={activeGoals.length}
|
||||
/>
|
||||
) : undefined,
|
||||
});
|
||||
|
||||
const moderationCtx = useMemo(
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Compass, MessageCircle, CalendarClock } from 'lucide-react';
|
||||
import { MessageCircle, Shield, Target, Users } from 'lucide-react';
|
||||
import type { ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { SuggestedCommunitiesWidget } from '@/components/widgets/SuggestedCommunitiesWidget';
|
||||
import { ActiveConversationsWidget } from '@/components/widgets/ActiveConversationsWidget';
|
||||
import { UpcomingEventsWidget } from '@/components/widgets/UpcomingEventsWidget';
|
||||
import { MyCommunitiesWidget } from '@/components/widgets/MyCommunitiesWidget';
|
||||
import { CommunityFundraisingWidget } from '@/components/widgets/CommunityFundraisingWidget';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityRightSidebarProps {
|
||||
/**
|
||||
* When viewing a specific community, pass its a-tag (`34550:<pubkey>:<d>`).
|
||||
* - Suggested Communities will exclude it from the list.
|
||||
* - Active Conversations will scope to just that community.
|
||||
* - Upcoming Events will scope to events tagged to that community.
|
||||
*/
|
||||
scopedATag?: string;
|
||||
community?: ParsedCommunity | null;
|
||||
memberCount?: number;
|
||||
viewerRank?: number;
|
||||
reportsCount?: number;
|
||||
activeGoalsCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -35,12 +35,63 @@ function Section({ title, icon, children }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated right-hand sidebar for the Communities listing page and individual
|
||||
* community detail pages. Surfaces three widgets that complement community
|
||||
* browsing without overlapping the main feed.
|
||||
*/
|
||||
export function CommunityRightSidebar({ scopedATag, className }: CommunityRightSidebarProps) {
|
||||
function getRoleLabel(rank: number | undefined): string {
|
||||
if (rank === undefined) return 'Observer';
|
||||
if (rank === 0) return 'Leadership';
|
||||
return `Member (rank ${rank})`;
|
||||
}
|
||||
|
||||
function CommunitySnapshot({
|
||||
community,
|
||||
memberCount,
|
||||
viewerRank,
|
||||
reportsCount,
|
||||
activeGoalsCount,
|
||||
}: {
|
||||
community: ParsedCommunity;
|
||||
memberCount?: number;
|
||||
viewerRank?: number;
|
||||
reportsCount?: number;
|
||||
activeGoalsCount?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 px-1">
|
||||
<p className="text-xs font-semibold truncate">{community.name}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Members</p>
|
||||
<p className="font-semibold">{memberCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Moderators</p>
|
||||
<p className="font-semibold">{community.moderatorPubkeys.length}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Your role</p>
|
||||
<p className="font-semibold">{getRoleLabel(viewerRank)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-2 py-1.5">
|
||||
<p className="text-muted-foreground">Open reports</p>
|
||||
<p className="font-semibold">{reportsCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{activeGoalsCount ?? 0} active goal{activeGoalsCount === 1 ? '' : 's'} in fundraising.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Community-focused right sidebar for overview and detail pages. */
|
||||
export function CommunityRightSidebar({
|
||||
scopedATag,
|
||||
community,
|
||||
memberCount,
|
||||
viewerRank,
|
||||
reportsCount,
|
||||
activeGoalsCount,
|
||||
className,
|
||||
}: CommunityRightSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -48,18 +99,38 @@ export function CommunityRightSidebar({ scopedATag, className }: CommunityRightS
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Section title="Suggested communities" icon={<Compass className="size-3.5" />}>
|
||||
<SuggestedCommunitiesWidget excludeATag={scopedATag} />
|
||||
</Section>
|
||||
{scopedATag && community ? (
|
||||
<Section title="Community snapshot" icon={<Users className="size-3.5" />}>
|
||||
<CommunitySnapshot
|
||||
community={community}
|
||||
memberCount={memberCount}
|
||||
viewerRank={viewerRank}
|
||||
reportsCount={reportsCount}
|
||||
activeGoalsCount={activeGoalsCount}
|
||||
/>
|
||||
</Section>
|
||||
) : (
|
||||
<Section title="My communities" icon={<Users className="size-3.5" />}>
|
||||
<MyCommunitiesWidget />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Active conversations" icon={<MessageCircle className="size-3.5" />}>
|
||||
<ActiveConversationsWidget scopedATag={scopedATag} />
|
||||
</Section>
|
||||
|
||||
<Section title="Upcoming events" icon={<CalendarClock className="size-3.5" />}>
|
||||
<UpcomingEventsWidget scopedATag={scopedATag} />
|
||||
<Section title="Fundraising" icon={<Target className="size-3.5" />}>
|
||||
<CommunityFundraisingWidget scopedATag={scopedATag} />
|
||||
</Section>
|
||||
|
||||
{scopedATag && (
|
||||
<Section title="Moderation" icon={<Shield className="size-3.5" />}>
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Reports and member bans are scoped to this community and enforced in feed rendering.
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
<LinkFooter />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Target } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
import { parseGoalEvent, isGoalExpired, formatSats, ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
||||
|
||||
interface CommunityFundraisingWidgetProps {
|
||||
/** Number of active goals to render. */
|
||||
limit?: number;
|
||||
/** Optional single-community scope (`34550:<pubkey>:<d>`). */
|
||||
scopedATag?: string;
|
||||
}
|
||||
|
||||
interface GoalItem {
|
||||
event: NostrEvent;
|
||||
title: string;
|
||||
amountSats: number;
|
||||
communityATag?: string;
|
||||
closedAt?: number;
|
||||
}
|
||||
|
||||
/** Community fundraising widget powered by NIP-75 goals (kind 9041). */
|
||||
export function CommunityFundraisingWidget({ limit = 5, scopedATag }: CommunityFundraisingWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
|
||||
|
||||
const aTags = useMemo(() => {
|
||||
if (scopedATag) return [scopedATag];
|
||||
return (myCommunities ?? []).map((c) => c.community.aTag);
|
||||
}, [myCommunities, scopedATag]);
|
||||
|
||||
const communityNameByATag = useMemo(() => {
|
||||
const byATag = new Map<string, string>();
|
||||
for (const entry of myCommunities ?? []) {
|
||||
byATag.set(entry.community.aTag, entry.community.name);
|
||||
}
|
||||
return byATag;
|
||||
}, [myCommunities]);
|
||||
|
||||
const { data: events, isLoading: goalsLoading, isError } = useQuery({
|
||||
queryKey: ['widget-community-fundraising', aTags.join(',')],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (aTags.length === 0) return [];
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8_000)]);
|
||||
return nostr.query(
|
||||
[{ kinds: [ZAP_GOAL_KIND], '#a': aTags, limit: 100 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
},
|
||||
enabled: scopedATag ? true : !communitiesLoading && aTags.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const goals = useMemo<GoalItem[]>(() => {
|
||||
if (!events) return [];
|
||||
const parsedGoals: GoalItem[] = [];
|
||||
for (const event of events) {
|
||||
const parsed = parseGoalEvent(event);
|
||||
if (!parsed || isGoalExpired(parsed)) continue;
|
||||
parsedGoals.push({
|
||||
event,
|
||||
title: parsed.title,
|
||||
amountSats: parsed.amountSats,
|
||||
communityATag: parsed.communityATag,
|
||||
closedAt: parsed.closedAt,
|
||||
});
|
||||
}
|
||||
return parsedGoals
|
||||
.sort((a, b) => {
|
||||
const aDeadline = a.closedAt ?? Number.MAX_SAFE_INTEGER;
|
||||
const bDeadline = b.closedAt ?? Number.MAX_SAFE_INTEGER;
|
||||
return aDeadline - bDeadline;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}, [events, limit]);
|
||||
|
||||
const isLoading = scopedATag ? goalsLoading : communitiesLoading || goalsLoading;
|
||||
|
||||
if (!scopedATag && aTags.length === 0 && !isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Join communities to follow fundraising goals.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-2.5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-destructive p-1">Failed to load community goals.</p>;
|
||||
}
|
||||
|
||||
if (goals.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No active fundraising goals.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{goals.map((goal) => {
|
||||
const encoded = nip19.neventEncode({ id: goal.event.id, author: goal.event.pubkey });
|
||||
return (
|
||||
<Link
|
||||
key={goal.event.id}
|
||||
to={`/${encoded}`}
|
||||
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<p className="text-xs font-semibold truncate">{goal.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<Target className="size-3 shrink-0" />
|
||||
{formatSats(goal.amountSats)} sats
|
||||
</p>
|
||||
{!scopedATag && goal.communityATag && communityNameByATag.get(goal.communityATag) && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{communityNameByATag.get(goal.communityATag)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Crown } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useMyCommunities } from '@/hooks/useMyCommunities';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
|
||||
interface MyCommunitiesWidgetProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Sidebar widget listing communities the current user founded or joined. */
|
||||
export function MyCommunitiesWidget({ limit = 6 }: MyCommunitiesWidgetProps) {
|
||||
const { data: communities, isLoading } = useMyCommunities();
|
||||
|
||||
const items = useMemo(
|
||||
() => (communities ?? []).slice(0, limit),
|
||||
[communities, limit],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-2.5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!communities || communities.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground p-1">
|
||||
Join communities to build your Agora network.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{items.map((entry) => {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
pubkey: entry.event.pubkey,
|
||||
identifier: entry.community.dTag,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={entry.community.aTag}
|
||||
to={`/${naddr}`}
|
||||
className="block px-2 py-2 rounded-lg hover:bg-secondary/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="text-xs font-semibold truncate flex-1">{entry.community.name}</p>
|
||||
{entry.isFounded && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-amber-500 shrink-0">
|
||||
<Crown className="size-2.5" />
|
||||
Founder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.community.description && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">
|
||||
{entry.community.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/communities" className="text-xs text-primary hover:underline">
|
||||
View all communities
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user