Let moderators reorder Featured and Community campaign lists
The Featured row already sorted by the moderator's `featured` label
`created_at`, but reordering required clicking Unfeature then Feature
again — clumsy, and the Community grid sorted only by campaign
`created_at` with no moderator input at all.
This commit promotes the existing axis-label `created_at` into a
first-class sort key on both lists and adds drag-and-drop + kebab-row
UI for moderators.
Protocol (no schema change):
- The Featured row sorts by the `featured` label's `created_at`,
newest first (existing behavior).
- The Community grid now sorts by the `approved` label's
`created_at`, newest first (mirroring the Featured row).
- Reordering = republishing the same axis label for the moved
campaign with a chosen `created_at`. Move-to-top stamps `now`;
move-up stamps `neighborAbove.t + 1`; move-down stamps
`neighborBelow.t - 1`. Drag-to-position picks a value between the
two new neighbors.
- No new tags, no new kinds, no new authority — readers that already
understand the moderation namespace pick up the order for free.
- Conflict model unchanged: newest label per (coord, axis) wins.
Implementation:
- `foldModerationLabels` now populates `approvedOrder` alongside
`featuredOrder`.
- `useCampaignModeration().moderate` accepts an optional explicit
`created_at` for the label event (omitted for normal
approve/hide/feature; passed by the reorder hook).
- New `useReorderCampaign` hook with `moveToTop`, `moveUp`,
`moveDown`, and a general `moveTo(toIndex)` used by drag-and-drop.
- New `ReorderableCampaignGrid` wraps a list of `CampaignCard`s:
- non-mods get a plain grid, zero overhead;
- mods on desktop get HTML5 drag-and-drop with a six-dot handle
on hover (the handle is the only `draggable` element so card
clicks still navigate the underlying `<Link>`);
- mods on mobile get Move up / Move down / Move to top rows
injected into the existing moderator kebab via a context
provider (`ReorderProvider` / `useReorderControlsFor`).
- An optimistic local order smooths the gap between publish and
refetch so the card snaps into the new position immediately; it
rolls back automatically on publish failure.
- Translations added in all 15 non-English locales.
- NIP.md documents the ordering convention in a new
"Moderator-driven Ordering" section under the campaign-moderation
surfacing rules.
This commit is contained in:
@@ -534,7 +534,7 @@ Surfacing rules (hide always wins):
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above). Ordered newest-`created_at`-of-`approved`-label first (same mechanic as the Featured row, with `approved` as the order axis instead of `featured`).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
@@ -554,6 +554,22 @@ Surfacing rules (hide always wins):
|
||||
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
|
||||
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
|
||||
|
||||
#### Moderator-driven Ordering
|
||||
|
||||
The Featured row and Community Campaigns grid are sorted by the `created_at` of the moderator's latest label on the relevant axis (`featured` for the Featured row, `approved` for the Community grid), newest first. This is intentional: it doubles as the protocol-level reordering mechanism, with no new tags or kinds required.
|
||||
|
||||
A moderator MAY reorder either list by republishing the same axis label for a campaign with a chosen `created_at`. Three operations cover the common cases:
|
||||
|
||||
- **Move to top** — publish with `created_at = max(now, currentTopLabel.created_at + 1)`. The `max` guard handles a (rare) clock-skewed existing label whose `created_at` is already at or beyond `now`.
|
||||
- **Move up by one** — publish with `created_at = neighborAbove.created_at + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
|
||||
- **Move down by one** — publish with `created_at = neighborBelow.created_at - 1`. Only the moved campaign's label is republished; the neighbor below is untouched, it simply ends up sorted above the moved campaign because its `created_at` is now larger.
|
||||
|
||||
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any `created_at` strictly between their timestamps. When the gap is too tight (`prev.created_at - next.created_at < 2`), clients SHOULD pick `next.created_at + 1` and accept that the rendered list may briefly be off by sub-second until the new label propagates — refetching the labels resolves the sort.
|
||||
|
||||
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
|
||||
|
||||
This scheme is unobservable to non-moderation clients. Anyone reading the labels — including non-Agora clients — sees only the axis state (approved / hidden / featured); the ordering is a property of how Agora's UI consumes the timestamps.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowUp, ArrowDown, ArrowUpToLine,
|
||||
Check, EyeOff, Eye, MoreHorizontal,
|
||||
ShieldCheck, ShieldOff, Sparkles, SparklesIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -47,6 +48,27 @@ interface ModerationItemsProps {
|
||||
surface: ModerationSurface;
|
||||
/** Which axes to render. */
|
||||
axes: readonly ModerationAxis[];
|
||||
/**
|
||||
* Optional reorder controls. Present when this entity sits inside a
|
||||
* moderator-curated ordered list (the Featured row and Community
|
||||
* Campaigns grid on the home page). When present, three rows render
|
||||
* between the standard axis controls and the trailing rule:
|
||||
*
|
||||
* - Move to top (skipped when already at index 0)
|
||||
* - Move up (skipped when already at index 0)
|
||||
* - Move down (skipped when already at the last index)
|
||||
*
|
||||
* Callers compute `canMoveUp` / `canMoveDown` themselves from the
|
||||
* displayed list so the dropdown stays purely presentational and
|
||||
* doesn't need to know about list state.
|
||||
*/
|
||||
reorder?: {
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMoveToTop: () => Promise<void> | void;
|
||||
onMoveUp: () => Promise<void> | void;
|
||||
onMoveDown: () => Promise<void> | void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ModerationMenuProps extends ModerationItemsProps {
|
||||
@@ -89,18 +111,24 @@ function ModerationItemsShell({
|
||||
coord,
|
||||
entityTitle,
|
||||
axes,
|
||||
reorder,
|
||||
moderation,
|
||||
moderate,
|
||||
}: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
axes: readonly ModerationAxis[];
|
||||
reorder?: ModerationItemsProps['reorder'];
|
||||
moderation: ReturnType<typeof useCampaignModeration>['data'];
|
||||
moderate: ReturnType<typeof useCampaignModeration>['moderate'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(null);
|
||||
// Separate busy flag for reorder operations — they share the same
|
||||
// mutate hook but we want the menu to remain interactive for axis
|
||||
// actions while a reorder is in flight (and vice-versa).
|
||||
const [reordering, setReordering] = useState(false);
|
||||
|
||||
const isApproved = moderation.approvedCoords.has(coord);
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
@@ -128,6 +156,33 @@ function ModerationItemsShell({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a reorder callback with the shared toast + busy-flag UX. The
|
||||
* caller passes the actual move (a thunk returned from
|
||||
* `useReorderCampaign`); we surface success / failure here so every
|
||||
* site that mounts the menu gets the same feedback for free.
|
||||
*/
|
||||
const runReorder = async (
|
||||
op: () => Promise<void> | void,
|
||||
toastKey: 'movedUp' | 'movedDown' | 'movedToTop',
|
||||
) => {
|
||||
if (reordering) return;
|
||||
setReordering(true);
|
||||
try {
|
||||
await op();
|
||||
toast({ title: t(`moderation.menu.toast.${toastKey}`), description: entityTitle });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
toast({
|
||||
title: t('moderation.menu.failedReorder'),
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
@@ -191,6 +246,43 @@ function ModerationItemsShell({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Reorder section — only rendered when the host passes the
|
||||
reorder prop (i.e. the entity lives in a moderator-curated
|
||||
ordered list). Individual rows are gated on canMoveUp /
|
||||
canMoveDown so the menu doesn't show dead-end actions. */}
|
||||
{reorder && (reorder.canMoveUp || reorder.canMoveDown) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{reorder.canMoveUp && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runReorder(reorder.onMoveToTop, 'movedToTop')}
|
||||
disabled={reordering}
|
||||
>
|
||||
<ArrowUpToLine className="h-4 w-4 mr-2" />
|
||||
{t('moderation.menu.moveToTop')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{reorder.canMoveUp && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runReorder(reorder.onMoveUp, 'movedUp')}
|
||||
disabled={reordering}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4 mr-2" />
|
||||
{t('moderation.menu.moveUp')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{reorder.canMoveDown && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runReorder(reorder.onMoveDown, 'movedDown')}
|
||||
disabled={reordering}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 mr-2" />
|
||||
{t('moderation.menu.moveDown')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -199,17 +291,32 @@ function ModerationItemsShell({
|
||||
// hook so a pledge card never subscribes to the campaign label query
|
||||
// (and vice versa).
|
||||
|
||||
function CampaignItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
|
||||
function CampaignItemsInner(props: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
axes: readonly ModerationAxis[];
|
||||
reorder?: ModerationItemsProps['reorder'];
|
||||
}) {
|
||||
const { data, moderate } = useCampaignModeration();
|
||||
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
|
||||
}
|
||||
|
||||
function PledgeItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
|
||||
function PledgeItemsInner(props: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
axes: readonly ModerationAxis[];
|
||||
reorder?: ModerationItemsProps['reorder'];
|
||||
}) {
|
||||
const { data, moderate } = usePledgeModeration({ coordinates: [props.coord] });
|
||||
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
|
||||
}
|
||||
|
||||
function GroupItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
|
||||
function GroupItemsInner(props: {
|
||||
coord: string;
|
||||
entityTitle: string;
|
||||
axes: readonly ModerationAxis[];
|
||||
reorder?: ModerationItemsProps['reorder'];
|
||||
}) {
|
||||
const { data, moderate } = useOrganizationModeration();
|
||||
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
|
||||
}
|
||||
@@ -246,7 +353,12 @@ export function ModerationMenuItems(props: ModerationItemsProps) {
|
||||
|
||||
if (!isMod) return null;
|
||||
|
||||
const inner = { coord: props.coord, entityTitle: props.entityTitle, axes: props.axes };
|
||||
const inner = {
|
||||
coord: props.coord,
|
||||
entityTitle: props.entityTitle,
|
||||
axes: props.axes,
|
||||
reorder: props.reorder,
|
||||
};
|
||||
switch (props.surface) {
|
||||
case 'campaign': return <CampaignItemsInner {...inner} />;
|
||||
case 'pledge': return <PledgeItemsInner {...inner} />;
|
||||
|
||||
@@ -6,6 +6,16 @@ import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
|
||||
import { HiddenBadge } from './HiddenBadge';
|
||||
import { ModerationMenu, type ModerationAxis, type ModerationSurface } from './ModerationMenu';
|
||||
import { useReorderControlsFor } from './reorderContext';
|
||||
|
||||
/** Reorder controls forwarded to the embedded `ModerationMenu`. */
|
||||
interface ReorderControls {
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMoveToTop: () => Promise<void> | void;
|
||||
onMoveUp: () => Promise<void> | void;
|
||||
onMoveDown: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ModerationOverlayProps {
|
||||
/** Addressable coordinate of the entity (`<kind>:<pubkey>:<d>`). */
|
||||
@@ -36,6 +46,13 @@ interface ModerationOverlayProps {
|
||||
* pledges/groups use `top-2 right-2`.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional reorder controls. Forwarded to the moderator kebab so the
|
||||
* Move up / Move down / Move to top rows show inside the existing
|
||||
* dropdown. Provided by a parent that knows the card's position in
|
||||
* its surrounding ordered list (e.g. `ReorderableCampaignGrid`).
|
||||
*/
|
||||
reorder?: ReorderControls;
|
||||
}
|
||||
|
||||
/** Shared overlay body once the hide state has been resolved. */
|
||||
@@ -48,9 +65,17 @@ function OverlayBody({
|
||||
badgeSize,
|
||||
showMenu = true,
|
||||
className,
|
||||
reorder,
|
||||
}: Omit<ModerationOverlayProps, never> & { isHidden: boolean }) {
|
||||
const wrapperClass = className ?? 'absolute top-2 right-2 z-10 flex items-center gap-1.5';
|
||||
|
||||
// If no explicit reorder prop, fall back to a ReorderProvider in
|
||||
// the parent tree (mounted by `ReorderableCampaignGrid`). This is
|
||||
// how home-page cards pick up move-up / move-down rows without the
|
||||
// card itself knowing anything about reorder concerns.
|
||||
const contextReorder = useReorderControlsFor(coord);
|
||||
const effectiveReorder = reorder ?? contextReorder;
|
||||
|
||||
// When the menu is suppressed AND nothing is hidden, the overlay
|
||||
// would render an empty positioned div. Skip render entirely so the
|
||||
// banner stays clean.
|
||||
@@ -65,6 +90,7 @@ function OverlayBody({
|
||||
entityTitle={entityTitle}
|
||||
surface={surface}
|
||||
axes={axes}
|
||||
reorder={effectiveReorder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { ReorderAxis } from '@/hooks/useReorderCampaign';
|
||||
import {
|
||||
ReorderContext,
|
||||
type ReorderContextValue,
|
||||
type ReorderEntry,
|
||||
} from './reorderContext';
|
||||
|
||||
/**
|
||||
* Provides reorder controls to every descendant `ModerationOverlay`.
|
||||
* `ReorderableCampaignGrid` mounts this with its computed lookup;
|
||||
* non-reorderable grids simply don't mount it and the moderator
|
||||
* kebab renders without the move rows.
|
||||
*
|
||||
* The provider builds a per-coord lookup once per render of the
|
||||
* list; consumers look up their own coord and feed the result into
|
||||
* the moderation menu's optional `reorder` prop. Using a context
|
||||
* (rather than props through `CampaignCard`) keeps the card itself
|
||||
* unaware of list positioning concerns — any future grid can
|
||||
* publish its own reorder context without touching the card.
|
||||
*/
|
||||
export function ReorderProvider({
|
||||
axis,
|
||||
coords,
|
||||
onMoveToTop,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
children,
|
||||
}: {
|
||||
axis: ReorderAxis;
|
||||
coords: readonly string[];
|
||||
onMoveToTop: (coord: string) => Promise<void> | void;
|
||||
onMoveUp: (coord: string) => Promise<void> | void;
|
||||
onMoveDown: (coord: string) => Promise<void> | void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const value = useMemo<ReorderContextValue>(() => {
|
||||
const byCoord = new Map<string, ReorderEntry>();
|
||||
coords.forEach((coord, idx) => {
|
||||
byCoord.set(coord, {
|
||||
canMoveUp: idx > 0,
|
||||
canMoveDown: idx < coords.length - 1,
|
||||
onMoveToTop: () => onMoveToTop(coord),
|
||||
onMoveUp: () => onMoveUp(coord),
|
||||
onMoveDown: () => onMoveDown(coord),
|
||||
});
|
||||
});
|
||||
return { axis, byCoord };
|
||||
}, [axis, coords, onMoveToTop, onMoveUp, onMoveDown]);
|
||||
|
||||
return <ReorderContext.Provider value={value}>{children}</ReorderContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useReorderCampaign, type ReorderAxis } from '@/hooks/useReorderCampaign';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ReorderProvider } from './ReorderProvider';
|
||||
|
||||
interface ReorderableCampaignGridProps {
|
||||
campaigns: ParsedCampaign[];
|
||||
/**
|
||||
* Which moderation axis carries the order. `featured` for the
|
||||
* pinned row, `approval` for the Community grid. Drives which label
|
||||
* the reorder hook republishes.
|
||||
*/
|
||||
axis: ReorderAxis;
|
||||
/** Grid class. Caller passes the exact `grid grid-cols-…` it needs. */
|
||||
gridClassName: string;
|
||||
/**
|
||||
* Custom card renderer. Defaults to the standard `CampaignCard`.
|
||||
* The Featured row uses a custom renderer for its single-/multi-
|
||||
* column variant logic.
|
||||
*/
|
||||
renderCard?: (campaign: ParsedCampaign) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for a plain grid of `CampaignCard`s that
|
||||
* lets moderators reorder the list.
|
||||
*
|
||||
* - **Non-moderators**: identical to a plain grid. No DnD listeners,
|
||||
* no context provider, no extra DOM. The component is cheap enough
|
||||
* to drop into every campaign grid.
|
||||
* - **Moderators on desktop**: each card is wrapped in a native
|
||||
* HTML5 `draggable` div. Dropping on another card publishes a new
|
||||
* label with a timestamp computed from the new neighbors (see
|
||||
* `useReorderCampaign.moveTo`). One label per drop — no batch
|
||||
* publish, no neighbor re-stamping.
|
||||
* - **Moderators on mobile**: drag is disabled (touch DnD without a
|
||||
* library is unreliable and we don't ship one), but the moderator
|
||||
* kebab gets Move up / Move down / Move to top rows via the
|
||||
* `ReorderProvider` context. Same publish path, different trigger.
|
||||
*
|
||||
* Optimistic local order:
|
||||
*
|
||||
* Republishing a label invalidates the moderation query and the
|
||||
* campaign list query, which means React refetches both before the
|
||||
* grid can re-sort. Until those resolve we'd render the campaign in
|
||||
* its OLD position, then jerk to the new one when the new label
|
||||
* arrives. To smooth that out we hold an `optimisticOrder` of coords
|
||||
* that takes precedence over the incoming campaign list until the
|
||||
* authoritative order matches.
|
||||
*/
|
||||
export function ReorderableCampaignGrid({
|
||||
campaigns,
|
||||
axis,
|
||||
gridClassName,
|
||||
renderCard,
|
||||
}: ReorderableCampaignGridProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const isMobile = useIsMobile();
|
||||
const reorder = useReorderCampaign();
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Optimistic order: coords in the desired display order, set by a
|
||||
// successful move and cleared once the authoritative `campaigns`
|
||||
// prop converges on the same sequence. The ref backs an effectful
|
||||
// comparison against incoming props.
|
||||
const [optimisticCoords, setOptimisticCoords] = useState<readonly string[] | null>(null);
|
||||
|
||||
// Compute the displayed campaign list. If we have an optimistic
|
||||
// order and every coord in it is still in `campaigns`, render in
|
||||
// that order — otherwise fall through to the authoritative list.
|
||||
const byCoord = useMemo(() => {
|
||||
const m = new Map<string, ParsedCampaign>();
|
||||
for (const c of campaigns) m.set(c.aTag, c);
|
||||
return m;
|
||||
}, [campaigns]);
|
||||
|
||||
const displayed = useMemo<ParsedCampaign[]>(() => {
|
||||
if (!optimisticCoords) return campaigns;
|
||||
const optimisticList = optimisticCoords
|
||||
.map((coord) => byCoord.get(coord))
|
||||
.filter((c): c is ParsedCampaign => !!c);
|
||||
// If the optimistic list lost a campaign (e.g. it was hidden in
|
||||
// the meantime), abandon optimism and fall back to authoritative.
|
||||
if (optimisticList.length !== optimisticCoords.length) return campaigns;
|
||||
return optimisticList;
|
||||
}, [optimisticCoords, byCoord, campaigns]);
|
||||
|
||||
// Compare the displayed coord sequence to the authoritative one;
|
||||
// when they match, drop the optimistic override so future
|
||||
// moderation updates (from any mod, not just us) are reflected
|
||||
// immediately.
|
||||
const displayedCoords = useMemo(() => displayed.map((c) => c.aTag), [displayed]);
|
||||
const authoritativeCoords = useMemo(() => campaigns.map((c) => c.aTag), [campaigns]);
|
||||
if (
|
||||
optimisticCoords &&
|
||||
authoritativeCoords.length === optimisticCoords.length &&
|
||||
authoritativeCoords.every((c, i) => c === optimisticCoords[i])
|
||||
) {
|
||||
// Authoritative caught up — clear the override. Calling setState
|
||||
// during render is fine here because it's idempotent and
|
||||
// bails out on the second pass.
|
||||
queueMicrotask(() => setOptimisticCoords(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a reorder mutation: optimistically reorders locally, then
|
||||
* publishes. Failure rolls back. The signature takes the
|
||||
* already-mutated coord list (computed in the caller) so the move
|
||||
* logic stays in one place per operation.
|
||||
*/
|
||||
const applyOptimisticThenPublish = useCallback(
|
||||
async (newCoords: string[], publish: () => Promise<void>) => {
|
||||
const prev = optimisticCoords;
|
||||
setOptimisticCoords(newCoords);
|
||||
try {
|
||||
await publish();
|
||||
} catch (err) {
|
||||
// Roll back to whatever order we had before (or to
|
||||
// authoritative, by clearing). Toast goes through the
|
||||
// moderation menu's runReorder for menu-driven moves;
|
||||
// drag-and-drop has its own toast below.
|
||||
setOptimisticCoords(prev);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[optimisticCoords],
|
||||
);
|
||||
|
||||
const onMoveToTop = useCallback(
|
||||
async (coord: string) => {
|
||||
const idx = displayedCoords.indexOf(coord);
|
||||
if (idx <= 0) return;
|
||||
const next = [coord, ...displayedCoords.filter((c) => c !== coord)];
|
||||
await applyOptimisticThenPublish(next, () => reorder.moveToTop(coord, axis, displayedCoords));
|
||||
},
|
||||
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
|
||||
);
|
||||
|
||||
const onMoveUp = useCallback(
|
||||
async (coord: string) => {
|
||||
const idx = displayedCoords.indexOf(coord);
|
||||
if (idx <= 0) return;
|
||||
const next = [...displayedCoords];
|
||||
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
||||
await applyOptimisticThenPublish(next, () => reorder.moveUp(coord, axis, displayedCoords));
|
||||
},
|
||||
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
|
||||
);
|
||||
|
||||
const onMoveDown = useCallback(
|
||||
async (coord: string) => {
|
||||
const idx = displayedCoords.indexOf(coord);
|
||||
if (idx < 0 || idx >= displayedCoords.length - 1) return;
|
||||
const next = [...displayedCoords];
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
await applyOptimisticThenPublish(next, () => reorder.moveDown(coord, axis, displayedCoords));
|
||||
},
|
||||
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
|
||||
);
|
||||
|
||||
// Generic "drop at index" used by drag-and-drop. Success is its
|
||||
// own visual feedback (the card snaps into place via the
|
||||
// optimistic order), so we only toast on failure.
|
||||
const onMoveTo = useCallback(
|
||||
async (coord: string, toIndex: number) => {
|
||||
const fromIndex = displayedCoords.indexOf(coord);
|
||||
if (fromIndex < 0 || fromIndex === toIndex) return;
|
||||
const next = [...displayedCoords];
|
||||
next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, coord);
|
||||
try {
|
||||
await applyOptimisticThenPublish(next, () =>
|
||||
reorder.moveTo(coord, axis, displayedCoords, toIndex),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
toast({
|
||||
title: t('moderation.menu.failedReorder'),
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayedCoords, applyOptimisticThenPublish, reorder, axis, toast, t],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(campaign: ParsedCampaign) => renderCard?.(campaign) ?? <CampaignCard campaign={campaign} />,
|
||||
[renderCard],
|
||||
);
|
||||
|
||||
// Non-mods: plain grid, no DnD, no context. Branch out early so we
|
||||
// don't pay any of the moderator-only cost (the moderation cache
|
||||
// is already gated separately in `ModerationOverlay`).
|
||||
if (!isMod) {
|
||||
return (
|
||||
<div className={gridClassName}>
|
||||
{displayed.map((campaign) => (
|
||||
<div key={campaign.aTag}>{renderItem(campaign)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Moderators: provide reorder context (for the kebab menu rows)
|
||||
// and, on desktop, wrap each card in a `DraggableCard`. The
|
||||
// wrapper handles its own dragover/drop styling.
|
||||
return (
|
||||
<ReorderProvider
|
||||
axis={axis}
|
||||
coords={displayedCoords}
|
||||
onMoveToTop={onMoveToTop}
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
>
|
||||
<div className={gridClassName}>
|
||||
{displayed.map((campaign, idx) =>
|
||||
isMobile ? (
|
||||
<div key={campaign.aTag}>{renderItem(campaign)}</div>
|
||||
) : (
|
||||
<DraggableCard
|
||||
key={campaign.aTag}
|
||||
index={idx}
|
||||
coord={campaign.aTag}
|
||||
onDropAt={(droppedCoord) => onMoveTo(droppedCoord, idx)}
|
||||
>
|
||||
{renderItem(campaign)}
|
||||
</DraggableCard>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ReorderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML5-DnD wrapper for a single grid item. No external library — a
|
||||
* grid of 12-50 cards is well within native DnD's comfortable range.
|
||||
*
|
||||
* Drag semantics:
|
||||
*
|
||||
* - Only the dedicated **drag handle** (six-dot button, top-left of
|
||||
* the card on hover) is `draggable`. The card itself stays a
|
||||
* plain `<Link>` so a click anywhere else still navigates to the
|
||||
* campaign — making the whole card draggable would swallow that.
|
||||
* - `dragstart` on the handle stashes the source coord on
|
||||
* `dataTransfer`.
|
||||
* - The wrapper handles `dragover` + `drop` so the user can target
|
||||
* any part of a destination card, not just the destination
|
||||
* handle.
|
||||
* - `dragover` calls `preventDefault` (otherwise drop never fires)
|
||||
* and adds a brief ring outline.
|
||||
* - `drop` reads the source coord and calls `onDropAt`.
|
||||
*
|
||||
* The wrapper does NOT try to visually relocate the card during
|
||||
* dragover — the parent's optimistic reorder snaps the grid into
|
||||
* the new order on drop.
|
||||
*/
|
||||
function DraggableCard({
|
||||
index,
|
||||
coord,
|
||||
onDropAt,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
coord: string;
|
||||
onDropAt: (sourceCoord: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative group/drag motion-safe:transition-shadow',
|
||||
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background rounded-xl shadow-lg',
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer.types.includes('text/x-agora-campaign-coord')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (!isOver) setIsOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsOver(false)}
|
||||
onDrop={(e) => {
|
||||
const sourceCoord = e.dataTransfer.getData('text/x-agora-campaign-coord');
|
||||
setIsOver(false);
|
||||
if (!sourceCoord || sourceCoord === coord) return;
|
||||
e.preventDefault();
|
||||
onDropAt(sourceCoord);
|
||||
}}
|
||||
>
|
||||
{/* Drag handle — top-left so it never collides with the
|
||||
existing moderator kebab in the top-right. The button
|
||||
itself carries `draggable`, not the wrapper, so clicking
|
||||
anywhere else on the card still navigates the `<Link>`.
|
||||
Visible on hover and on keyboard focus. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
draggable
|
||||
aria-label={t('moderation.menu.dragHandle', { index: index + 1 })}
|
||||
title={t('moderation.menu.dragHandle', { index: index + 1 })}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/x-agora-campaign-coord', coord);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Don't let the click bubble up to the underlying `<Link>`.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Same as click: a handle press shouldn't fire the link.
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="absolute top-3 left-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-md bg-background/80 backdrop-blur text-muted-foreground opacity-0 group-hover/drag:opacity-100 focus-visible:opacity-100 hover:text-foreground cursor-grab active:cursor-grabbing motion-safe:transition-opacity"
|
||||
>
|
||||
<DragHandleIcon />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Six-dot grip icon. Pure SVG to avoid pulling another lucide import. */
|
||||
function DragHandleIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="5" cy="3" r="1.4" />
|
||||
<circle cx="11" cy="3" r="1.4" />
|
||||
<circle cx="5" cy="8" r="1.4" />
|
||||
<circle cx="11" cy="8" r="1.4" />
|
||||
<circle cx="5" cy="13" r="1.4" />
|
||||
<circle cx="11" cy="13" r="1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,3 +7,6 @@ export {
|
||||
} from './ModerationMenu';
|
||||
export { ModerationOverlay } from './ModerationOverlay';
|
||||
export { ModeratorCollapsibleSection } from './ModeratorCollapsibleSection';
|
||||
export { ReorderableCampaignGrid } from './ReorderableCampaignGrid';
|
||||
export { ReorderProvider } from './ReorderProvider';
|
||||
export { useReorderControlsFor } from './reorderContext';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { ReorderAxis } from '@/hooks/useReorderCampaign';
|
||||
|
||||
/**
|
||||
* Reorder controls forwarded from a parent grid down to whatever
|
||||
* `ModerationOverlay` happens to render the card's moderator kebab.
|
||||
*
|
||||
* The context lives in its own non-component module so the React
|
||||
* Fast Refresh ESLint rule is satisfied — JSX components and
|
||||
* `createContext` / hooks that aren't components themselves are
|
||||
* intentionally separated.
|
||||
*/
|
||||
export interface ReorderEntry {
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMoveToTop: () => Promise<void> | void;
|
||||
onMoveUp: () => Promise<void> | void;
|
||||
onMoveDown: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ReorderContextValue {
|
||||
axis: ReorderAxis;
|
||||
byCoord: Map<string, ReorderEntry>;
|
||||
}
|
||||
|
||||
/** Internal — exported only for the matching `ReorderProvider` component. */
|
||||
export const ReorderContext = createContext<ReorderContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Read the reorder controls for a single coord, if a provider is
|
||||
* mounted. Returns `undefined` outside a provider, which the
|
||||
* moderator kebab interprets as "no reorder UI to show".
|
||||
*/
|
||||
export function useReorderControlsFor(coord: string): ReorderEntry | undefined {
|
||||
const ctx = useContext(ReorderContext);
|
||||
return ctx?.byCoord.get(coord);
|
||||
}
|
||||
@@ -84,7 +84,26 @@ export function useCampaignModeration() {
|
||||
});
|
||||
|
||||
const moderate = useMutation({
|
||||
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
|
||||
mutationFn: async ({
|
||||
coord,
|
||||
action,
|
||||
createdAt,
|
||||
}: {
|
||||
coord: string;
|
||||
action: ModerationLabel;
|
||||
/**
|
||||
* Optional explicit `created_at` for the label event. Used by
|
||||
* `useReorderCampaign` to position a campaign within the
|
||||
* featured row or Community grid — both sort by the latest
|
||||
* label's `created_at` on the relevant axis, newest first, so
|
||||
* picking a timestamp between two neighbors lands the item
|
||||
* between them.
|
||||
*
|
||||
* Omit for normal approve / hide / feature actions and the
|
||||
* publisher will stamp `now` as usual.
|
||||
*/
|
||||
createdAt?: number;
|
||||
}) => {
|
||||
// Quick parse-check on the coord so we don't sign garbage.
|
||||
if (!coord.startsWith(`${CAMPAIGN_KIND}:`)) {
|
||||
throw new Error(`Coordinate must start with ${CAMPAIGN_KIND}:`);
|
||||
@@ -98,6 +117,7 @@ export function useCampaignModeration() {
|
||||
['a', coord],
|
||||
['alt', `Campaign moderation: ${action}`],
|
||||
],
|
||||
...(createdAt !== undefined ? { created_at: createdAt } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useCampaignModeration } from './useCampaignModeration';
|
||||
import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
|
||||
/**
|
||||
* Reordering axis. Featured uses the `featured` axis; the Community
|
||||
* Campaigns grid uses the `approval` axis. Both surfaces sort by the
|
||||
* `created_at` of the latest label on their axis, newest first.
|
||||
*/
|
||||
export type ReorderAxis = 'featured' | 'approval';
|
||||
|
||||
/** Maps a reorder axis to the `ModerationLabel` we publish to bump it. */
|
||||
function axisToLabel(axis: ReorderAxis): ModerationLabel {
|
||||
return axis === 'featured' ? 'featured' : 'approved';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordering implemented entirely on top of the existing kind-1985
|
||||
* moderation labels (no new tags, no new kind). The sort key is the
|
||||
* label's `created_at` newest-first; "moving" a campaign means
|
||||
* republishing its label with a chosen `created_at` that lands the
|
||||
* campaign at the desired position in the displayed list.
|
||||
*
|
||||
* All operations take the **currently displayed ordered list** as
|
||||
* input and reference it to compute the new `created_at`:
|
||||
*
|
||||
* - `moveToTop` — publish with `now`, or `max(orderTimestamps) + 1` if
|
||||
* any existing label is somehow already at `now` or beyond.
|
||||
* - `moveUp` — publish with `prevNeighbor.t + 1`. The "+1" is what
|
||||
* crosses the boundary; the previous neighbor's timestamp is the
|
||||
* smallest one strictly greater than ours, so beating it by one
|
||||
* second is sufficient.
|
||||
* - `moveDown` — publish with `nextNeighbor.t - 1`. Same logic
|
||||
* inverted; we want to fall just below the item directly beneath us.
|
||||
* - `moveTo(toIndex)` — generalizes the three: figures out the new
|
||||
* neighbors after the move, picks a timestamp between them, and
|
||||
* publishes once.
|
||||
*
|
||||
* **Conflict model** matches the rest of the axis: the newest label
|
||||
* per `(coord, axis)` wins regardless of moderator. If two mods
|
||||
* reorder concurrently, whoever's publish lands later "wins" — same
|
||||
* trust model the rest of the moderation system already uses.
|
||||
*
|
||||
* **Why timestamps and not a rank tag.** Encoding the order in the
|
||||
* label's `created_at` keeps the protocol surface unchanged: every
|
||||
* relay, every reader, every existing label cache already sorts
|
||||
* correctly without learning a new tag. The downside is that we burn
|
||||
* 1-second resolution per reorder operation; for a moderator-driven
|
||||
* UI with handful-of-items lists this is comfortably below the rate
|
||||
* at which reorders happen.
|
||||
*/
|
||||
export function useReorderCampaign() {
|
||||
const { moderate, data: moderation } = useCampaignModeration();
|
||||
|
||||
/**
|
||||
* Returns the `created_at` of the latest label on `axis` for `coord`,
|
||||
* or `undefined` if no such label exists yet (the campaign was never
|
||||
* approved / featured before).
|
||||
*/
|
||||
const orderTimestamp = useCallback(
|
||||
(coord: string, axis: ReorderAxis): number | undefined => {
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
return map.get(coord);
|
||||
},
|
||||
[moderation],
|
||||
);
|
||||
|
||||
/**
|
||||
* Publishes a label on `axis` for `coord` with an explicit
|
||||
* `created_at`. We piggyback on `useCampaignModeration().moderate`
|
||||
* which already handles the relay invalidation and the campaign
|
||||
* coord prefix check.
|
||||
*/
|
||||
const publishAt = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, createdAt: number) => {
|
||||
await moderate.mutateAsync({
|
||||
coord,
|
||||
action: axisToLabel(axis),
|
||||
createdAt,
|
||||
});
|
||||
},
|
||||
[moderate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` to the top of the displayed list.
|
||||
*
|
||||
* `displayedList` is the ordered coords currently rendered to the
|
||||
* user. We need it to defend against a clock-skewed label that's
|
||||
* somehow already in the future — we always end up strictly above
|
||||
* the current top.
|
||||
*/
|
||||
const moveToTop = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const topCoord = displayedList[0];
|
||||
const topTs = topCoord && topCoord !== coord ? map.get(topCoord) ?? 0 : 0;
|
||||
const newTs = Math.max(now, topTs + 1);
|
||||
await publishAt(coord, axis, newTs);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` up by one position in the displayed list. No-op when
|
||||
* the item is already at the top.
|
||||
*/
|
||||
const moveUp = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
const idx = displayedList.indexOf(coord);
|
||||
if (idx <= 0) return;
|
||||
if (idx === 1) {
|
||||
// The neighbor above is the current top; just go to top.
|
||||
await moveToTop(coord, axis, displayedList);
|
||||
return;
|
||||
}
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const aboveCoord = displayedList[idx - 1];
|
||||
const aboveTs = map.get(aboveCoord);
|
||||
if (aboveTs === undefined) {
|
||||
// Shouldn't happen for items currently in the displayed list,
|
||||
// but degrade to "move to top" rather than throw.
|
||||
await moveToTop(coord, axis, displayedList);
|
||||
return;
|
||||
}
|
||||
await publishAt(coord, axis, aboveTs + 1);
|
||||
},
|
||||
[moderation, publishAt, moveToTop],
|
||||
);
|
||||
|
||||
/**
|
||||
* Move `coord` down by one position. No-op when already at the
|
||||
* bottom.
|
||||
*/
|
||||
const moveDown = useCallback(
|
||||
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
|
||||
const idx = displayedList.indexOf(coord);
|
||||
if (idx < 0 || idx >= displayedList.length - 1) return;
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const belowCoord = displayedList[idx + 1];
|
||||
const belowTs = map.get(belowCoord);
|
||||
if (belowTs === undefined) return;
|
||||
// Subtract 1s to land strictly below the next item. The next
|
||||
// item's existing neighbor timestamp is some other value (or
|
||||
// nothing) — we don't need to touch it because we're only
|
||||
// crossing one boundary.
|
||||
await publishAt(coord, axis, belowTs - 1);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
);
|
||||
|
||||
/**
|
||||
* General-purpose move: relocate `coord` to `toIndex` in the
|
||||
* displayed list. Used by drag-and-drop. Computes a timestamp
|
||||
* between the new neighbors (if any) and publishes a single label.
|
||||
*
|
||||
* The chosen timestamp is `min(prev.t, now) - 1` when there's no
|
||||
* `next`, or `next.t + 1` when there's no `prev`, or
|
||||
* `Math.floor((prev.t + next.t) / 2)` when both exist and the gap
|
||||
* is at least 2 seconds. If the gap is too tight (< 2s) we fall
|
||||
* back to `prev.t + 1` and accept the off-by-one (only the moved
|
||||
* item ends up out of position by sub-second, which the next render
|
||||
* fixes when the new label arrives).
|
||||
*/
|
||||
const moveTo = useCallback(
|
||||
async (
|
||||
coord: string,
|
||||
axis: ReorderAxis,
|
||||
displayedList: readonly string[],
|
||||
toIndex: number,
|
||||
) => {
|
||||
const fromIndex = displayedList.indexOf(coord);
|
||||
if (fromIndex < 0) return;
|
||||
const clamped = Math.max(0, Math.min(toIndex, displayedList.length - 1));
|
||||
if (clamped === fromIndex) return;
|
||||
|
||||
// Build the list without `coord`, then identify the items that
|
||||
// will sit directly above and below `coord` after insertion at
|
||||
// `clamped`.
|
||||
const without = displayedList.filter((c) => c !== coord);
|
||||
const prevCoord = clamped > 0 ? without[clamped - 1] : undefined;
|
||||
const nextCoord = clamped < without.length ? without[clamped] : undefined;
|
||||
|
||||
const map = axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const prevTs = prevCoord ? map.get(prevCoord) : undefined;
|
||||
const nextTs = nextCoord ? map.get(nextCoord) : undefined;
|
||||
|
||||
let newTs: number;
|
||||
if (prevTs === undefined && nextTs === undefined) {
|
||||
newTs = now;
|
||||
} else if (prevTs === undefined) {
|
||||
// Moving to position 0: stay above current top.
|
||||
newTs = Math.max(now, (nextTs ?? 0) + 1);
|
||||
} else if (nextTs === undefined) {
|
||||
// Moving to bottom: stay below current bottom but ≥ 1.
|
||||
newTs = Math.max(1, prevTs - 1);
|
||||
} else if (prevTs - nextTs >= 2) {
|
||||
newTs = Math.floor((prevTs + nextTs) / 2);
|
||||
} else {
|
||||
// Tight gap: nudge above next. The displayed list refreshes
|
||||
// from the new label, so any sub-second mis-ordering self-
|
||||
// corrects on the next render.
|
||||
newTs = nextTs + 1;
|
||||
}
|
||||
|
||||
await publishAt(coord, axis, newTs);
|
||||
},
|
||||
[moderation, publishAt],
|
||||
);
|
||||
|
||||
return {
|
||||
moveToTop,
|
||||
moveUp,
|
||||
moveDown,
|
||||
moveTo,
|
||||
orderTimestamp,
|
||||
isPending: moderate.isPending,
|
||||
};
|
||||
}
|
||||
@@ -62,8 +62,20 @@ export interface ModerationData {
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
|
||||
* sort featured rows newest-first.
|
||||
*
|
||||
* Moderators reorder a featured campaign by republishing its `featured`
|
||||
* label with a chosen `created_at` (see `useReorderCampaign`) — the
|
||||
* sort key is the label's timestamp, not the campaign's own, so
|
||||
* re-featuring an old campaign bumps it to the top.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `approved` label. Used
|
||||
* to sort the Community Campaigns grid newest-approved first, so
|
||||
* moderators can promote a campaign within the grid by re-approving
|
||||
* it (same mechanic as `featuredOrder` on the featured axis).
|
||||
*/
|
||||
approvedOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
@@ -74,6 +86,7 @@ export const EMPTY_MODERATION_DATA: ModerationData = {
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
approvedOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
@@ -139,8 +152,12 @@ export function foldModerationLabels(
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
const approvedOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.approval?.label === 'approved') {
|
||||
approvedCoords.add(coord);
|
||||
approvedOrder.set(coord, state.approval.createdAt);
|
||||
}
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
@@ -148,5 +165,5 @@ export function foldModerationLabels(
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, approvedOrder, moderators };
|
||||
}
|
||||
|
||||
+11
-1
@@ -788,6 +788,7 @@
|
||||
"ariaPledge": "إدارة التعهد",
|
||||
"ariaGroup": "إدارة المجموعة",
|
||||
"failedAction": "فشل {{action}}",
|
||||
"failedReorder": "فشل إعادة الترتيب",
|
||||
"approve": "اعتماد",
|
||||
"unapprove": "إلغاء الاعتماد",
|
||||
"approvedState": "معتمدة",
|
||||
@@ -797,12 +798,21 @@
|
||||
"feature": "تمييز",
|
||||
"unfeature": "إلغاء التمييز",
|
||||
"featuredState": "مميّزة",
|
||||
"moveToTop": "نقل إلى الأعلى",
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل",
|
||||
"dragHandle": "اسحب لإعادة الترتيب (الموضع {{index}})",
|
||||
"toastApproved": "تم الاعتماد للصفحة الرئيسية",
|
||||
"toastUnapproved": "أُزيلت من الصفحة الرئيسية",
|
||||
"toastHidden": "تم الإخفاء",
|
||||
"toastUnhidden": "تم إلغاء الإخفاء",
|
||||
"toastFeatured": "تم التمييز",
|
||||
"toastUnfeatured": "أُزيلت من المميزة"
|
||||
"toastUnfeatured": "أُزيلت من المميزة",
|
||||
"toast": {
|
||||
"movedToTop": "تم النقل إلى الأعلى",
|
||||
"movedUp": "تم التحريك للأعلى",
|
||||
"movedDown": "تم التحريك للأسفل"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1235,6 +1235,7 @@
|
||||
"ariaPledge": "Moderate pledge",
|
||||
"ariaGroup": "Moderate group",
|
||||
"failedAction": "Failed to {{action}}",
|
||||
"failedReorder": "Failed to reorder",
|
||||
"approve": "Approve",
|
||||
"unapprove": "Unapprove",
|
||||
"approvedState": "Approved",
|
||||
@@ -1244,12 +1245,21 @@
|
||||
"feature": "Feature",
|
||||
"unfeature": "Unfeature",
|
||||
"featuredState": "Featured",
|
||||
"moveToTop": "Move to top",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"dragHandle": "Drag to reorder (position {{index}})",
|
||||
"toastApproved": "Approved for homepage",
|
||||
"toastUnapproved": "Removed from homepage",
|
||||
"toastHidden": "Hidden",
|
||||
"toastUnhidden": "Unhidden",
|
||||
"toastFeatured": "Featured",
|
||||
"toastUnfeatured": "Removed from featured"
|
||||
"toastUnfeatured": "Removed from featured",
|
||||
"toast": {
|
||||
"movedToTop": "Moved to top",
|
||||
"movedUp": "Moved up",
|
||||
"movedDown": "Moved down"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -804,6 +804,7 @@
|
||||
"ariaPledge": "Moderar promesa",
|
||||
"ariaGroup": "Moderar grupo",
|
||||
"failedAction": "No se pudo {{action}}",
|
||||
"failedReorder": "No se pudo reordenar",
|
||||
"approve": "Aprobar",
|
||||
"unapprove": "Desaprobar",
|
||||
"approvedState": "Aprobado",
|
||||
@@ -813,12 +814,21 @@
|
||||
"feature": "Destacar",
|
||||
"unfeature": "Quitar de destacados",
|
||||
"featuredState": "Destacado",
|
||||
"moveToTop": "Mover al principio",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"dragHandle": "Arrastra para reordenar (posición {{index}})",
|
||||
"toastApproved": "Aprobado para la página de inicio",
|
||||
"toastUnapproved": "Eliminado de la página de inicio",
|
||||
"toastHidden": "Ocultado",
|
||||
"toastUnhidden": "Restaurado",
|
||||
"toastFeatured": "Destacado",
|
||||
"toastUnfeatured": "Eliminado de destacados"
|
||||
"toastUnfeatured": "Eliminado de destacados",
|
||||
"toast": {
|
||||
"movedToTop": "Movido al principio",
|
||||
"movedUp": "Movido hacia arriba",
|
||||
"movedDown": "Movido hacia abajo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -813,12 +813,22 @@
|
||||
"feature": "ویژه کردن",
|
||||
"unfeature": "لغو ویژهسازی",
|
||||
"featuredState": "ویژه",
|
||||
"moveToTop": "انتقال به بالا",
|
||||
"moveUp": "جابجایی به بالا",
|
||||
"moveDown": "جابجایی به پایین",
|
||||
"dragHandle": "برای تغییر ترتیب بکشید (موقعیت {{index}})",
|
||||
"failedReorder": "تغییر ترتیب ناموفق بود",
|
||||
"toastApproved": "برای صفحه اصلی تأیید شد",
|
||||
"toastUnapproved": "از صفحه اصلی حذف شد",
|
||||
"toastHidden": "پنهان شد",
|
||||
"toastUnhidden": "آشکار شد",
|
||||
"toastFeatured": "ویژه شد",
|
||||
"toastUnfeatured": "از فهرست ویژهها حذف شد"
|
||||
"toastUnfeatured": "از فهرست ویژهها حذف شد",
|
||||
"toast": {
|
||||
"movedToTop": "به بالا منتقل شد",
|
||||
"movedUp": "به بالا جابجا شد",
|
||||
"movedDown": "به پایین جابجا شد"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1227,6 +1227,7 @@
|
||||
"ariaPledge": "Modérer la promesse",
|
||||
"ariaGroup": "Modérer le groupe",
|
||||
"failedAction": "Échec de l'action {{action}}",
|
||||
"failedReorder": "Échec de la réorganisation",
|
||||
"approve": "Approuver",
|
||||
"unapprove": "Désapprouver",
|
||||
"approvedState": "Approuvée",
|
||||
@@ -1236,12 +1237,21 @@
|
||||
"feature": "Mettre en avant",
|
||||
"unfeature": "Retirer de la sélection",
|
||||
"featuredState": "Mise en avant",
|
||||
"moveToTop": "Déplacer en haut",
|
||||
"moveUp": "Déplacer vers le haut",
|
||||
"moveDown": "Déplacer vers le bas",
|
||||
"dragHandle": "Glisser pour réorganiser (position {{index}})",
|
||||
"toastApproved": "Approuvée pour la page d'accueil",
|
||||
"toastUnapproved": "Retirée de la page d'accueil",
|
||||
"toastHidden": "Masquée",
|
||||
"toastUnhidden": "Démasquée",
|
||||
"toastFeatured": "Mise en avant",
|
||||
"toastUnfeatured": "Retirée de la sélection"
|
||||
"toastUnfeatured": "Retirée de la sélection",
|
||||
"toast": {
|
||||
"movedToTop": "Déplacée en haut",
|
||||
"movedUp": "Déplacée vers le haut",
|
||||
"movedDown": "Déplacée vers le bas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1251,7 +1251,17 @@
|
||||
"toastHidden": "छुपा दिया गया",
|
||||
"toastUnhidden": "अनहाइड कर दिया गया",
|
||||
"toastFeatured": "फ़ीचर कर दिया गया",
|
||||
"toastUnfeatured": "फ़ीचर से हटाया गया"
|
||||
"toastUnfeatured": "फ़ीचर से हटाया गया",
|
||||
"failedReorder": "क्रम बदलने में विफल",
|
||||
"moveToTop": "सबसे ऊपर ले जाएँ",
|
||||
"moveUp": "ऊपर ले जाएँ",
|
||||
"moveDown": "नीचे ले जाएँ",
|
||||
"dragHandle": "क्रम बदलने के लिए खींचें (स्थिति {{index}})",
|
||||
"toast": {
|
||||
"movedToTop": "सबसे ऊपर ले जाया गया",
|
||||
"movedUp": "ऊपर ले जाया गया",
|
||||
"movedDown": "नीचे ले जाया गया"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1251,7 +1251,17 @@
|
||||
"toastHidden": "Disembunyikan",
|
||||
"toastUnhidden": "Ditampilkan kembali",
|
||||
"toastFeatured": "Diunggulkan",
|
||||
"toastUnfeatured": "Dihapus dari unggulan"
|
||||
"toastUnfeatured": "Dihapus dari unggulan",
|
||||
"failedReorder": "Gagal mengurutkan ulang",
|
||||
"moveToTop": "Pindahkan ke atas",
|
||||
"moveUp": "Naikkan",
|
||||
"moveDown": "Turunkan",
|
||||
"dragHandle": "Seret untuk mengurutkan ulang (posisi {{index}})",
|
||||
"toast": {
|
||||
"movedToTop": "Dipindahkan ke atas",
|
||||
"movedUp": "Dinaikkan",
|
||||
"movedDown": "Diturunkan"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -804,6 +804,11 @@
|
||||
"ariaPledge": "សម្របសម្រួលការសន្យា",
|
||||
"ariaGroup": "សម្របសម្រួលក្រុម",
|
||||
"failedAction": "បរាជ័យក្នុងការ {{action}}",
|
||||
"failedReorder": "បរាជ័យក្នុងការរៀបចំឡើងវិញ",
|
||||
"moveToTop": "ផ្លាស់ទីទៅកំពូល",
|
||||
"moveUp": "ផ្លាស់ទីឡើងលើ",
|
||||
"moveDown": "ផ្លាស់ទីចុះក្រោម",
|
||||
"dragHandle": "អូសដើម្បីរៀបចំឡើងវិញ (ទីតាំង {{index}})",
|
||||
"approve": "អនុម័ត",
|
||||
"unapprove": "ដកការអនុម័ត",
|
||||
"approvedState": "បានអនុម័ត",
|
||||
@@ -818,7 +823,12 @@
|
||||
"toastHidden": "បានលាក់",
|
||||
"toastUnhidden": "បានឈប់លាក់",
|
||||
"toastFeatured": "បានលេចធ្លោ",
|
||||
"toastUnfeatured": "បានដកចេញពីការលេចធ្លោ"
|
||||
"toastUnfeatured": "បានដកចេញពីការលេចធ្លោ",
|
||||
"toast": {
|
||||
"movedToTop": "បានផ្លាស់ទីទៅកំពូល",
|
||||
"movedUp": "បានផ្លាស់ទីឡើងលើ",
|
||||
"movedDown": "បានផ្លាស់ទីចុះក្រោម"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -818,7 +818,17 @@
|
||||
"toastHidden": "پټ شوی",
|
||||
"toastUnhidden": "بېرته ښودل شوی",
|
||||
"toastFeatured": "ځانګړی شوی",
|
||||
"toastUnfeatured": "د ځانګړو څخه لرې شوی"
|
||||
"toastUnfeatured": "د ځانګړو څخه لرې شوی",
|
||||
"failedReorder": "د بیا ترتیب کولو په کې پاتې راغی",
|
||||
"moveToTop": "سر ته انتقال",
|
||||
"moveUp": "پورته انتقال",
|
||||
"moveDown": "ښکته انتقال",
|
||||
"dragHandle": "د بیا ترتیب لپاره کش کړئ (موقعیت {{index}})",
|
||||
"toast": {
|
||||
"movedToTop": "سر ته انتقال شو",
|
||||
"movedUp": "پورته انتقال شو",
|
||||
"movedDown": "ښکته انتقال شو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1251,7 +1251,17 @@
|
||||
"toastHidden": "Ocultado",
|
||||
"toastUnhidden": "Reexibido",
|
||||
"toastFeatured": "Destacado",
|
||||
"toastUnfeatured": "Removido dos destaques"
|
||||
"toastUnfeatured": "Removido dos destaques",
|
||||
"moveToTop": "Mover para o topo",
|
||||
"moveUp": "Mover para cima",
|
||||
"moveDown": "Mover para baixo",
|
||||
"dragHandle": "Arraste para reordenar (posição {{index}})",
|
||||
"failedReorder": "Falha ao reordenar",
|
||||
"toast": {
|
||||
"movedToTop": "Movido para o topo",
|
||||
"movedUp": "Movido para cima",
|
||||
"movedDown": "Movido para baixo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1237,6 +1237,7 @@
|
||||
"ariaPledge": "Модерировать обещание",
|
||||
"ariaGroup": "Модерировать группу",
|
||||
"failedAction": "Не удалось выполнить действие: {{action}}",
|
||||
"failedReorder": "Не удалось изменить порядок",
|
||||
"approve": "Одобрить",
|
||||
"unapprove": "Отозвать одобрение",
|
||||
"approvedState": "Одобрено",
|
||||
@@ -1246,12 +1247,21 @@
|
||||
"feature": "Выделить",
|
||||
"unfeature": "Убрать из избранного",
|
||||
"featuredState": "Избранное",
|
||||
"moveToTop": "Переместить наверх",
|
||||
"moveUp": "Переместить вверх",
|
||||
"moveDown": "Переместить вниз",
|
||||
"dragHandle": "Перетащите для изменения порядка (позиция {{index}})",
|
||||
"toastApproved": "Одобрено для главной страницы",
|
||||
"toastUnapproved": "Удалено с главной страницы",
|
||||
"toastHidden": "Скрыто",
|
||||
"toastUnhidden": "Показано",
|
||||
"toastFeatured": "Добавлено в избранное",
|
||||
"toastUnfeatured": "Удалено из избранного"
|
||||
"toastUnfeatured": "Удалено из избранного",
|
||||
"toast": {
|
||||
"movedToTop": "Перемещено наверх",
|
||||
"movedUp": "Перемещено вверх",
|
||||
"movedDown": "Перемещено вниз"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -818,7 +818,17 @@
|
||||
"toastHidden": "Zvavanzwa",
|
||||
"toastUnhidden": "Zvabviswa pakuvanzwa",
|
||||
"toastFeatured": "Zvasarudzwa",
|
||||
"toastUnfeatured": "Zvabviswa pakusarudzwa"
|
||||
"toastUnfeatured": "Zvabviswa pakusarudzwa",
|
||||
"moveToTop": "Endesa kumusoro",
|
||||
"moveUp": "Endesa kumusoro",
|
||||
"moveDown": "Endesa pasi",
|
||||
"dragHandle": "Kweva kuti uchinje chinzvimbo (chinzvimbo {{index}})",
|
||||
"failedReorder": "Hazvina kubudirira kurongedza patsva",
|
||||
"toast": {
|
||||
"movedToTop": "Zvaendeswa kumusoro",
|
||||
"movedUp": "Zvaendeswa kumusoro",
|
||||
"movedDown": "Zvaendeswa pasi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1250,7 +1250,17 @@
|
||||
"toastHidden": "Imefichwa",
|
||||
"toastUnhidden": "Imeonyeshwa",
|
||||
"toastFeatured": "Imeangaziwa",
|
||||
"toastUnfeatured": "Imeondolewa kwenye maarufu"
|
||||
"toastUnfeatured": "Imeondolewa kwenye maarufu",
|
||||
"failedReorder": "Imeshindikana kupanga upya",
|
||||
"moveToTop": "Hamisha juu kabisa",
|
||||
"moveUp": "Hamisha juu",
|
||||
"moveDown": "Hamisha chini",
|
||||
"dragHandle": "Buruta ili kupanga upya (nafasi {{index}})",
|
||||
"toast": {
|
||||
"movedToTop": "Imehamishwa juu kabisa",
|
||||
"movedUp": "Imehamishwa juu",
|
||||
"movedDown": "Imehamishwa chini"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -1236,6 +1236,11 @@
|
||||
"ariaPledge": "Taahhüdü modere et",
|
||||
"ariaGroup": "Grubu modere et",
|
||||
"failedAction": "{{action}} başarısız oldu",
|
||||
"failedReorder": "Yeniden sıralama başarısız oldu",
|
||||
"moveToTop": "En üste taşı",
|
||||
"moveUp": "Yukarı taşı",
|
||||
"moveDown": "Aşağı taşı",
|
||||
"dragHandle": "Yeniden sıralamak için sürükleyin (konum {{index}})",
|
||||
"approve": "Onayla",
|
||||
"unapprove": "Onayı kaldır",
|
||||
"approvedState": "Onaylandı",
|
||||
@@ -1250,7 +1255,12 @@
|
||||
"toastHidden": "Gizlendi",
|
||||
"toastUnhidden": "Gizleme kaldırıldı",
|
||||
"toastFeatured": "Öne çıkarıldı",
|
||||
"toastUnfeatured": "Öne çıkanlardan kaldırıldı"
|
||||
"toastUnfeatured": "Öne çıkanlardan kaldırıldı",
|
||||
"toast": {
|
||||
"movedToTop": "En üste taşındı",
|
||||
"movedUp": "Yukarı taşındı",
|
||||
"movedDown": "Aşağı taşındı"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -818,7 +818,17 @@
|
||||
"toastHidden": "已隱藏",
|
||||
"toastUnhidden": "已取消隱藏",
|
||||
"toastFeatured": "已加入精選",
|
||||
"toastUnfeatured": "已自精選移除"
|
||||
"toastUnfeatured": "已自精選移除",
|
||||
"failedReorder": "無法重新排序",
|
||||
"moveToTop": "移至頂端",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"dragHandle": "拖曳以重新排序(位置 {{index}})",
|
||||
"toast": {
|
||||
"movedToTop": "已移至頂端",
|
||||
"movedUp": "已上移",
|
||||
"movedDown": "已下移"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+11
-1
@@ -804,6 +804,11 @@
|
||||
"ariaPledge": "管理悬赏",
|
||||
"ariaGroup": "管理群组",
|
||||
"failedAction": "操作失败:{{action}}",
|
||||
"failedReorder": "重新排序失败",
|
||||
"moveToTop": "移到顶部",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"dragHandle": "拖动以重新排序(位置 {{index}})",
|
||||
"approve": "批准",
|
||||
"unapprove": "取消批准",
|
||||
"approvedState": "已批准",
|
||||
@@ -818,7 +823,12 @@
|
||||
"toastHidden": "已隐藏",
|
||||
"toastUnhidden": "已取消隐藏",
|
||||
"toastFeatured": "已精选",
|
||||
"toastUnfeatured": "已从精选移除"
|
||||
"toastUnfeatured": "已从精选移除",
|
||||
"toast": {
|
||||
"movedToTop": "已移到顶部",
|
||||
"movedUp": "已上移",
|
||||
"movedDown": "已下移"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+46
-19
@@ -8,7 +8,7 @@ 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 } from '@/components/moderation';
|
||||
import { ModeratorCollapsibleSection, ReorderableCampaignGrid } from '@/components/moderation';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
@@ -137,15 +137,29 @@ export function CampaignsPage() {
|
||||
return Array.from(byCoord.values()).sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [recentCampaigns, labeledCampaigns]);
|
||||
|
||||
// Community Campaigns: approved, not hidden, not featured.
|
||||
// Community Campaigns: approved, not hidden, not featured. Sorted
|
||||
// by the `created_at` of the latest `approved` label, newest first
|
||||
// — mirroring the featured row's `featuredOrder` sort. Moderators
|
||||
// can reorder the grid by re-approving (or dragging) a campaign;
|
||||
// see `useReorderCampaign`. Campaigns missing from `approvedOrder`
|
||||
// (which shouldn't happen — every coord in `approvedCoords` has an
|
||||
// entry) fall back to the campaign's own `createdAt` so the sort
|
||||
// is total.
|
||||
const communityCampaigns = useMemo<ParsedCampaign[]>(() => {
|
||||
if (!moderation) return [];
|
||||
return allKnownCampaigns.filter(
|
||||
(c) =>
|
||||
moderation.approvedCoords.has(c.aTag) &&
|
||||
!moderation.hiddenCoords.has(c.aTag) &&
|
||||
!featuredCoordSet.has(c.aTag),
|
||||
);
|
||||
const approvedOrder = moderation.approvedOrder;
|
||||
return allKnownCampaigns
|
||||
.filter(
|
||||
(c) =>
|
||||
moderation.approvedCoords.has(c.aTag) &&
|
||||
!moderation.hiddenCoords.has(c.aTag) &&
|
||||
!featuredCoordSet.has(c.aTag),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const ta = approvedOrder.get(a.aTag) ?? a.createdAt;
|
||||
const tb = approvedOrder.get(b.aTag) ?? b.createdAt;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [allKnownCampaigns, moderation, featuredCoordSet]);
|
||||
|
||||
// Pending: not approved, not hidden. Featured-but-unapproved is treated
|
||||
@@ -204,7 +218,14 @@ export function CampaignsPage() {
|
||||
featured (featured rides above). The grid is fed by the union
|
||||
of the recent-stream query and a coord-targeted query keyed
|
||||
on every approved coord, so approved campaigns older than
|
||||
the 200-event window still surface. */}
|
||||
the 200-event window still surface.
|
||||
|
||||
For moderators the grid is wrapped in `ReorderableCampaignGrid`
|
||||
which adds drag-and-drop on desktop and Move up / Move down
|
||||
kebab rows on mobile. Reordering republishes the campaign's
|
||||
`approved` label with a chosen `created_at`, which is the
|
||||
sort key for this grid (`approvedOrder` on the moderation
|
||||
rollup). */}
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
@@ -226,11 +247,11 @@ export function CampaignsPage() {
|
||||
) : communityCampaigns.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{communityCampaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
<ReorderableCampaignGrid
|
||||
campaigns={communityCampaigns}
|
||||
axis="approval"
|
||||
gridClassName="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Browse all campaigns" link — reveals the page with search,
|
||||
@@ -460,16 +481,22 @@ function FeaturedRow({
|
||||
// 2-4 use the regular compact card sized to the dynamic grid.
|
||||
const useFeaturedVariant = campaigns.length === 1;
|
||||
|
||||
// Moderators get drag-and-drop / kebab reorder on the featured
|
||||
// row; non-mods get a plain grid through the same component (it
|
||||
// branches internally). `axis="featured"` selects the
|
||||
// `featured` label as the order axis.
|
||||
return (
|
||||
<div className={featuredGridClass(campaigns.length)}>
|
||||
{campaigns.map((campaign) => (
|
||||
<ReorderableCampaignGrid
|
||||
campaigns={campaigns}
|
||||
axis="featured"
|
||||
gridClassName={featuredGridClass(campaigns.length)}
|
||||
renderCard={(campaign) => (
|
||||
<CampaignCard
|
||||
key={campaign.aTag}
|
||||
campaign={campaign}
|
||||
variant={useFeaturedVariant ? 'featured' : 'compact'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user