From e82f0146d26d77965ecdd203f307ca5bd73fde38 Mon Sep 17 00:00:00 2001 From: mkfain Date: Mon, 1 Jun 2026 19:51:51 +0200 Subject: [PATCH] Allow reordering over-cap featured campaigns from All Campaigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/pages/CampaignsPage.tsx | 141 +++++++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 20 deletions(-) diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx index ba98b333..97ea6c6d 100644 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -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 ? ( ) : ( -
- {allCampaignsChronological.map((campaign) => ( - - ))} -
+ // 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. + +
+ {allCampaignsChronological.map((campaign) => ( + + ))} +
+
)} {/* 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; + onMoveUp: (coord: string) => Promise | void; + onMoveDown: (coord: string) => Promise | void; + children: ReactNode; +}) { + if (!enabled) return <>{children}; + return ( + + {children} + + ); +} + function EmptyState() { const { t } = useTranslation(); const { config } = useAppContext();