diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index cafcc406..9b854336 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -1,13 +1,18 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; -import { ChevronDown, ChevronUp, Globe2, HandHeart, PlusCircle, Users } from 'lucide-react'; +import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, Hourglass, PlusCircle, Users } from 'lucide-react'; import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Skeleton } from '@/components/ui/skeleton'; import { CommunityGrid } from '@/components/discovery/CommunityGrid'; import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard'; @@ -16,13 +21,17 @@ import { COOL_PALETTE } from '@/lib/hopePalette'; import { cn } from '@/lib/utils'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities'; import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations'; import { useGlobalActivity } from '@/hooks/useGlobalActivity'; import { useGlobalDonations } from '@/hooks/useGlobalDonations'; +import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; import { useToast } from '@/hooks/useToast'; import { useUserOrganizations } from '@/hooks/useUserOrganizations'; import { formatSatsShort } from '@/lib/formatCampaignAmount'; +import type { ParsedCommunity } from '@/lib/communityUtils'; // ─── Page ────────────────────────────────────────────────────────────────────── @@ -33,6 +42,13 @@ export function CommunitiesPage() { const navigate = useNavigate(); const { toast } = useToast(); + // Moderator gate. Reuses the campaign moderator pack (Team Soapbox) — + // see useOrganizationModeration for why the same pack governs both + // surfaces. The `isMod` boolean drives the visibility of the two + // collapsible review sections at the bottom of the page. + const { data: moderators } = useCampaignModerators(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + useLayoutOptions({ noMaxWidth: true, rightSidebar: null, @@ -75,11 +91,160 @@ export function CommunitiesPage() { /> + + {/* Moderator-only review sections. Mirrors the home page's + "Pending approval" and "Hidden" rails for campaigns — collapsed + by default when long, so moderators can scan the queue without + it dominating the page. */} + {isMod && } ); } +// ═══════════════════════════════════════════════════════════════════════════════ +// Moderator review sections +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Renders the "Pending review" and "Hidden" rails for moderators only. + * Pulls a recent slice of kind 34550 organizations via the existing + * discovery hook and folds against the moderation rollup: + * + * - **Pending review** — orgs with no approve/hide label yet (neither + * `approved` nor `hidden`). + * - **Hidden** — orgs whose latest hide-axis label is `hidden`. Featured + * orgs that get hidden show up here too; the moderation menu on the + * card lets a moderator unhide. + * + * Mirrors the campaign side's `ModeratorSection` pattern (collapsible, + * defaults to open when the list is short). + */ +function ModeratorReviewSections() { + // Wider pull than the public discovery shelf so reviewers see deeper + // history. Bumping the limit further would just add network cost — + // anything truly old can be reviewed by visiting it directly. + const { data: allOrgs, isLoading } = useDiscoverCommunities({ limit: 200 }); + const { data: moderation, isReady } = useOrganizationModeration(); + + const pendingOrgs = useMemo(() => { + if (!moderation || !allOrgs) return [] as ParsedCommunity[]; + return allOrgs.filter( + (org) => + !moderation.approvedCoords.has(org.aTag) && + !moderation.hiddenCoords.has(org.aTag), + ); + }, [moderation, allOrgs]); + + const hiddenOrgs = useMemo(() => { + if (!moderation || !allOrgs) return [] as ParsedCommunity[]; + return allOrgs.filter((org) => moderation.hiddenCoords.has(org.aTag)); + }, [moderation, allOrgs]); + + const sectionsLoading = isLoading || !isReady; + + return ( + <> + } + title="Pending review" + description="Organizations on the network that no Team Soapbox moderator has approved or hidden yet. Featuring or approving moves them out of this list." + count={pendingOrgs.length} + orgs={pendingOrgs} + isLoading={sectionsLoading} + emptyText="Nothing awaiting review." + /> + } + title="Hidden" + description="Organizations suppressed from public discovery. Use the kebab menu on a card to unhide." + count={hiddenOrgs.length} + orgs={hiddenOrgs} + isLoading={sectionsLoading} + emptyText="No organizations are currently hidden." + /> + + ); +} + +/** + * Collapsible moderator-only section listing organizations in a particular + * moderation state (pending / hidden). Defaults to expanded when the list + * is short (≤ 6 items), collapsed otherwise — same heuristic as the + * campaign version. + */ +function ModeratorOrgSection({ + icon, + title, + description, + count, + orgs, + isLoading, + emptyText, +}: { + icon: React.ReactNode; + title: string; + description: string; + count: number; + orgs: ParsedCommunity[]; + isLoading: boolean; + emptyText: string; +}) { + const [open, setOpen] = useState(count <= 6); + + return ( + +
+ + + + + {isLoading && orgs.length === 0 ? ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ) : orgs.length === 0 ? ( +
+ + + {emptyText} + + +
+ ) : ( + + {orgs.map((org) => ( + + ))} + + )} +
+
+
+ ); +} + // ═══════════════════════════════════════════════════════════════════════════════ // Hero // ═══════════════════════════════════════════════════════════════════════════════