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:
mkfain
2026-05-30 22:07:53 +02:00
parent 7c14115119
commit 9e26bb8209
27 changed files with 1098 additions and 43 deletions
+17 -1
View File
@@ -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
+116 -4
View File
@@ -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>
);
}
+3
View File
@@ -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);
}
+21 -1
View File
@@ -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: () => {
+222
View File
@@ -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,
};
}
+19 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1251,7 +1251,17 @@
"toastHidden": "छुपा दिया गया",
"toastUnhidden": "अनहाइड कर दिया गया",
"toastFeatured": "फ़ीचर कर दिया गया",
"toastUnfeatured": "फ़ीचर से हटाया गया"
"toastUnfeatured": "फ़ीचर से हटाया गया",
"failedReorder": "क्रम बदलने में विफल",
"moveToTop": "सबसे ऊपर ले जाएँ",
"moveUp": "ऊपर ले जाएँ",
"moveDown": "नीचे ले जाएँ",
"dragHandle": "क्रम बदलने के लिए खींचें (स्थिति {{index}})",
"toast": {
"movedToTop": "सबसे ऊपर ले जाया गया",
"movedUp": "ऊपर ले जाया गया",
"movedDown": "नीचे ले जाया गया"
}
}
},
"settings": {
+11 -1
View File
@@ -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
View File
@@ -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
View File
@@ -818,7 +818,17 @@
"toastHidden": "پټ شوی",
"toastUnhidden": "بېرته ښودل شوی",
"toastFeatured": "ځانګړی شوی",
"toastUnfeatured": "د ځانګړو څخه لرې شوی"
"toastUnfeatured": "د ځانګړو څخه لرې شوی",
"failedReorder": "د بیا ترتیب کولو په کې پاتې راغی",
"moveToTop": "سر ته انتقال",
"moveUp": "پورته انتقال",
"moveDown": "ښکته انتقال",
"dragHandle": "د بیا ترتیب لپاره کش کړئ (موقعیت {{index}})",
"toast": {
"movedToTop": "سر ته انتقال شو",
"movedUp": "پورته انتقال شو",
"movedDown": "ښکته انتقال شو"
}
}
},
"settings": {
+11 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+11 -1
View File
@@ -818,7 +818,17 @@
"toastHidden": "已隱藏",
"toastUnhidden": "已取消隱藏",
"toastFeatured": "已加入精選",
"toastUnfeatured": "已自精選移除"
"toastUnfeatured": "已自精選移除",
"failedReorder": "無法重新排序",
"moveToTop": "移至頂端",
"moveUp": "上移",
"moveDown": "下移",
"dragHandle": "拖曳以重新排序(位置 {{index}}",
"toast": {
"movedToTop": "已移至頂端",
"movedUp": "已上移",
"movedDown": "已下移"
}
}
},
"settings": {
+11 -1
View File
@@ -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
View File
@@ -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>
)}
/>
);
}