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:
+121
-20
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user