diff --git a/NIP.md b/NIP.md index 498a6e90..52a6a9ee 100644 --- a/NIP.md +++ b/NIP.md @@ -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 (`/`) 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 diff --git a/src/components/moderation/ModerationMenu.tsx b/src/components/moderation/ModerationMenu.tsx index bfdac64b..cd1102b7 100644 --- a/src/components/moderation/ModerationMenu.tsx +++ b/src/components/moderation/ModerationMenu.tsx @@ -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; + onMoveUp: () => Promise | void; + onMoveDown: () => Promise | 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['data']; moderate: ReturnType['moderate']; }) { const { t } = useTranslation(); const { toast } = useToast(); const [busy, setBusy] = useState(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, + 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 ( <> @@ -191,6 +246,43 @@ function ModerationItemsShell({ ) )} + + {/* 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) && ( + <> + + {reorder.canMoveUp && ( + runReorder(reorder.onMoveToTop, 'movedToTop')} + disabled={reordering} + > + + {t('moderation.menu.moveToTop')} + + )} + {reorder.canMoveUp && ( + runReorder(reorder.onMoveUp, 'movedUp')} + disabled={reordering} + > + + {t('moderation.menu.moveUp')} + + )} + {reorder.canMoveDown && ( + runReorder(reorder.onMoveDown, 'movedDown')} + disabled={reordering} + > + + {t('moderation.menu.moveDown')} + + )} + + )} ); } @@ -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 ; } -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 ; } -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 ; } @@ -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 ; case 'pledge': return ; diff --git a/src/components/moderation/ModerationOverlay.tsx b/src/components/moderation/ModerationOverlay.tsx index e855251e..44845c48 100644 --- a/src/components/moderation/ModerationOverlay.tsx +++ b/src/components/moderation/ModerationOverlay.tsx @@ -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; + onMoveUp: () => Promise | void; + onMoveDown: () => Promise | void; +} interface ModerationOverlayProps { /** Addressable coordinate of the entity (`::`). */ @@ -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 & { 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} /> )} diff --git a/src/components/moderation/ReorderProvider.tsx b/src/components/moderation/ReorderProvider.tsx new file mode 100644 index 00000000..3766a480 --- /dev/null +++ b/src/components/moderation/ReorderProvider.tsx @@ -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; + onMoveUp: (coord: string) => Promise | void; + onMoveDown: (coord: string) => Promise | void; + children: React.ReactNode; +}) { + const value = useMemo(() => { + const byCoord = new Map(); + 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 {children}; +} diff --git a/src/components/moderation/ReorderableCampaignGrid.tsx b/src/components/moderation/ReorderableCampaignGrid.tsx new file mode 100644 index 00000000..9060dd5e --- /dev/null +++ b/src/components/moderation/ReorderableCampaignGrid.tsx @@ -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(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(); + for (const c of campaigns) m.set(c.aTag, c); + return m; + }, [campaigns]); + + const displayed = useMemo(() => { + 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) => { + 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) ?? , + [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 ( +
+ {displayed.map((campaign) => ( +
{renderItem(campaign)}
+ ))} +
+ ); + } + + // 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 ( + +
+ {displayed.map((campaign, idx) => + isMobile ? ( +
{renderItem(campaign)}
+ ) : ( + onMoveTo(droppedCoord, idx)} + > + {renderItem(campaign)} + + ), + )} +
+
+ ); +} + +/** + * 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 `` 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 ( +
{ + 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 ``. + Visible on hover and on keyboard focus. */} +
{ + 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 ``. + 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" + > + +
+ {children} +
+ ); +} + +/** Six-dot grip icon. Pure SVG to avoid pulling another lucide import. */ +function DragHandleIcon() { + return ( + + + + + + + + + ); +} diff --git a/src/components/moderation/index.ts b/src/components/moderation/index.ts index b5f50400..308e7aa9 100644 --- a/src/components/moderation/index.ts +++ b/src/components/moderation/index.ts @@ -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'; diff --git a/src/components/moderation/reorderContext.ts b/src/components/moderation/reorderContext.ts new file mode 100644 index 00000000..d2c00858 --- /dev/null +++ b/src/components/moderation/reorderContext.ts @@ -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; + onMoveUp: () => Promise | void; + onMoveDown: () => Promise | void; +} + +export interface ReorderContextValue { + axis: ReorderAxis; + byCoord: Map; +} + +/** Internal — exported only for the matching `ReorderProvider` component. */ +export const ReorderContext = createContext(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); +} diff --git a/src/hooks/useCampaignModeration.ts b/src/hooks/useCampaignModeration.ts index 136ced8c..5551d383 100644 --- a/src/hooks/useCampaignModeration.ts +++ b/src/hooks/useCampaignModeration.ts @@ -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: () => { diff --git a/src/hooks/useReorderCampaign.ts b/src/hooks/useReorderCampaign.ts new file mode 100644 index 00000000..25ef8762 --- /dev/null +++ b/src/hooks/useReorderCampaign.ts @@ -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, + }; +} diff --git a/src/lib/agoraModeration.ts b/src/lib/agoraModeration.ts index 3757fac8..ed84b234 100644 --- a/src/lib/agoraModeration.ts +++ b/src/lib/agoraModeration.ts @@ -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; + /** + * 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; /** 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(); const featuredCoords = new Set(); const featuredOrder = new Map(); + const approvedOrder = new Map(); 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 }; } diff --git a/src/locales/ar.json b/src/locales/ar.json index f7c1db23..f8680790 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -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": { diff --git a/src/locales/en.json b/src/locales/en.json index f439e99c..55bd0390 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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": { diff --git a/src/locales/es.json b/src/locales/es.json index 39214d03..87119456 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 2c31f85a..a43820d2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 4d36f2bd..44ae7e34 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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": { diff --git a/src/locales/hi.json b/src/locales/hi.json index 2cf405df..e8d078e5 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1251,7 +1251,17 @@ "toastHidden": "छुपा दिया गया", "toastUnhidden": "अनहाइड कर दिया गया", "toastFeatured": "फ़ीचर कर दिया गया", - "toastUnfeatured": "फ़ीचर से हटाया गया" + "toastUnfeatured": "फ़ीचर से हटाया गया", + "failedReorder": "क्रम बदलने में विफल", + "moveToTop": "सबसे ऊपर ले जाएँ", + "moveUp": "ऊपर ले जाएँ", + "moveDown": "नीचे ले जाएँ", + "dragHandle": "क्रम बदलने के लिए खींचें (स्थिति {{index}})", + "toast": { + "movedToTop": "सबसे ऊपर ले जाया गया", + "movedUp": "ऊपर ले जाया गया", + "movedDown": "नीचे ले जाया गया" + } } }, "settings": { diff --git a/src/locales/id.json b/src/locales/id.json index a78ba802..91ee3e44 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -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": { diff --git a/src/locales/km.json b/src/locales/km.json index fa930ecb..6b513085 100644 --- a/src/locales/km.json +++ b/src/locales/km.json @@ -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": { diff --git a/src/locales/ps.json b/src/locales/ps.json index 3b4e6cae..6199e52e 100644 --- a/src/locales/ps.json +++ b/src/locales/ps.json @@ -818,7 +818,17 @@ "toastHidden": "پټ شوی", "toastUnhidden": "بېرته ښودل شوی", "toastFeatured": "ځانګړی شوی", - "toastUnfeatured": "د ځانګړو څخه لرې شوی" + "toastUnfeatured": "د ځانګړو څخه لرې شوی", + "failedReorder": "د بیا ترتیب کولو په کې پاتې راغی", + "moveToTop": "سر ته انتقال", + "moveUp": "پورته انتقال", + "moveDown": "ښکته انتقال", + "dragHandle": "د بیا ترتیب لپاره کش کړئ (موقعیت {{index}})", + "toast": { + "movedToTop": "سر ته انتقال شو", + "movedUp": "پورته انتقال شو", + "movedDown": "ښکته انتقال شو" + } } }, "settings": { diff --git a/src/locales/pt.json b/src/locales/pt.json index a3271d80..e263142d 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 99abd800..d02504b6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": { diff --git a/src/locales/sn.json b/src/locales/sn.json index 8a22c953..6e7b74a6 100644 --- a/src/locales/sn.json +++ b/src/locales/sn.json @@ -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": { diff --git a/src/locales/sw.json b/src/locales/sw.json index fff6e30c..c80e611f 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -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": { diff --git a/src/locales/tr.json b/src/locales/tr.json index 5960053c..1698096f 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -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": { diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index 9ab337d8..9118b468 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -818,7 +818,17 @@ "toastHidden": "已隱藏", "toastUnhidden": "已取消隱藏", "toastFeatured": "已加入精選", - "toastUnfeatured": "已自精選移除" + "toastUnfeatured": "已自精選移除", + "failedReorder": "無法重新排序", + "moveToTop": "移至頂端", + "moveUp": "上移", + "moveDown": "下移", + "dragHandle": "拖曳以重新排序(位置 {{index}})", + "toast": { + "movedToTop": "已移至頂端", + "movedUp": "已上移", + "movedDown": "已下移" + } } }, "settings": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 67e3c6fb..64fffd1b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": { diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx index 0630d776..3e281fa9 100644 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -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(() => { 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). */}
@@ -226,11 +247,11 @@ export function CampaignsPage() { ) : communityCampaigns.length === 0 ? ( ) : ( -
- {communityCampaigns.map((campaign) => ( - - ))} -
+ )} {/* "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 ( -
- {campaigns.map((campaign) => ( + ( - ))} -
+ )} + /> ); }