Allow reordering over-cap featured campaigns from All Campaigns

The 6-card cap on the WLC Verified row meant moderators couldn't
reach featured campaigns at positions 7..N to reorder them — the
drag handles and kebab move rows only existed on cards in the
visible hero row.

Wrap the All Campaigns section in a ReorderProvider seeded with the
*full* featuredCoords list (not just what's visible) so every
WLC-chipped card in the chronological grid gets the same Move up /
Move down / Move to top rows in its kebab. The provider only mounts
for moderators; non-mods see no behavior change.

Non-featured cards aren't in the provider's byCoord lookup, so
their kebab simply doesn't show reorder rows — the moderation menu
already gates the section behind canMoveUp || canMoveDown.

No optimistic local reorder here: the chronological grid is sorted
by createdAt, not by featured rank, so a successful 'Move to top'
on a position-12 card lifts it into the Verified hero row above
(and out of the chronological feed, via the existing heroSet
dedupe) once the moderation pack invalidates and refetches.
Failures surface as a toast, matching ReorderableCampaignGrid.

A small ConditionalReorderProvider helper keeps the JSX clean and
spares non-mods the provider work.
This commit is contained in:
mkfain
2026-06-01 19:51:51 +02:00
parent 973defcd28
commit e82f0146d2
+121 -20
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
@@ -9,12 +10,14 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { ModeratorCollapsibleSection, ReorderableCampaignGrid } from '@/components/moderation';
import { ModeratorCollapsibleSection, ReorderableCampaignGrid, ReorderProvider } from '@/components/moderation';
import { useAuthor } from '@/hooks/useAuthor';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useReorderCampaign } from '@/hooks/useReorderCampaign';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { useAppContext } from '@/hooks/useAppContext';
import type { ParsedCampaign } from '@/lib/campaign';
@@ -80,6 +83,8 @@ export function CampaignsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const reorder = useReorderCampaign();
const { toast } = useToast();
const wlcAuthor = useAuthor(WLC_PUBKEY);
const wlcName = wlcAuthor.data?.metadata?.display_name
|| wlcAuthor.data?.metadata?.name
@@ -199,6 +204,51 @@ export function CampaignsPage() {
.sort((a, b) => a.createdAt - b.createdAt);
}, [recentCampaigns, moderation, orderedFeatured]);
// Reorder handlers for over-cap featured campaigns that surface in
// the chronological "All campaigns" section. They mirror the move
// operations on `ReorderableCampaignGrid`, but operate against the
// *full* featuredCoords list so a card at position 12 can move to
// position 11 (or to the top, promoting it into the visible cap).
// No optimistic local reorder here: the chronological grid is
// sorted by createdAt, not by featured rank, so a successful move
// changes which cards appear in the Verified row above — which
// re-renders automatically once the moderation pack invalidates
// and refetches. Failure surfaces as a toast, matching
// ReorderableCampaignGrid.
const onFeaturedMoveToTop = useCallback(
async (coord: string) => {
try {
await reorder.moveToTop(coord, featuredCoords);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({ title: t('moderation.menu.failedReorder'), description: msg, variant: 'destructive' });
}
},
[reorder, featuredCoords, toast, t],
);
const onFeaturedMoveUp = useCallback(
async (coord: string) => {
try {
await reorder.moveUp(coord, featuredCoords);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({ title: t('moderation.menu.failedReorder'), description: msg, variant: 'destructive' });
}
},
[reorder, featuredCoords, toast, t],
);
const onFeaturedMoveDown = useCallback(
async (coord: string) => {
try {
await reorder.moveDown(coord, featuredCoords);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
toast({ title: t('moderation.menu.failedReorder'), description: msg, variant: 'destructive' });
}
},
[reorder, featuredCoords, toast, t],
);
const featuredEmpty =
moderationReady && featuredCoords.length === 0 && !featuredLoading;
@@ -281,24 +331,40 @@ export function CampaignsPage() {
) : allCampaignsChronological.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{allCampaignsChronological.map((campaign) => (
<CampaignCard
key={campaign.aTag}
campaign={campaign}
verifiedBy={
featuredCoordSet.has(campaign.aTag)
? {
pubkey: WLC_PUBKEY,
npub: WLC_NPUB,
defaultName: 'World Liberty Congress',
shortLabel: 'WLC',
}
: undefined
}
/>
))}
</div>
// Moderators get reorder rows on every featured card's
// kebab here so over-cap featured campaigns (the ones
// not shown in the visible Verified row above) can still
// be moved up, down, or to the top. The provider's coord
// list is the FULL featuredCoords — positions 7..N are
// exactly what we need to act on. Non-featured cards
// aren't in byCoord, so their kebab simply doesn't show
// reorder rows. Non-mods skip the provider entirely.
<ConditionalReorderProvider
enabled={isMod}
coords={featuredCoords}
onMoveToTop={onFeaturedMoveToTop}
onMoveUp={onFeaturedMoveUp}
onMoveDown={onFeaturedMoveDown}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{allCampaignsChronological.map((campaign) => (
<CampaignCard
key={campaign.aTag}
campaign={campaign}
verifiedBy={
featuredCoordSet.has(campaign.aTag)
? {
pubkey: WLC_PUBKEY,
npub: WLC_NPUB,
defaultName: 'World Liberty Congress',
shortLabel: 'WLC',
}
: undefined
}
/>
))}
</div>
</ConditionalReorderProvider>
)}
{/* Gateway to the full, censorship-resistant /campaigns
@@ -566,6 +632,41 @@ function CampaignGridSkeleton() {
);
}
/**
* Mounts a `ReorderProvider` only when `enabled` is true. Lets the
* All Campaigns section conditionally expose featured-list reorder
* controls to moderators without paying for a provider on every
* visitor's render. When disabled, children render unchanged and no
* reorder context is published.
*/
function ConditionalReorderProvider({
enabled,
coords,
onMoveToTop,
onMoveUp,
onMoveDown,
children,
}: {
enabled: boolean;
coords: readonly string[];
onMoveToTop: (coord: string) => Promise<void> | void;
onMoveUp: (coord: string) => Promise<void> | void;
onMoveDown: (coord: string) => Promise<void> | void;
children: ReactNode;
}) {
if (!enabled) return <>{children}</>;
return (
<ReorderProvider
coords={coords}
onMoveToTop={onMoveToTop}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
>
{children}
</ReorderProvider>
);
}
function EmptyState() {
const { t } = useTranslation();
const { config } = useAppContext();