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
// ═══════════════════════════════════════════════════════════════════════════════