Retire the approval axis; Featured becomes the sole positive-curation mechanism

Now that moderators can directly order the Featured row, the second
"Community Campaigns" bucket (approved + not-featured + not-hidden)
is redundant. This commit removes the approval axis end-to-end and
collapses the home page to a single curated section.

Protocol (NIP.md):

- `ModerationLabel` shrinks from six values to four — `hidden`,
  `unhidden`, `featured`, `unfeatured`. The legacy `approved` /
  `unapproved` labels are now ignored on read and MUST NOT be
  published.
- `ModerationAxis` shrinks from three to two: `hide` and `featured`,
  both supported by all three surfaces (campaigns, organizations,
  pledges).
- The rank tag now only applies to `featured` labels.
- A migration note in NIP.md explains the retirement and tells
  clients to ignore lingering approval-axis labels in relay
  archives.

UI:

- CampaignsPage drops the Community Campaigns and Pending sections.
  Home is now Featured (with the empty state in place when nothing
  is featured) → Browse-all link → moderator-only Hidden section.
  The labeled-coord targeted fetch shrinks to hidden coords only.
- ModerationMenu loses the Approve / Unapprove rows and the
  `hasApproval` / `isApproved` plumbing.
- `CampaignCard`'s `axes` prop drops `'approval'`.
- `ReorderAxis` collapses to a single axis — the type and the
  parameter are removed from the reorder hook, provider, context,
  and grid component since every reorder targets the featured axis.
- Pledge and organization moderation hooks lose their defensive
  `'approved' | 'unapproved'` rejection branches now that those
  values are off the `ModerationLabel` union.

i18n (16 locales):

- Five moderation.menu keys removed: `approve`, `unapprove`,
  `approvedState`, `toastApproved`, `toastUnapproved`.
- Five campaigns.home keys removed: `community`, `communityDesc`,
  `pending`, `pendingDesc`, `pendingEmpty`.
- `campaigns.home.yourCampaignsDesc` rewritten across every locale
  to drop the "appears on the homepage once a moderator approves"
  copy; new copy points authors at /campaigns for discovery and
  notes that the team curates a featured selection on the home page.

Test suite green: tsc, eslint, vitest, vite build all pass.
This commit is contained in:
mkfain
2026-05-30 22:48:21 +02:00
parent ef9c2eff89
commit b0759402cf
28 changed files with 242 additions and 609 deletions
+21 -21
View File
@@ -22,7 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
@@ -521,23 +521,22 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
| Axis | Values | Surfaces | Meaning |
|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
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 by the featured label's effective rank (see Moderator-driven Ordering), descending. 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). Ordered by the approved label's effective rank, descending (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.
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered by the featured label's effective rank (see Moderator-driven Ordering), descending.
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
**Organizations**
@@ -556,13 +555,13 @@ Surfacing rules (hide always wins):
#### Moderator-driven Ordering
The Featured row and Community Campaigns grid are sorted by the **effective rank** of the moderator's latest label on the relevant axis (`featured` for the Featured row, `approved` for the Community grid), descending.
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal approve / hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature/approval actions naturally float to the top, exactly as if no ordering scheme existed.
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
A moderator MAY reorder either list by republishing the same axis label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
@@ -572,7 +571,9 @@ A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemen
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.
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same approve / hide / feature state they always have.
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
#### Event Structure
@@ -582,9 +583,9 @@ Reorder labels remain valid moderation labels in every other respect. Clients th
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "approved", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: approved"]
["alt", "Campaign moderation: featured"]
]
}
```
@@ -628,7 +629,7 @@ Required tags:
Optional tags:
- `rank` — single string element parsed as an integer. Used only on `approved` and `featured` labels to position the target within Agora's moderator-curated ordered lists; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
A label with a rank tag looks like:
@@ -658,8 +659,8 @@ d-tag: k4p5w0n22suf
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
- Self-approval is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
- Self-promotion is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
@@ -692,10 +693,9 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
#### Client Behavior
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
---
+1 -1
View File
@@ -247,7 +247,7 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
coord={campaign.aTag}
entityTitle={campaign.title}
surface="campaign"
axes={['approval', 'hide', 'featured']}
axes={['hide', 'featured']}
badgeSize="default"
className="absolute top-3 right-3 z-10 flex items-center gap-2"
/>
+10 -27
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import {
ArrowUp, ArrowDown, ArrowUpToLine,
Check, EyeOff, Eye, MoreHorizontal,
ShieldCheck, ShieldOff, Sparkles, SparklesIcon,
Sparkles, SparklesIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -31,13 +31,15 @@ import type { ModerationLabel } from '@/lib/agoraModeration';
export type ModerationSurface = 'campaign' | 'pledge' | 'group';
/**
* Which axes the menu should render. Campaigns have all three; pledges
* and groups don't have an approval axis. The order in this array does
* NOT determine render order — the menu always renders Approve → Hide →
* Feature top-to-bottom when present, which keeps the three surfaces
* visually consistent.
* Which axes the menu should render. Two are defined: `hide` and
* `featured`. Every current surface (campaigns, organizations,
* pledges) supports both; the prop exists so future surfaces can
* selectively expose one axis if needed. The order in this array
* does NOT determine render order — the menu always renders
* Hide → Feature top-to-bottom when present, which keeps the
* surfaces visually consistent.
*/
export type ModerationAxis = 'approval' | 'hide' | 'featured';
export type ModerationAxis = 'hide' | 'featured';
interface ModerationItemsProps {
/** Addressable coordinate of the entity (`<kind>:<pubkey>:<d>`). */
@@ -130,11 +132,9 @@ function ModerationItemsShell({
// 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);
const isFeatured = moderation.featuredCoords.has(coord);
const hasApproval = axes.includes('approval');
const hasHide = axes.includes('hide');
const hasFeatured = axes.includes('featured');
@@ -190,23 +190,6 @@ function ModerationItemsShell({
</DropdownMenuLabel>
<DropdownMenuSeparator />
{hasApproval && (
isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', t('moderation.menu.toastUnapproved'))} disabled={!!busy}>
<ShieldOff className="h-4 w-4 mr-2" />
{t('moderation.menu.unapprove')}
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> {t('moderation.menu.approvedState')}
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', t('moderation.menu.toastApproved'))} disabled={!!busy}>
<ShieldCheck className="h-4 w-4 mr-2" />
{t('moderation.menu.approve')}
</DropdownMenuItem>
)
)}
{hasHide && (
isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', t('moderation.menu.toastUnhidden'))} disabled={!!busy}>
@@ -228,7 +211,7 @@ function ModerationItemsShell({
)
)}
{hasFeatured && (hasApproval || hasHide) && <DropdownMenuSeparator />}
{hasFeatured && hasHide && <DropdownMenuSeparator />}
{hasFeatured && (
isFeatured ? (
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import type { ReorderAxis } from '@/hooks/useReorderCampaign';
import {
ReorderContext,
type ReorderContextValue,
@@ -21,14 +20,12 @@ import {
* 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;
@@ -46,8 +43,8 @@ export function ReorderProvider({
onMoveDown: () => onMoveDown(coord),
});
});
return { axis, byCoord };
}, [axis, coords, onMoveToTop, onMoveUp, onMoveDown]);
return { byCoord };
}, [coords, onMoveToTop, onMoveUp, onMoveDown]);
return <ReorderContext.Provider value={value}>{children}</ReorderContext.Provider>;
}
@@ -6,7 +6,7 @@ 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 { useReorderCampaign } from '@/hooks/useReorderCampaign';
import { useToast } from '@/hooks/useToast';
import type { ParsedCampaign } from '@/lib/campaign';
import { cn } from '@/lib/utils';
@@ -15,12 +15,6 @@ 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;
/**
@@ -33,16 +27,16 @@ interface ReorderableCampaignGridProps {
/**
* Drop-in replacement for a plain grid of `CampaignCard`s that
* lets moderators reorder the list.
* lets moderators reorder the Featured row.
*
* - **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.
* `featured` label with a rank 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
@@ -60,7 +54,6 @@ interface ReorderableCampaignGridProps {
*/
export function ReorderableCampaignGrid({
campaigns,
axis,
gridClassName,
renderCard,
}: ReorderableCampaignGridProps) {
@@ -144,9 +137,9 @@ export function ReorderableCampaignGrid({
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));
await applyOptimisticThenPublish(next, () => reorder.moveToTop(coord, displayedCoords));
},
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
[displayedCoords, applyOptimisticThenPublish, reorder],
);
const onMoveUp = useCallback(
@@ -155,9 +148,9 @@ export function ReorderableCampaignGrid({
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));
await applyOptimisticThenPublish(next, () => reorder.moveUp(coord, displayedCoords));
},
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
[displayedCoords, applyOptimisticThenPublish, reorder],
);
const onMoveDown = useCallback(
@@ -166,9 +159,9 @@ export function ReorderableCampaignGrid({
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));
await applyOptimisticThenPublish(next, () => reorder.moveDown(coord, displayedCoords));
},
[displayedCoords, applyOptimisticThenPublish, reorder, axis],
[displayedCoords, applyOptimisticThenPublish, reorder],
);
// Generic "drop at index" used by drag-and-drop. Success is its
@@ -183,7 +176,7 @@ export function ReorderableCampaignGrid({
next.splice(toIndex, 0, coord);
try {
await applyOptimisticThenPublish(next, () =>
reorder.moveTo(coord, axis, displayedCoords, toIndex),
reorder.moveTo(coord, displayedCoords, toIndex),
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
@@ -194,7 +187,7 @@ export function ReorderableCampaignGrid({
});
}
},
[displayedCoords, applyOptimisticThenPublish, reorder, axis, toast, t],
[displayedCoords, applyOptimisticThenPublish, reorder, toast, t],
);
const renderItem = useCallback(
@@ -220,7 +213,6 @@ export function ReorderableCampaignGrid({
// wrapper handles its own dragover/drop styling.
return (
<ReorderProvider
axis={axis}
coords={displayedCoords}
onMoveToTop={onMoveToTop}
onMoveUp={onMoveUp}
@@ -1,7 +1,5 @@
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.
@@ -20,7 +18,6 @@ export interface ReorderEntry {
}
export interface ReorderContextValue {
axis: ReorderAxis;
byCoord: Map<string, ReorderEntry>;
}
+18 -18
View File
@@ -25,21 +25,21 @@ type CampaignModerationData = ModerationData;
/**
* Fetches and folds campaign-moderation label events authored by Team
* Soapbox members. Returns approval / hide / featured rollups per campaign
* Soapbox members. Returns hide / featured rollups per campaign
* coordinate.
*
* **Display rule** consumers should follow:
* - Featured row on `/` iff `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
* - Community Campaigns grid on `/` iff `approvedCoords.has(coord) && !hiddenCoords.has(coord) && !featuredCoords.has(coord)` (featured dedupe).
* - Discover shelf iff `approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
* - "Pending" (moderator-only sections) iff `!approvedCoords.has(coord) && !hiddenCoords.has(coord)`.
* - Featured row on `/` iff `featuredCoords.has(coord) && !hiddenCoords.has(coord)`, ordered by `featuredOrder` descending.
* - Discover shelf on `/campaigns` iff `!hiddenCoords.has(coord)`.
* - "Hidden" (moderator-only sections) iff `hiddenCoords.has(coord)`.
* - Featured is independent of Approved at the protocol level; hide always wins.
* - Hide always wins over featured.
*
* The mutation `moderate({ coord, action })` publishes a single kind 1985
* event labeling one campaign in the `agora.moderation` namespace. Callers
* MUST be in the moderator set or the relay-side `authors:` filter on read
* will silently ignore the new event.
* The mutation `moderate({ coord, action, rank? })` publishes a single
* kind 1985 event labeling one campaign in the `agora.moderation`
* namespace. Callers MUST be in the moderator set or the relay-side
* `authors:` filter on read will silently ignore the new event. The
* optional `rank` writes a `["rank", "<integer>"]` tag for moderator-
* driven ordering of the featured row — see `useReorderCampaign`.
*/
export function useCampaignModeration() {
const { nostr } = useNostr();
@@ -95,9 +95,9 @@ export function useCampaignModeration() {
* Optional explicit rank for the label, written into a
* `["rank", "<number>"]` tag on the event. Used by
* `useReorderCampaign` to position a campaign within the
* featured row or Community grid — the moderation fold uses
* the rank as the sort key (descending), falling back to
* `created_at` when no rank tag is present.
* featured row — the moderation fold uses the rank as the
* sort key (descending), falling back to `created_at` when
* no rank tag is present.
*
* The event itself is always signed with `created_at = now`
* so the fold's "newest event per (coord, axis)" rule picks
@@ -105,7 +105,7 @@ export function useCampaignModeration() {
* than the current label's rank — without that, moving a
* campaign downward would be silently rejected by the fold.
*
* Omit for normal approve / hide / feature actions.
* Omit for normal hide / feature actions.
*/
rank?: number;
}) => {
@@ -133,10 +133,10 @@ export function useCampaignModeration() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaign-moderation'] });
// Moderation decisions (approve / hide / feature) gate which campaigns
// surface on the home page, discover shelf, and community grids — so
// the list queries need to refetch too, otherwise the moderator's UI
// still shows the old approval state until refresh.
// Moderation decisions (hide / feature) gate which campaigns
// surface on the home page and discover shelf — so the list
// queries need to refetch too, otherwise the moderator's UI
// still shows the old state until refresh.
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
queryClient.invalidateQueries({ queryKey: ['campaigns-all-scores'] });
-16
View File
@@ -28,15 +28,6 @@ type OrganizationModerationData = ModerationData;
* to the campaign side (we fetch every namespace-tagged label authored by
* moderators) — the surface separation is purely client-side.
*
* **Two-axis model.** Unlike campaigns, organizations don't have an
* `approved` axis. Every Agora-tagged organization is publicly visible
* by default; moderation reduces to `featured` (lift into the curated
* shelf) and `hidden` (suppress from public discovery). The shared
* fold helper still tracks `approvedCoords` for type symmetry with the
* campaign hook, but the org UI never emits or reads it — moderators
* SHOULD NOT publish `approved` / `unapproved` labels against kind
* 34550 coordinates.
*
* **Display rule** consumers should follow:
* - Featured shelf on `/communities` iff
* `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
@@ -99,13 +90,6 @@ export function useOrganizationModeration() {
if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
}
// Organizations use a two-axis model — only `featured` / `unfeatured`
// / `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Organizations do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
-16
View File
@@ -37,15 +37,6 @@ interface UsePledgeModerationOptions {
* relay-side query is identical to the other two surfaces — surface
* separation is purely client-side.
*
* **Two-axis model.** Like organizations, pledges don't have an
* `approved` axis. Every Agora-tagged pledge is publicly visible by
* default; moderation reduces to `featured` (lift into a curated slot)
* and `hidden` (suppress from public discovery). The shared fold helper
* still tracks `approvedCoords` for type symmetry with the campaign
* hook, but the pledge UI never emits or reads it — moderators SHOULD
* NOT publish `approved` / `unapproved` labels against kind 36639
* coordinates.
*
* **Display rule** consumers should follow:
* - Hide enforcement on `/pledges` and any pledge discovery surface:
* non-moderators MUST NOT see `hidden` pledges. Moderators MAY see
@@ -111,13 +102,6 @@ export function usePledgeModeration({ coordinates, enabled = true }: UsePledgeMo
if (!coord.startsWith(`${PLEDGE_KIND}:`)) {
throw new Error(`Coordinate must start with ${PLEDGE_KIND}:`);
}
// Pledges use a two-axis model — only `featured` / `unfeatured` /
// `hidden` / `unhidden` are valid here. Reject `approved` /
// `unapproved` defensively so a stray UI bug can't poison the
// label stream with axis-mixed events.
if (action === 'approved' || action === 'unapproved') {
throw new Error(`Pledges do not support the ${action} label`);
}
return publishEvent({
kind: LABEL_KIND,
content: '',
+27 -51
View File
@@ -1,22 +1,6 @@
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
* effective rank of the latest label on their axis, descending — see
* `useCampaignModeration` / `foldModerationLabels` for the rank
* extraction (explicit `["rank", N]` tag falling back to
* `created_at`).
*/
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';
}
/**
* Multiplier that lifts a freshly-stamped rank into the
@@ -24,10 +8,9 @@ function axisToLabel(axis: ReorderAxis): ModerationLabel {
* `Date.now() * RANK_SCALE`, which is several orders of magnitude
* above any legacy `created_at` fallback (seconds-since-epoch) — so
* a newly-reordered campaign always sits above un-reordered legacy
* neighbors when they share the same axis state. The fine-grained
* sub-second resolution also leaves ample room for inserting
* midpoint ranks during drag-to-position without exhausting the
* integer gap.
* neighbors. The fine-grained sub-second resolution also leaves
* ample room for inserting midpoint ranks during drag-to-position
* without exhausting the integer gap.
*
* Headroom check: `Date.now() * 1000 ≈ 1.7e15`; `Number.MAX_SAFE_INTEGER
* ≈ 9e15`. ~150 years before overflow concerns.
@@ -41,11 +24,11 @@ function freshRank(): number {
/**
* Reordering is implemented via a `["rank", "<number>"]` tag on
* kind 1985 moderation labels. The fold reads the rank as the sort
* kind 1985 `featured` labels. The fold reads the rank as the sort
* key (descending), falling back to `created_at` when the rank tag
* is absent — so labels published before this feature existed (and
* any normal approve / hide / feature actions that don't carry a
* rank) continue to sort sensibly.
* any normal feature actions that don't carry a rank) continue to
* sort sensibly.
*
* Why a tag and not the label's `created_at` directly: the fold
* always picks the newest-`created_at` event per `(coord, axis)`.
@@ -59,7 +42,7 @@ function freshRank(): number {
* Operations:
*
* - `moveToTop` — publish with `rank = max(now_scaled, topRank + 1)`.
* The `max` guard handles the (rare) clock-skewed neighbor whose
* The `max` guard handles a (rare) clock-skewed neighbor whose
* stored rank is somehow already above `now_scaled`.
* - `moveUp` — publish with `rank = aboveNeighbor.rank + 1`. The
* "+1" is what crosses the boundary; the neighbor above already
@@ -80,23 +63,17 @@ function freshRank(): number {
export function useReorderCampaign() {
const { moderate, data: moderation } = useCampaignModeration();
const orderMap = useCallback(
(axis: ReorderAxis) =>
axis === 'featured' ? moderation.featuredOrder : moderation.approvedOrder,
[moderation],
);
/**
* Publishes a label on `axis` for `coord` carrying an explicit
* Publishes a `featured` label for `coord` carrying an explicit
* rank. `useCampaignModeration().moderate` handles the relay
* invalidations and the campaign coord-prefix check; the rank is
* written into a `["rank", "<number>"]` tag on the label event.
*/
const publishWithRank = useCallback(
async (coord: string, axis: ReorderAxis, rank: number) => {
async (coord: string, rank: number) => {
await moderate.mutateAsync({
coord,
action: axisToLabel(axis),
action: 'featured',
rank,
});
},
@@ -105,14 +82,14 @@ export function useReorderCampaign() {
/** Move `coord` to position 0 of the displayed list. */
const moveToTop = useCallback(
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
const map = orderMap(axis);
async (coord: string, displayedList: readonly string[]) => {
const map = moderation.featuredOrder;
const topCoord = displayedList[0];
const topRank = topCoord && topCoord !== coord ? map.get(topCoord) ?? 0 : 0;
const newRank = Math.max(freshRank(), topRank + 1);
await publishWithRank(coord, axis, newRank);
await publishWithRank(coord, newRank);
},
[orderMap, publishWithRank],
[moderation, publishWithRank],
);
/**
@@ -120,40 +97,40 @@ export function useReorderCampaign() {
* at the top.
*/
const moveUp = useCallback(
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
async (coord: string, 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);
await moveToTop(coord, displayedList);
return;
}
const map = orderMap(axis);
const map = moderation.featuredOrder;
const aboveCoord = displayedList[idx - 1];
const aboveRank = map.get(aboveCoord);
if (aboveRank === 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);
await moveToTop(coord, displayedList);
return;
}
await publishWithRank(coord, axis, aboveRank + 1);
await publishWithRank(coord, aboveRank + 1);
},
[orderMap, publishWithRank, moveToTop],
[moderation, publishWithRank, moveToTop],
);
/** Move `coord` down by one position. */
const moveDown = useCallback(
async (coord: string, axis: ReorderAxis, displayedList: readonly string[]) => {
async (coord: string, displayedList: readonly string[]) => {
const idx = displayedList.indexOf(coord);
if (idx < 0 || idx >= displayedList.length - 1) return;
const map = orderMap(axis);
const map = moderation.featuredOrder;
const belowCoord = displayedList[idx + 1];
const belowRank = map.get(belowCoord);
if (belowRank === undefined) return;
await publishWithRank(coord, axis, belowRank - 1);
await publishWithRank(coord, belowRank - 1);
},
[orderMap, publishWithRank],
[moderation, publishWithRank],
);
/**
@@ -164,7 +141,6 @@ export function useReorderCampaign() {
const moveTo = useCallback(
async (
coord: string,
axis: ReorderAxis,
displayedList: readonly string[],
toIndex: number,
) => {
@@ -179,7 +155,7 @@ export function useReorderCampaign() {
const prevCoord = clamped > 0 ? without[clamped - 1] : undefined;
const nextCoord = clamped < without.length ? without[clamped] : undefined;
const map = orderMap(axis);
const map = moderation.featuredOrder;
const prevRank = prevCoord ? map.get(prevCoord) : undefined;
const nextRank = nextCoord ? map.get(nextCoord) : undefined;
@@ -208,9 +184,9 @@ export function useReorderCampaign() {
newRank = nextRank + 1;
}
await publishWithRank(coord, axis, newRank);
await publishWithRank(coord, newRank);
},
[orderMap, publishWithRank],
[moderation, publishWithRank],
);
return {
+28 -45
View File
@@ -2,15 +2,20 @@ import type { NostrEvent } from '@nostrify/nostrify';
/**
* Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
* the `agora.moderation` namespace). Both campaigns (kind 33863) and
* organizations (kind 34550) ride the same label stream and the same
* moderator pack (Team Soapbox); the only thing that varies between them is
* the kind prefix on the `a` tag.
* the `agora.moderation` namespace). Campaigns (kind 33863), organizations
* (kind 34550), and pledges (kind 36639) all ride the same label stream and
* the same moderator pack (Team Soapbox); the only thing that varies
* between them is the kind prefix on the `a` tag.
*
* Centralizing the constants, types, and folding logic here keeps the two
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
* from drifting apart on namespace strings, axis semantics, or the
* surfacing-rule contract documented in NIP.md.
* Centralizing the constants, types, and folding logic here keeps the
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`,
* `usePledgeModeration`) from drifting apart on namespace strings, axis
* semantics, or the surfacing-rule contract documented in NIP.md.
*
* Two axes are defined: `hide` (universal) and `featured` (universal).
* The approval axis was removed once Featured became the single positive
* curation mechanism on the home page — see NIP.md and the project
* changelog for the history.
*/
/** NIP-32 label kind. */
@@ -19,10 +24,8 @@ export const LABEL_KIND = 1985;
/** Label namespace for Agora's moderation labels. */
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
/** The six possible label values in the moderation namespace. */
/** The four possible label values in the moderation namespace. */
export type ModerationLabel =
| 'approved'
| 'unapproved'
| 'hidden'
| 'unhidden'
| 'featured'
@@ -45,17 +48,16 @@ interface AxisDecision {
* the current label).
*
* `undefined` for labels published before the reorder feature
* shipped, or for normal approve / hide / feature actions that
* don't carry a rank. Callers compute an effective sort key with
* shipped, or for normal hide / feature actions that don't carry
* a rank. Callers compute an effective sort key with
* `rank ?? createdAt`, giving legacy labels a sensible default
* while letting reorder labels override.
*/
rank?: number;
}
/** Per-coordinate rollup of approval + hide + featured state. */
/** Per-coordinate rollup of hide + featured state. */
export interface ModerationState {
approval?: AxisDecision; // `approved` or `unapproved`
hide?: AxisDecision; // `hidden` or `unhidden`
featured?: AxisDecision; // `featured` or `unfeatured`
}
@@ -68,8 +70,6 @@ export interface ModerationState {
export interface ModerationData {
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
byCoord: Map<string, ModerationState>;
/** Coordinates where the latest approval label is `approved`. */
approvedCoords: Set<string>;
/** Coordinates where the latest hide label is `hidden`. */
hiddenCoords: Set<string>;
/** Coordinates where the latest featured label is `featured`. */
@@ -91,30 +91,18 @@ export interface ModerationData {
* float to the top, exactly as before the rank tag landed.
*/
featuredOrder: Map<string, number>;
/**
* Map of `coord` -> sort key for the Community Campaigns grid.
* Same shape and rules as `featuredOrder`, but tracks the
* `approved` axis.
*/
approvedOrder: Map<string, number>;
/** Pubkeys that were considered moderators when the query ran. */
moderators: string[];
}
export const EMPTY_MODERATION_DATA: ModerationData = {
byCoord: new Map(),
approvedCoords: new Set(),
hiddenCoords: new Set(),
featuredCoords: new Set(),
featuredOrder: new Map(),
approvedOrder: new Map(),
moderators: [],
};
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
return value === 'approved' || value === 'unapproved';
}
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
return value === 'hidden' || value === 'unhidden';
}
@@ -147,7 +135,9 @@ function extractRank(event: NostrEvent): number | undefined {
* into each other even though they share a namespace and signer set.
*
* Events with a value outside the moderation namespace, or with no `l` tag
* in that namespace, are dropped.
* in that namespace, are dropped. Legacy `approved` / `unapproved` labels
* (from the previous approval axis) are silently ignored — the axis was
* retired in favor of Featured-only positive curation.
*/
export function foldModerationLabels(
events: NostrEvent[],
@@ -169,11 +159,7 @@ export function foldModerationLabels(
const rank = extractRank(event);
const state = byCoord.get(aTag) ?? {};
if (isApprovalLabel(value)) {
if (!state.approval || event.created_at > state.approval.createdAt) {
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
}
} else if (isHideLabel(value)) {
if (isHideLabel(value)) {
if (!state.hide || event.created_at > state.hide.createdAt) {
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
}
@@ -182,28 +168,25 @@ export function foldModerationLabels(
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at, rank };
}
}
// Unknown values (including legacy `approved`/`unapproved`) drop out
// silently. The approval axis is retired; clients that still see
// such labels in their cache simply ignore them.
byCoord.set(aTag, state);
}
const approvedCoords = new Set<string>();
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);
// Effective sort key: explicit rank tag wins, falling back to
// the label's created_at so labels published before the rank
// tag existed still sort correctly (newest-approved first).
approvedOrder.set(coord, state.approval.rank ?? state.approval.createdAt);
}
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
if (state.featured?.label === 'featured') {
featuredCoords.add(coord);
// Effective sort key: explicit rank tag wins, falling back to
// the label's created_at so labels published before the rank
// tag existed still sort correctly (newest-featured first).
featuredOrder.set(coord, state.featured.rank ?? state.featured.createdAt);
}
}
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, approvedOrder, moderators };
return { byCoord, hiddenCoords, featuredCoords, featuredOrder, moderators };
}
+1 -11
View File
@@ -740,17 +740,12 @@
"exploreCampaigns": "تصفّح الحملات",
"featured": "مميّزة",
"featuredDesc": "حملات منتقاة بعناية من فريق {{appName}}.",
"community": "حملات المجتمع",
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
"browseAll": "تصفّح كل الحملات ←",
"pending": "بانتظار الموافقة",
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
"hidden": "مخفية",
"hiddenDesc": "حملات أُزيلت من الصفحة الرئيسية العامة. استخدم قائمة البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد حملات مخفية حالياً.",
"yourCampaigns": "حملاتك",
"yourCampaignsDesc": "حملاتك منشورة على Nostr وتعمل التبرعات عبر الرابط. ستظهر على الصفحة الرئيسية بمجرد أن يوافق عليها مشرف من فريق Soapbox.",
"yourCampaignsDesc": "حملاتك منشورة على Nostr وتعمل التبرعات عبر رابط الحملة. تصفّح كل الحملات على /campaigns؛ ويختار فريق {{appName}} مجموعة منتقاة لعرضها على الصفحة الرئيسية.",
"empty": "لا توجد حملات بعد",
"emptyHint": "كن أول من يبدأ حملة تمويل على {{appName}}. اروِ قصتك، اختر المستفيدين، وشارك الرابط."
},
@@ -789,9 +784,6 @@
"ariaGroup": "إدارة المجموعة",
"failedAction": "فشل {{action}}",
"failedReorder": "فشل إعادة الترتيب",
"approve": "اعتماد",
"unapprove": "إلغاء الاعتماد",
"approvedState": "معتمدة",
"hide": "إخفاء",
"unhide": "إلغاء الإخفاء",
"hiddenState": "مخفية",
@@ -802,8 +794,6 @@
"moveUp": "تحريك للأعلى",
"moveDown": "تحريك للأسفل",
"dragHandle": "اسحب لإعادة الترتيب (الموضع {{index}})",
"toastApproved": "تم الاعتماد للصفحة الرئيسية",
"toastUnapproved": "أُزيلت من الصفحة الرئيسية",
"toastHidden": "تم الإخفاء",
"toastUnhidden": "تم إلغاء الإخفاء",
"toastFeatured": "تم التمييز",
+2 -12
View File
@@ -1182,21 +1182,16 @@
"exploreCampaigns": "Explore campaigns",
"featured": "Featured",
"featuredDesc": "Hand-picked campaigns from the {{appName}} team.",
"community": "Community Campaigns",
"communityDesc": "Help fund the changes worth making.",
"browseAll": "Browse all campaigns →",
"searchPlaceholder": "Search campaigns\u2026",
"searchAriaLabel": "Search campaigns",
"noMatch": "No campaigns match \u201c{{query}}\u201d",
"noMatchHint": "Try a different search term, or clear the search.",
"pending": "Pending approval",
"pendingDesc": "Campaigns on the network that no Team Soapbox moderator has approved or hidden yet.",
"pendingEmpty": "Nothing awaiting review.",
"hidden": "Hidden",
"hiddenDesc": "Campaigns suppressed from the public homepage. Use the kebab menu on a card to unhide.",
"hiddenDesc": "Campaigns suppressed from public discovery. Use the kebab menu on a card to unhide.",
"hiddenEmpty": "No campaigns are currently hidden.",
"yourCampaigns": "Your campaigns",
"yourCampaignsDesc": "Your campaigns are live on Nostr and donations work via the campaign link. They appear on the homepage once a Team Soapbox moderator approves them.",
"yourCampaignsDesc": "Your campaigns are live on Nostr and donations work via the campaign link. Browse all campaigns at /campaigns; the {{appName}} team features a curated selection on the homepage.",
"empty": "No campaigns yet",
"emptyHint": "Be the first to start a fundraiser on {{appName}}. Tell your story, choose your beneficiaries, and share the link."
},
@@ -1236,9 +1231,6 @@
"ariaGroup": "Moderate group",
"failedAction": "Failed to {{action}}",
"failedReorder": "Failed to reorder",
"approve": "Approve",
"unapprove": "Unapprove",
"approvedState": "Approved",
"hide": "Hide",
"unhide": "Unhide",
"hiddenState": "Hidden",
@@ -1249,8 +1241,6 @@
"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",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "Explorar campañas",
"featured": "Destacadas",
"featuredDesc": "Campañas seleccionadas por el equipo de {{appName}}.",
"community": "Campañas de la comunidad",
"communityDesc": "Ayuda a financiar los cambios que valen la pena.",
"browseAll": "Ver todas las campañas →",
"pending": "Pendientes de aprobación",
"pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.",
"pendingEmpty": "Nada pendiente de revisión.",
"hidden": "Ocultas",
"hiddenDesc": "Campañas suprimidas de la página de inicio pública. Usa el menú de la tarjeta para mostrarlas de nuevo.",
"hiddenEmpty": "No hay campañas ocultas actualmente.",
"yourCampaigns": "Tus campañas",
"yourCampaignsDesc": "Tus campañas están activas en Nostr y las donaciones funcionan mediante el enlace. Aparecerán en la página de inicio cuando un moderador del equipo Soapbox las apruebe.",
"yourCampaignsDesc": "Tus campañas están activas en Nostr y las donaciones funcionan mediante el enlace de la campaña. Explora todas las campañas en /campaigns; el equipo de {{appName}} destaca una selección curada en la página de inicio.",
"empty": "Aún no hay campañas",
"emptyHint": "Sé el primero en iniciar una recaudación en {{appName}}. Cuenta tu historia, elige a los beneficiarios y comparte el enlace.",
"searchPlaceholder": "Buscar campañas…",
@@ -805,9 +800,6 @@
"ariaGroup": "Moderar grupo",
"failedAction": "No se pudo {{action}}",
"failedReorder": "No se pudo reordenar",
"approve": "Aprobar",
"unapprove": "Desaprobar",
"approvedState": "Aprobado",
"hide": "Ocultar",
"unhide": "Mostrar",
"hiddenState": "Oculto",
@@ -818,8 +810,6 @@
"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",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "مرور کمپین‌ها",
"featured": "ویژه",
"featuredDesc": "کمپین‌های منتخب تیم {{appName}}.",
"community": "کمپین‌های جامعه",
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
"browseAll": "← مرور همه کمپین‌ها",
"pending": "در انتظار تأیید",
"pendingDesc": "کمپین‌هایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آن‌ها را تأیید یا پنهان نکرده است.",
"pendingEmpty": "چیزی برای بررسی نیست.",
"hidden": "پنهان‌شده",
"hiddenDesc": "کمپین‌هایی که از صفحه اصلی عمومی حذف شده‌اند. برای آشکار کردن دوباره از منوی کارت استفاده کنید.",
"hiddenEmpty": "در حال حاضر هیچ کمپینی پنهان نشده است.",
"yourCampaigns": "کمپین‌های شما",
"yourCampaignsDesc": "کمپین‌های شما در Nostr فعال هستند و کمک‌های مالی از طریق لینک کار می‌کنند. به محض تأیید توسط یک ناظر تیم Soapbox، در صفحه اصلی ظاهر می‌شوند.",
"yourCampaignsDesc": "کمپین‌های شما در Nostr فعال هستند و کمک‌های مالی از طریق لینک کمپین کار می‌کنند. همه کمپین‌ها را در /campaigns مرور کنید؛ تیم {{appName}} مجموعه‌ای منتخب را در صفحه اصلی معرفی می‌کند.",
"empty": "هنوز کمپینی وجود ندارد",
"emptyHint": "اولین نفری باشید که در {{appName}} کمپین راه‌اندازی می‌کند. داستان خود را بگویید، ذی‌نفعان را انتخاب کنید، و لینک را به اشتراک بگذارید.",
"searchPlaceholder": "جستجوی کمپین‌ها…",
@@ -804,9 +799,6 @@
"ariaPledge": "نظارت بر تعهد",
"ariaGroup": "نظارت بر گروه",
"failedAction": "{{action}} ناموفق بود",
"approve": "تأیید",
"unapprove": "لغو تأیید",
"approvedState": "تأییدشده",
"hide": "پنهان کردن",
"unhide": "آشکار کردن",
"hiddenState": "پنهان",
@@ -818,8 +810,6 @@
"moveDown": "جابجایی به پایین",
"dragHandle": "برای تغییر ترتیب بکشید (موقعیت {{index}})",
"failedReorder": "تغییر ترتیب ناموفق بود",
"toastApproved": "برای صفحه اصلی تأیید شد",
"toastUnapproved": "از صفحه اصلی حذف شد",
"toastHidden": "پنهان شد",
"toastUnhidden": "آشکار شد",
"toastFeatured": "ویژه شد",
+1 -11
View File
@@ -1174,17 +1174,12 @@
"exploreCampaigns": "Explorer les campagnes",
"featured": "Mis en avant",
"featuredDesc": "Campagnes sélectionnées par l'équipe de {{appName}}.",
"community": "Campagnes communautaires",
"communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.",
"browseAll": "Parcourir toutes les campagnes →",
"pending": "En attente d'approbation",
"pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.",
"pendingEmpty": "Rien en attente d'examen.",
"hidden": "Masquées",
"hiddenDesc": "Campagnes supprimées de la page d'accueil publique. Utilisez le menu en kebab d'une carte pour les démasquer.",
"hiddenEmpty": "Aucune campagne n'est actuellement masquée.",
"yourCampaigns": "Vos campagnes",
"yourCampaignsDesc": "Vos campagnes sont en ligne sur Nostr et les dons fonctionnent via le lien de la campagne. Elles apparaissent sur la page d'accueil dès qu'un modérateur de Team Soapbox les approuve.",
"yourCampaignsDesc": "Vos campagnes sont en ligne sur Nostr et les dons fonctionnent via le lien de la campagne. Parcourez toutes les campagnes sur /campaigns ; l'équipe de {{appName}} met en avant une sélection sur la page d'accueil.",
"empty": "Aucune campagne pour l'instant",
"emptyHint": "Soyez le premier à démarrer une collecte de fonds sur {{appName}}. Racontez votre histoire, choisissez vos bénéficiaires et partagez le lien.",
"searchPlaceholder": "Rechercher des campagnes…",
@@ -1228,9 +1223,6 @@
"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",
"hide": "Masquer",
"unhide": "Démasquer",
"hiddenState": "Masquée",
@@ -1241,8 +1233,6 @@
"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",
+1 -11
View File
@@ -1184,17 +1184,12 @@
"exploreCampaigns": "कैंपेन देखें",
"featured": "फ़ीचर्ड",
"featuredDesc": "{{appName}} टीम द्वारा चुने गए कैंपेन।",
"community": "कम्युनिटी कैंपेन",
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
"browseAll": "सभी कैंपेन देखें →",
"pending": "मंज़ूरी का इंतज़ार",
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक होमपेज से दबाए गए कैंपेन। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई कैंपेन छुपा हुआ नहीं है।",
"yourCampaigns": "आपके कैंपेन",
"yourCampaignsDesc": "आपके कैंपेन Nostr पर लाइव हैं और डोनेशन कैंपेन लिंक से काम करते हैं। ये होमपेज पर तब दिखेंगे जब Team Soapbox का कोई मॉडरेटर इन्हें मंज़ूरी देगा।",
"yourCampaignsDesc": "आपके कैंपेन Nostr पर लाइव हैं और डोनेशन कैंपेन लिंक से काम करते हैं। सभी कैंपेन /campaigns पर देखें; {{appName}} टीम होमपेज पर चुनिंदा कैंपेन फ़ीचर करती है।",
"empty": "अभी कोई कैंपेन नहीं",
"emptyHint": "{{appName}} पर फंडरेज़र शुरू करने वाले पहले बनें। अपनी कहानी बताएँ, लाभार्थी चुनें, और लिंक शेयर करें।",
"searchPlaceholder": "कैंपेन खोजें…",
@@ -1237,17 +1232,12 @@
"ariaPledge": "प्लेज मॉडरेट करें",
"ariaGroup": "ग्रुप मॉडरेट करें",
"failedAction": "{{action}} नहीं हो सका",
"approve": "मंज़ूरी दें",
"unapprove": "मंज़ूरी हटाएँ",
"approvedState": "मंज़ूर",
"hide": "छुपाएँ",
"unhide": "अनहाइड करें",
"hiddenState": "छुपा हुआ",
"feature": "फ़ीचर करें",
"unfeature": "फ़ीचर से हटाएँ",
"featuredState": "फ़ीचर्ड",
"toastApproved": "होमपेज के लिए मंज़ूरी दी गई",
"toastUnapproved": "होमपेज से हटाया गया",
"toastHidden": "छुपा दिया गया",
"toastUnhidden": "अनहाइड कर दिया गया",
"toastFeatured": "फ़ीचर कर दिया गया",
+1 -11
View File
@@ -1184,17 +1184,12 @@
"exploreCampaigns": "Jelajahi kampanye",
"featured": "Unggulan",
"featuredDesc": "Kampanye pilihan tangan dari tim {{appName}}.",
"community": "Kampanye Komunitas",
"communityDesc": "Bantu danai perubahan yang patut dilakukan.",
"browseAll": "Telusuri semua kampanye →",
"pending": "Menunggu persetujuan",
"pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.",
"pendingEmpty": "Tidak ada yang menunggu peninjauan.",
"hidden": "Tersembunyi",
"hiddenDesc": "Kampanye yang disembunyikan dari beranda publik. Gunakan menu kebab pada kartu untuk menampilkannya kembali.",
"hiddenEmpty": "Tidak ada kampanye yang sedang disembunyikan.",
"yourCampaigns": "Kampanye Anda",
"yourCampaignsDesc": "Kampanye Anda aktif di Nostr dan donasi berfungsi melalui tautan kampanye. Mereka akan muncul di beranda setelah moderator Team Soapbox menyetujuinya.",
"yourCampaignsDesc": "Kampanye Anda aktif di Nostr dan donasi berfungsi melalui tautan kampanye. Telusuri semua kampanye di /campaigns; tim {{appName}} menampilkan pilihan kurasi di beranda.",
"empty": "Belum ada kampanye",
"emptyHint": "Jadilah yang pertama memulai penggalangan dana di {{appName}}. Ceritakan kisah Anda, pilih penerima manfaat, dan bagikan tautannya.",
"searchPlaceholder": "Cari kampanye…",
@@ -1237,17 +1232,12 @@
"ariaPledge": "Moderasi ikrar",
"ariaGroup": "Moderasi grup",
"failedAction": "Gagal {{action}}",
"approve": "Setujui",
"unapprove": "Batalkan persetujuan",
"approvedState": "Disetujui",
"hide": "Sembunyikan",
"unhide": "Tampilkan kembali",
"hiddenState": "Tersembunyi",
"feature": "Unggulkan",
"unfeature": "Batalkan unggulan",
"featuredState": "Diunggulkan",
"toastApproved": "Disetujui untuk beranda",
"toastUnapproved": "Dihapus dari beranda",
"toastHidden": "Disembunyikan",
"toastUnhidden": "Ditampilkan kembali",
"toastFeatured": "Diunggulkan",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "រកមើលយុទ្ធនាការ",
"featured": "បានជ្រើសរើស",
"featuredDesc": "យុទ្ធនាការដែលជ្រើសរើសដោយក្រុម {{appName}}។",
"community": "យុទ្ធនាការសហគមន៍",
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
"pending": "កំពុងរង់ចាំការអនុម័ត",
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
"hidden": "បានលាក់",
"hiddenDesc": "យុទ្ធនាការដែលត្រូវបានដកចេញពីទំព័រដើមសាធារណៈ។ ប្រើម៉ឺនុយលើកាតដើម្បីឈប់លាក់។",
"hiddenEmpty": "បច្ចុប្បន្នគ្មានយុទ្ធនាការត្រូវបានលាក់។",
"yourCampaigns": "យុទ្ធនាការរបស់អ្នក",
"yourCampaignsDesc": "យុទ្ធនាការរបស់អ្នកនៅរស់នៅលើ Nostr ហើយការបរិច្ចាគដំណើរការតាមរយៈតំណយុទ្ធនាការ។ វានឹងបង្ហាញនៅទំព័រដើម នៅពេលដែលអ្នកសម្រសម្រួលក្រុម Soapbox អនុម័ត។",
"yourCampaignsDesc": "យុទ្ធនាការរបស់អ្នកនៅរស់នៅលើ Nostr ហើយការបរិច្ចាគដំណើរការតាមរយៈតំណយុទ្ធនាការ។ រកមើលយុទ្ធនាការទាំងអស់នៅ /campaigns; ក្រុម {{appName}} បង្ហាញការជ្រើសរើសដែលបានសម្រិតសម្រាំងនៅទំព័រដើម។",
"empty": "មិនទាន់មានយុទ្ធនាការនៅឡើយ",
"emptyHint": "ធ្វើជាមនុស្សដំបូងដែលចាប់ផ្ដើមការប្រមូលមូលនិធិនៅ {{appName}}។ ប្រាប់រឿងរបស់អ្នក ជ្រើសរើសអ្នកទទួលផល និងចែករំលែកតំណ។",
"searchPlaceholder": "ស្វែងរកយុទ្ធនាការ…",
@@ -809,17 +804,12 @@
"moveUp": "ផ្លាស់ទីឡើងលើ",
"moveDown": "ផ្លាស់ទីចុះក្រោម",
"dragHandle": "អូសដើម្បីរៀបចំឡើងវិញ (ទីតាំង {{index}})",
"approve": "អនុម័ត",
"unapprove": "ដកការអនុម័ត",
"approvedState": "បានអនុម័ត",
"hide": "លាក់",
"unhide": "ឈប់លាក់",
"hiddenState": "បានលាក់",
"feature": "លេចធ្លោ",
"unfeature": "ដកការលេចធ្លោ",
"featuredState": "បានលេចធ្លោ",
"toastApproved": "បានអនុម័តសម្រាប់ទំព័រដើម",
"toastUnapproved": "បានដកចេញពីទំព័រដើម",
"toastHidden": "បានលាក់",
"toastUnhidden": "បានឈប់លាក់",
"toastFeatured": "បានលេចធ្លោ",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "د کمپاینونو لټون",
"featured": "ځانګړي",
"featuredDesc": "د {{appName}} ټیم له خوا ټاکل شوي کمپاینونه.",
"community": "د ټولنې کمپاینونه",
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
"browseAll": "← ټول کمپاینونه وګورئ",
"pending": "د منلو په تمه",
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
"hidden": "پټ شوي",
"hiddenDesc": "هغه کمپاینونه چې د عمومي کور پاڼې څخه پټ شوي. د بېرته ښودلو لپاره د کارت مینو وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ کمپاین پټ نه دی.",
"yourCampaigns": "ستاسو کمپاینونه",
"yourCampaignsDesc": "ستاسو کمپاینونه په Nostr کې ژوندي دي او مرستې د کمپاین له لینک څخه کار کوي. کله چې د Soapbox ټیم یو مدیر یې ومني، په کور پاڼه کې به څرګند شي.",
"yourCampaignsDesc": "ستاسو کمپاینونه په Nostr کې ژوندي دي او مرستې د کمپاین له لینک څخه کار کوي. ټول کمپاینونه په /campaigns کې وګورئ؛ د {{appName}} ټیم په کور پاڼه کې یوه ټاکل شوې ټولګه ښیي.",
"empty": "تر اوسه کوم کمپاین نشته",
"emptyHint": "په {{appName}} کې د مرستو راټولولو کمپاین پیل کوونکی لومړی شئ. خپله کیسه ووایاست، ګټه اخیستونکي وټاکئ، او لینک شریک کړئ.",
"searchPlaceholder": "د کمپاینونو لټون…",
@@ -804,17 +799,12 @@
"ariaPledge": "د ژمنې څارنه",
"ariaGroup": "د ډلې څارنه",
"failedAction": "په {{action}} کې پاتې راغی",
"approve": "منل",
"unapprove": "د منلو لرې کول",
"approvedState": "منل شوی",
"hide": "پټول",
"unhide": "بېرته ښودل",
"hiddenState": "پټ شوی",
"feature": "ځانګړي کول",
"unfeature": "د ځانګړي حالت لرې کول",
"featuredState": "ځانګړی",
"toastApproved": "د کور پاڼې لپاره منل شوی",
"toastUnapproved": "د کور پاڼې څخه لرې شوی",
"toastHidden": "پټ شوی",
"toastUnhidden": "بېرته ښودل شوی",
"toastFeatured": "ځانګړی شوی",
+1 -11
View File
@@ -1184,17 +1184,12 @@
"exploreCampaigns": "Explorar campanhas",
"featured": "Em destaque",
"featuredDesc": "Campanhas selecionadas pela equipe do {{appName}}.",
"community": "Campanhas da comunidade",
"communityDesc": "Ajude a financiar as mudanças que valem a pena.",
"browseAll": "Navegar por todas as campanhas →",
"pending": "Aguardando aprovação",
"pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.",
"pendingEmpty": "Nada aguardando revisão.",
"hidden": "Ocultas",
"hiddenDesc": "Campanhas suprimidas da página inicial pública. Use o menu de três pontos em um cartão para reexibir.",
"hiddenEmpty": "Nenhuma campanha está oculta atualmente.",
"yourCampaigns": "Suas campanhas",
"yourCampaignsDesc": "Suas campanhas estão no ar no Nostr e as doações funcionam através do link da campanha. Elas aparecem na página inicial quando um moderador da Team Soapbox as aprova.",
"yourCampaignsDesc": "Suas campanhas estão no ar no Nostr e as doações funcionam através do link da campanha. Navegue por todas as campanhas em /campaigns; a equipe do {{appName}} apresenta uma seleção curada na página inicial.",
"empty": "Nenhuma campanha ainda",
"emptyHint": "Seja o primeiro a iniciar uma arrecadação no {{appName}}. Conte sua história, escolha seus beneficiários e compartilhe o link.",
"searchPlaceholder": "Pesquisar campanhas…",
@@ -1237,17 +1232,12 @@
"ariaPledge": "Moderar promessa",
"ariaGroup": "Moderar grupo",
"failedAction": "Falha ao {{action}}",
"approve": "Aprovar",
"unapprove": "Desaprovar",
"approvedState": "Aprovado",
"hide": "Ocultar",
"unhide": "Reexibir",
"hiddenState": "Oculto",
"feature": "Destacar",
"unfeature": "Remover destaque",
"featuredState": "Em destaque",
"toastApproved": "Aprovado para a página inicial",
"toastUnapproved": "Removido da página inicial",
"toastHidden": "Ocultado",
"toastUnhidden": "Reexibido",
"toastFeatured": "Destacado",
+1 -11
View File
@@ -1184,17 +1184,12 @@
"exploreCampaigns": "Исследовать кампании",
"featured": "Избранные",
"featuredDesc": "Кампании, отобранные командой {{appName}}.",
"community": "Кампании сообщества",
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
"browseAll": "Просмотреть все кампании →",
"pending": "Ожидают одобрения",
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
"pendingEmpty": "Ничего не ждёт проверки.",
"hidden": "Скрытые",
"hiddenDesc": "Кампании, скрытые с публичной главной страницы. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых кампаний.",
"yourCampaigns": "Ваши кампании",
"yourCampaignsDesc": "Ваши кампании в эфире в Nostr, и пожертвования работают через ссылку кампании. Они появляются на главной странице, когда модератор Team Soapbox их одобряет.",
"yourCampaignsDesc": "Ваши кампании уже в эфире в Nostr, и пожертвования работают через ссылку кампании. Просмотрите все кампании на /campaigns; команда {{appName}} выделяет отобранную подборку на главной странице.",
"empty": "Пока нет кампаний",
"emptyHint": "Будьте первым, кто запустит сбор средств на {{appName}}. Расскажите свою историю, выберите бенефициаров и поделитесь ссылкой.",
"searchPlaceholder": "Поиск кампаний…",
@@ -1238,9 +1233,6 @@
"ariaGroup": "Модерировать группу",
"failedAction": "Не удалось выполнить действие: {{action}}",
"failedReorder": "Не удалось изменить порядок",
"approve": "Одобрить",
"unapprove": "Отозвать одобрение",
"approvedState": "Одобрено",
"hide": "Скрыть",
"unhide": "Показать",
"hiddenState": "Скрыто",
@@ -1251,8 +1243,6 @@
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
"dragHandle": "Перетащите для изменения порядка (позиция {{index}})",
"toastApproved": "Одобрено для главной страницы",
"toastUnapproved": "Удалено с главной страницы",
"toastHidden": "Скрыто",
"toastUnhidden": "Показано",
"toastFeatured": "Добавлено в избранное",
+5 -15
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "Tarisa mishandirapamwe",
"featured": "Yakasarudzwa",
"featuredDesc": "Mishandirapamwe yakasarudzwa neboka re{{appName}}.",
"community": "Mishandirapamwe yeNharaunda",
"communityDesc": "Batsira kupa mari kushanduko dzakakodzera.",
"browseAll": "Tarisa mishandirapamwe yose →",
"pending": "Yakamirira kutenderwa",
"pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.",
"pendingEmpty": "Hapana chinomirira kutariswa.",
"hidden": "Yakavanzwa",
"hiddenDesc": "Mishandirapamwe yakabviswa papeji rekutanga reveruzhinji. Shandisa menu pakadhi kuti uibvise pakuvanzwa.",
"hiddenEmpty": "Parizvino hapana mushandirapamwe wakavanzwa.",
"yourCampaigns": "Mishandirapamwe yako",
"yourCampaignsDesc": "Mishandirapamwe yako iri panetwork yeNostr uye mipiro inoshanda kuburikidza nemurawu wemushandirapamwe. Inoonekwa papeji rekutanga kana muoni weTeam Soapbox aitenderera.",
"yourCampaignsDesc": "Mishandirapamwe yako iri panetwork yeNostr uye mipiro inoshanda kuburikidza nemurawu wemushandirapamwe. Tarisa mishandirapamwe yose pa/campaigns; boka re{{appName}} rinosarudza yakananga papeji rekutanga.",
"empty": "Hapana mishandirapamwe parizvino",
"emptyHint": "Iva wekutanga kutanga kuunganidza mari pa{{appName}}. Taura nyaya yako, sarudza vanobatsirwa, uchipa rwumwe rwekugovera.",
"searchPlaceholder": "Tsvaga mishandirapamwe…",
@@ -803,19 +798,14 @@
"ariaCampaign": "Tarisa mushandirapamwe",
"ariaPledge": "Tarisa chitsidziro",
"ariaGroup": "Tarisa boka",
"failedAction": "Hazvina kubudirira ku{{action}}",
"approve": "Tendera",
"unapprove": "Bvisa kutenderwa",
"approvedState": "Zvakatenderwa",
"hide": "Vanza",
"failedAction": "Hazvina kubudirira ku{{action}}",
"hide": "Vanza",
"unhide": "Bvisa kuvanzwa",
"hiddenState": "Zvakavanzwa",
"feature": "Sarudza",
"unfeature": "Bvisa kusarudzwa",
"featuredState": "Zvakasarudzwa",
"toastApproved": "Zvatenderwa kupeji rekutanga",
"toastUnapproved": "Zvabviswa papeji rekutanga",
"toastHidden": "Zvavanzwa",
"featuredState": "Zvakasarudzwa",
"toastHidden": "Zvavanzwa",
"toastUnhidden": "Zvabviswa pakuvanzwa",
"toastFeatured": "Zvasarudzwa",
"toastUnfeatured": "Zvabviswa pakusarudzwa",
+1 -11
View File
@@ -1183,17 +1183,12 @@
"exploreCampaigns": "Chunguza kampeni",
"featured": "Maarufu",
"featuredDesc": "Kampeni zilizochaguliwa kwa mkono kutoka kwa timu ya {{appName}}.",
"community": "Kampeni za Jumuiya",
"communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.",
"browseAll": "Vinjari kampeni zote →",
"pending": "Inasubiri idhini",
"pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.",
"pendingEmpty": "Hakuna kinachosubiri ukaguzi.",
"hidden": "Vilivyofichwa",
"hiddenDesc": "Kampeni zilizofichwa kutoka kwenye ukurasa wa mwanzo wa umma. Tumia menyu ya nukta tatu kwenye kadi ili kuonyesha.",
"hiddenEmpty": "Hakuna kampeni zilizofichwa kwa sasa.",
"yourCampaigns": "Kampeni zako",
"yourCampaignsDesc": "Kampeni zako ziko mtandaoni kwenye Nostr na michango hufanya kazi kupitia kiungo cha kampeni. Zinaonekana kwenye ukurasa wa mwanzo mara tu msimamizi wa Team Soapbox anapozithibitisha.",
"yourCampaignsDesc": "Kampeni zako ziko mtandaoni kwenye Nostr na michango hufanya kazi kupitia kiungo cha kampeni. Vinjari kampeni zote kwenye /campaigns; timu ya {{appName}} huangazia uteuzi maalum kwenye ukurasa wa mwanzo.",
"empty": "Hakuna kampeni bado",
"emptyHint": "Kuwa wa kwanza kuanza kampeni ya kukusanya fedha kwenye {{appName}}. Eleza hadithi yako, chagua walengwa wako, na shiriki kiungo.",
"searchPlaceholder": "Tafuta kampeni…",
@@ -1236,17 +1231,12 @@
"ariaPledge": "Simamia ahadi",
"ariaGroup": "Simamia kikundi",
"failedAction": "Imeshindikana ku-{{action}}",
"approve": "Idhinisha",
"unapprove": "Ondoa idhini",
"approvedState": "Imeidhinishwa",
"hide": "Ficha",
"unhide": "Onyesha",
"hiddenState": "Imefichwa",
"feature": "Angazia",
"unfeature": "Ondoa kwenye maarufu",
"featuredState": "Imeangaziwa",
"toastApproved": "Imeidhinishwa kwa ukurasa wa mwanzo",
"toastUnapproved": "Imeondolewa kwenye ukurasa wa mwanzo",
"toastHidden": "Imefichwa",
"toastUnhidden": "Imeonyeshwa",
"toastFeatured": "Imeangaziwa",
+1 -11
View File
@@ -1183,17 +1183,12 @@
"exploreCampaigns": "Kampanyaları keşfet",
"featured": "Öne çıkanlar",
"featuredDesc": "{{appName}} ekibi tarafından özenle seçilmiş kampanyalar.",
"community": "Topluluk Kampanyaları",
"communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.",
"browseAll": "Tüm kampanyalara göz at →",
"pending": "Onay bekliyor",
"pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.",
"pendingEmpty": "İncelemeyi bekleyen bir şey yok.",
"hidden": "Gizli",
"hiddenDesc": "Herkese açık ana sayfadan gizlenmiş kampanyalar. Gizlemeyi kaldırmak için karttaki kebap menüsünü kullanın.",
"hiddenEmpty": "Şu anda gizlenmiş kampanya yok.",
"yourCampaigns": "Kampanyalarınız",
"yourCampaignsDesc": "Kampanyalarınız Nostr'da yayında ve bağışlar kampanya bağlantısıyla çalışır. Bir Team Soapbox moderatörü onayladığında ana sayfada görünürler.",
"yourCampaignsDesc": "Kampanyalarınız Nostr'da yayında ve bağışlar kampanya bağlantısıyla çalışır. Tüm kampanyalara /campaigns adresinden göz atın; {{appName}} ekibi ana sayfada özenle seçilmiş bir derlemeyi öne çıkarır.",
"empty": "Henüz kampanya yok",
"emptyHint": "{{appName}}'da fon toplama kampanyası başlatan ilk kişi olun. Hikayenizi anlatın, yararlanıcılarınızı seçin ve bağlantıyı paylaşın.",
"searchPlaceholder": "Kampanya ara…",
@@ -1241,17 +1236,12 @@
"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ı",
"hide": "Gizle",
"unhide": "Gizlemeyi kaldır",
"hiddenState": "Gizli",
"feature": "Öne çıkar",
"unfeature": "Öne çıkarmayı kaldır",
"featuredState": "Öne çıkarıldı",
"toastApproved": "Ana sayfa için onaylandı",
"toastUnapproved": "Ana sayfadan kaldırıldı",
"toastHidden": "Gizlendi",
"toastUnhidden": "Gizleme kaldırıldı",
"toastFeatured": "Öne çıkarıldı",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "瀏覽活動",
"featured": "精選",
"featuredDesc": "由 {{appName}} 團隊精心挑選的活動。",
"community": "社群活動",
"communityDesc": "為值得做的改變提供資金。",
"browseAll": "瀏覽所有活動 →",
"pending": "等待審批",
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
"pendingEmpty": "沒有等待審查的內容。",
"hidden": "已隱藏",
"hiddenDesc": "已從公共首頁隱藏的活動。使用卡片上的選單可以取消隱藏。",
"hiddenEmpty": "當前沒有被隱藏的活動。",
"yourCampaigns": "你的活動",
"yourCampaignsDesc": "你的活動已在 Nostr 上線,通過活動連結可以接收捐款。一旦 Soapbox 團隊版主批准,它們將出現在首頁。",
"yourCampaignsDesc": "你的活動已在 Nostr 上線,並可透過活動連結接收捐款。前往 /campaigns 瀏覽所有活動;{{appName}} 團隊會在首頁精選展示其中一部分。",
"empty": "暫無活動",
"emptyHint": "成為在 {{appName}} 發起眾籌的第一人。講述你的故事、選擇受益人、並分享連結。",
"searchPlaceholder": "搜尋活動…",
@@ -804,17 +799,12 @@
"ariaPledge": "管理懸賞",
"ariaGroup": "管理群組",
"failedAction": "無法{{action}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"hide": "隱藏",
"unhide": "取消隱藏",
"hiddenState": "已隱藏",
"feature": "推薦精選",
"unfeature": "取消精選",
"featuredState": "已精選",
"toastApproved": "已批准顯示於首頁",
"toastUnapproved": "已自首頁移除",
"toastHidden": "已隱藏",
"toastUnhidden": "已取消隱藏",
"toastFeatured": "已加入精選",
+1 -11
View File
@@ -752,17 +752,12 @@
"exploreCampaigns": "浏览活动",
"featured": "精选",
"featuredDesc": "由 {{appName}} 团队精心挑选的活动。",
"community": "社区活动",
"communityDesc": "为值得做的改变提供资金。",
"browseAll": "浏览所有活动 →",
"pending": "等待审批",
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
"pendingEmpty": "没有等待审查的内容。",
"hidden": "已隐藏",
"hiddenDesc": "已从公共首页隐藏的活动。使用卡片上的菜单可以取消隐藏。",
"hiddenEmpty": "当前没有被隐藏的活动。",
"yourCampaigns": "你的活动",
"yourCampaignsDesc": "你的活动已在 Nostr 上线,通过活动链接可接收捐款。一旦 Soapbox 团队版主批准,它们将出现在首页。",
"yourCampaignsDesc": "你的活动已在 Nostr 上线,通过活动链接可接收捐款。在 /campaigns 浏览所有活动;{{appName}} 团队会在首页展示精选活动。",
"empty": "暂无活动",
"emptyHint": "成为在 {{appName}} 发起众筹的第一人。讲述你的故事、选择受益人、并分享链接。",
"searchPlaceholder": "搜索活动…",
@@ -809,17 +804,12 @@
"moveUp": "上移",
"moveDown": "下移",
"dragHandle": "拖动以重新排序(位置 {{index}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"hide": "隐藏",
"unhide": "取消隐藏",
"hiddenState": "已隐藏",
"feature": "精选",
"unfeature": "取消精选",
"featuredState": "已精选",
"toastApproved": "已批准至首页",
"toastUnapproved": "已从首页移除",
"toastHidden": "已隐藏",
"toastUnhidden": "已取消隐藏",
"toastFeatured": "已精选",
+101 -204
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle } from 'lucide-react';
import { ArrowRight, EyeOff, HandHeart, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -28,28 +28,29 @@ const FEATURED_SKELETON_CAP = 8;
/**
* Home page (`/`).
*
* Four sections, top-to-bottom:
* Two public sections plus one moderator-only section:
*
* 1. **Featured** — moderator-curated, sorted newest-featured first.
* No cap — moderators can feature any number of campaigns and the
* grid expands. Visible to everyone.
* 2. **Community Campaigns** — every campaign approved by a Team
* Soapbox moderator, minus hidden and minus featured (featured
* dedupes into the row above). Sorted newest first. Visible to
* everyone.
* 3. **Pending** — campaigns on the network that no moderator has
* approved or hidden yet. Moderator-only review queue.
* 4. **Hidden** — campaigns currently suppressed. Moderator-only,
* collapsed by default so the page doesn't lead with suppressed
* content for the people responsible for it.
* 1. **Featured** — moderator-curated, ordered by the moderator's
* chosen rank (newest-by-rank first). No cap. Visible to
* everyone. The empty state replaces the section when no
* campaign is currently featured.
* 2. **Browse all** — a single link to `/campaigns`, the full
* discoverable set with search / sort / country filters.
* 3. **Hidden** — moderator-only, collapsed by default so the page
* doesn't lead with suppressed content for the people
* responsible for it.
*
* Campaign coverage is the failure mode to watch: an approved
* campaign that's older than the last 200 events would otherwise
* fall off the recent-stream query and disappear from the Community
* grid. We mitigate by issuing a second targeted query keyed on
* every approved/hidden coord, merging both result sets in the
* grids below. The Featured row already used a coord-targeted query
* for the same reason.
* The previous Community / Pending sections were retired alongside
* the approval axis: featuring is now the single positive-curation
* mechanism, and `/campaigns` is the censorship-resistant browse
* surface.
*
* Hidden-campaign coverage: a hidden campaign older than the recent
* stream window would drop off a `limit:` query, so we issue a
* targeted coord-keyed fetch over every hidden coord and feed the
* Hidden section from the union of that query and the recent
* stream. The Featured row uses the same coord-targeted pattern
* keyed on its own coords.
*
* Campaigns are the home page's sole focus. Groups and Pledges each
* have their own dedicated browse pages (`/groups`, `/pledges`).
@@ -59,16 +60,17 @@ export function CampaignsPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
// Moderation pack + label rollups. We gate the four sections on
// `moderationReady` so we never flash an unmoderated grid.
// Moderation pack + label rollups. We gate the Featured and Hidden
// sections on `moderationReady` so we never flash an unmoderated
// grid.
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// Featured slot list — derived from moderation labels. Sorted newest-
// featured first; hidden coords removed so a featured-then-hidden
// campaign disappears from the row. No cap: every campaign a
// moderator features renders.
// Featured slot list — derived from moderation labels. Sorted by
// the moderator-controlled rank (descending); hidden coords
// removed so a featured-then-hidden campaign disappears from the
// row. No cap: every campaign a moderator features renders.
const featuredCoords = useMemo(() => {
if (!moderation) return [] as string[];
return Array.from(moderation.featuredCoords)
@@ -84,9 +86,9 @@ export function CampaignsPage() {
: { coordinates: [] },
);
// Sort the fetched featured campaigns to match the newest-label order.
// `useCampaigns` returns them in network order; we want the row to match
// the moderation-label ordering.
// Sort the fetched featured campaigns to match the rank order.
// `useCampaigns` returns them in network order; we want the row to
// match the moderation-rank ordering.
const orderedFeatured = useMemo<ParsedCampaign[]>(() => {
if (!moderation || !featuredCampaigns) return [];
const order = moderation.featuredOrder;
@@ -95,33 +97,25 @@ export function CampaignsPage() {
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0));
}, [featuredCampaigns, featuredCoords, moderation]);
const featuredCoordSet = useMemo(() => new Set(featuredCoords), [featuredCoords]);
// Recent stream — the latest 200 campaign events on the network. Drives
// the Pending list (the only way a not-yet-labeled campaign can surface
// is to be in the recent stream), and supplements the targeted approved
// query below for fresh data on already-approved campaigns.
// Recent stream — the latest 200 campaign events on the network.
// Source for the Hidden section (which can also pull from the
// targeted hidden-coord query below).
const { data: recentCampaigns, isLoading: recentLoading } = useCampaigns({
limit: 200,
});
// Targeted query for every approved-or-hidden coord. This guarantees the
// Community grid and the Hidden section render correctly even when the
// approved/hidden campaign is older than the recent-200 window — the
// exact bug that made approved campaigns silently disappear from the
// home page once enough new campaigns published.
const labeledCoords = useMemo(() => {
// Targeted query for every hidden coord. Guarantees the Hidden
// section renders correctly even when the hidden campaign is
// older than the recent-200 window.
const hiddenCoordList = useMemo(() => {
if (!moderation) return [] as string[];
const out = new Set<string>();
for (const c of moderation.approvedCoords) out.add(c);
for (const c of moderation.hiddenCoords) out.add(c);
return Array.from(out);
return Array.from(moderation.hiddenCoords);
}, [moderation]);
const { data: labeledCampaigns, isLoading: labeledLoading } = useCampaigns(
moderationReady && labeledCoords.length > 0
? { coordinates: labeledCoords, limit: labeledCoords.length }
: { coordinates: [], limit: 1 },
const { data: hiddenCampaignsRaw, isLoading: hiddenLoading } = useCampaigns(
moderationReady && hiddenCoordList.length > 0
? { coordinates: hiddenCoordList }
: { coordinates: [] },
);
useSeoMeta({
@@ -129,184 +123,89 @@ export function CampaignsPage() {
description: t('campaigns.home.seoDescription'),
});
// Merge the two streams (recent + labeled), de-dupe by aTag, sort newest
// first. The result is the authoritative working set: every campaign
// the page needs to render across all four sections.
const allKnownCampaigns = useMemo(() => {
// Hidden section: union of the recent stream and the targeted
// query, deduped by aTag, filtered to coords currently labeled
// hidden. Newest-first for stable ordering.
const hiddenCampaigns = useMemo<ParsedCampaign[]>(() => {
if (!moderation) return [];
const byCoord = new Map<string, ParsedCampaign>();
for (const c of recentCampaigns ?? []) byCoord.set(c.aTag, c);
for (const c of labeledCampaigns ?? []) {
// Prefer whichever revision is newer — the recent stream and the
// targeted query can return different revisions of the same
// addressable event from different relays.
for (const c of hiddenCampaignsRaw ?? []) {
const prev = byCoord.get(c.aTag);
if (!prev || c.createdAt > prev.createdAt) byCoord.set(c.aTag, c);
}
return Array.from(byCoord.values()).sort((a, b) => b.createdAt - a.createdAt);
}, [recentCampaigns, labeledCampaigns]);
return Array.from(byCoord.values())
.filter((c) => moderation.hiddenCoords.has(c.aTag))
.sort((a, b) => b.createdAt - a.createdAt);
}, [recentCampaigns, hiddenCampaignsRaw, moderation]);
// 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 [];
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]);
const featuredEmpty =
moderationReady && featuredCoords.length === 0 && !featuredLoading;
// Pending: not approved, not hidden. Featured-but-unapproved is treated
// as pending too — a moderator can feature without explicitly approving
// and the queue still needs to surface the approval decision.
const pendingCampaigns = useMemo<ParsedCampaign[]>(() => {
if (!moderation) return [];
return allKnownCampaigns.filter(
(c) =>
!moderation.approvedCoords.has(c.aTag) &&
!moderation.hiddenCoords.has(c.aTag),
);
}, [allKnownCampaigns, moderation]);
// Hidden: latest label on the hide axis is `hidden`. Independent of
// approval status — hide always wins.
const hiddenCampaigns = useMemo<ParsedCampaign[]>(() => {
if (!moderation) return [];
return allKnownCampaigns.filter((c) => moderation.hiddenCoords.has(c.aTag));
}, [allKnownCampaigns, moderation]);
// The grids share the same readiness gate: moderation labels resolved
// AND at least one of the two campaign queries returned. We don't wait
// for both because each can fail or take a while; whichever arrives
// first starts populating the page.
const gridsLoading =
moderatorsLoading || !moderationReady || (recentLoading && labeledLoading);
// Show the Featured section as long as there's something to show
// OR we're still loading the moderation labels on first paint
// (avoids a flash of the empty state for first-time visitors).
const showFeaturedSection =
featuredCoords.length > 0 ||
(!moderationReady && (moderatorsLoading || featuredLoading)) ||
featuredEmpty;
return (
<main className="min-h-screen pb-16">
<Hero loggedIn={!!user} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
{/* Featured — only rendered when at least one campaign is featured
(or the featured query is still loading on first paint). */}
{(featuredCoords.length > 0 || (featuredLoading && !moderationReady)) && (
{showFeaturedSection && (
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.featured')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.featuredDesc', { appName: config.appName })}
</p>
</div>
<Button asChild variant="outline" className="hidden sm:inline-flex">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</div>
<FeaturedRow
campaigns={orderedFeatured}
isLoading={featuredLoading || !moderationReady}
expectedCount={featuredCoords.length}
/>
{featuredEmpty ? (
<EmptyState />
) : (
<FeaturedRow
campaigns={orderedFeatured}
isLoading={featuredLoading || !moderationReady}
expectedCount={featuredCoords.length}
/>
)}
{/* "Browse all campaigns" link — the gateway to the full,
censorship-resistant set on /campaigns. Kept inside
the Featured section so the home page hierarchy is
Featured → Browse all → (mod-only) Hidden. */}
<div className="pt-2 text-center sm:text-left">
<Button asChild variant="ghost" size="sm">
<Link to="/campaigns">{t('campaigns.home.browseAll')}</Link>
</Button>
</div>
</section>
)}
{/* Community Campaigns — moderator-approved, minus hidden, minus
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.
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>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.community')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('campaigns.home.communityDesc')}
</p>
</div>
<Button asChild variant="outline" className="hidden sm:inline-flex">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.home.startCampaign')}
</Link>
</Button>
</div>
{gridsLoading ? (
<CampaignGridSkeleton />
) : communityCampaigns.length === 0 ? (
<EmptyState />
) : (
<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,
sort, country filters, and the censorship-resistant view
of campaigns that haven't been approved or that mods have
hidden. */}
<div className="pt-2 text-center sm:text-left">
<Button asChild variant="ghost" size="sm">
<Link to="/campaigns">{t('campaigns.home.browseAll')}</Link>
</Button>
</div>
</section>
{/* Pending — moderator-only review queue. Campaigns the recent
stream reported but that have no approval AND no hide label.
Mods need to triage these so they show up on the public
grid (or get filtered out). */}
{isMod && (
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('campaigns.home.pending')}
description={t('campaigns.home.pendingDesc')}
count={pendingCampaigns.length}
isLoading={recentLoading && pendingCampaigns.length === 0}
emptyText={t('campaigns.home.pendingEmpty')}
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{pendingCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Hidden — moderator-only, collapsed by default. The Campaigns
page is where everyone can transparently flip a switch to
view hidden campaigns; on the home page mods get a more
structured collapsible review surface, kept closed so the
page doesn't lead with suppressed content. */}
{/* Hidden — moderator-only, collapsed by default. The
Campaigns page is where everyone can transparently flip
a switch to view hidden campaigns; on the home page mods
get a more structured collapsible review surface, kept
closed so the page doesn't lead with suppressed content. */}
{isMod && (
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
isLoading={labeledLoading && hiddenCampaigns.length === 0}
isLoading={(recentLoading || hiddenLoading) && hiddenCampaigns.length === 0}
emptyText={t('campaigns.home.hiddenEmpty')}
skeleton={<CampaignGridSkeleton />}
defaultOpen={false}
@@ -482,24 +381,22 @@ function FeaturedRow({
}
if (campaigns.length === 0) {
// Defensive — the parent guards on `featuredCoords.length > 0`, but if
// a hidden-after-featured race leaves us with no campaigns to render,
// collapse silently rather than show an empty card.
// Defensive — caller decides whether to render this component
// when there are no campaigns; if we get here regardless, fail
// quiet rather than show an empty row.
return null;
}
// 1 featured campaign gets the hero `variant="featured"` treatment;
// 2-4 use the regular compact card sized to the dynamic grid.
// 2+ 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.
// branches internally).
return (
<ReorderableCampaignGrid
campaigns={campaigns}
axis="featured"
gridClassName={featuredGridClass(campaigns.length)}
renderCard={(campaign) => (
<CampaignCard