Files
eranos/src/components/CampaignModerationMenu.tsx
T
mkfain 09bd4096e2 Make Featured a moderation axis and add an All Campaigns page
Featured was a hardcoded array of naddrs in src/lib/featuredCampaigns.ts.
Promote it to a third moderation axis (`featured` / `unfeatured`)
alongside `approved` and `hidden`, managed by Team Soapbox pack members
through the same kebab menu on each campaign card.

The Featured row on the homepage now:
- Reads from `moderation.featuredCoords`, ordered newest-featured-label first.
- Caps at 4 campaigns.
- Adapts its grid to 1/2/3/4 desktop columns based on count (mobile stays
  one column), collapses when nothing is featured, and surfaces the hero
  `variant="featured"` card only when exactly one campaign is featured.
- Hide still wins: a featured-then-hidden campaign disappears from the row.

Rename the homepage's second section from "All campaigns" to "Community
Campaigns", which more accurately reflects that it's the approved-not-
hidden set with featured campaigns deduplicated out.

Add a new /campaigns/all page that lists every campaign found on relays
(approved, pending, and unmoderated alike), with a "Show hidden" toggle
that adds hidden campaigns back in. The Discover page's "All campaigns"
link now points here instead of /, since the homepage is no longer a
truly-all view post-moderation. Also surface a small "Browse all
campaigns" link beneath the Community Campaigns grid so the new page is
discoverable from home.

Update NIP.md to document the third axis and the home-page surfacing
rules (Featured row, Community Campaigns grid, Discover shelf).

Delete src/lib/featuredCampaigns.ts entirely — there's no longer a build-
time list to maintain.
2026-05-19 18:04:28 -05:00

145 lines
5.2 KiB
TypeScript

import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
interface CampaignModerationMenuProps {
/** The campaign's `30223:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible label for the campaign (for toast feedback). */
campaignTitle: string;
/** Whether the campaign is currently approved. */
isApproved: boolean;
/** Whether the campaign is currently hidden. */
isHidden: boolean;
/** Whether the campaign is currently featured. */
isFeatured: boolean;
className?: string;
}
/**
* Per-card kebab menu exposing the six moderation actions:
* Approve / Unapprove (axis = approval)
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CampaignCard` `<Link>`, so the trigger swallows
* its own click + the dropdown content stops propagation, otherwise every
* menu interaction would navigate to the campaign detail page.
*/
export function CampaignModerationMenu({
coord,
campaignTitle,
isApproved,
isHidden,
isFeatured,
className,
}: CampaignModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { moderate } = useCampaignModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: `${verbPast}`, description: campaignTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate campaign"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed from homepage')}>
<ShieldOff className="h-4 w-4 mr-2" />
Unapprove
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Approved
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', 'Approved for homepage')}>
<ShieldCheck className="h-4 w-4 mr-2" />
Approve
</DropdownMenuItem>
)}
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured on homepage')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}