09bd4096e2
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.
145 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|