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