change for more relevant widgets

This commit is contained in:
sam
2026-05-12 18:35:52 +07:00
parent 32bf4bdab4
commit 2e144832b0
4 changed files with 323 additions and 21 deletions
+10 -1
View File
@@ -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(
+91 -20
View File
@@ -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>
);
}