Merge branch 'main' of gitlab.com:soapbox-pub/agora

This commit is contained in:
Alex Gleason
2026-05-25 15:19:01 -05:00
73 changed files with 4720 additions and 1869 deletions
+35 -12
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, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
| 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). |
| 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
@@ -498,14 +498,15 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Agora Moderation Labels
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label:
Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
- `36639:<author-pubkey>:<d>` — pledge (kind 36639, see "Pledge" below).
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa.
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. A client surfacing pledges MUST filter to `36639:`. Mixing the streams would let a moderator's `featured` label on a campaign appear to feature an unrelated pledge with the same `d` tag, or any other cross-surface bleed.
#### Namespace
@@ -520,13 +521,13 @@ 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** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive.
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.
| 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` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
| 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. |
Surfacing rules (hide always wins):
@@ -546,6 +547,13 @@ Surfacing rules (hide always wins):
- **Moderator-only "Hidden"** — iff hidden.
- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
**Pledges**
- **Discovery surfaces on `/pledges`** — non-moderators MUST NOT see `hidden` pledges in the active / upcoming / past sections, the search results grid, or any future browse surface. Moderators MAY opt-in to seeing hidden pledges via a Show-hidden toggle so they can unhide.
- **Author-own surfaces** — a pledge author's own pledges in their profile always render regardless of moderation state. Moderation governs public discovery, not authorship.
- **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.
#### Event Structure
```json
@@ -576,12 +584,27 @@ An organization label has the same shape with a kind 34550 `a` tag:
}
```
A pledge label has the same shape with a kind 36639 `a` tag:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "hidden", "agora.moderation"],
["a", "36639:<author-pubkey>:<pledge-d-tag>"],
["alt", "Pledge moderation: hidden"]
]
}
```
Required tags:
- `L` set to `agora.moderation`.
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it.
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
#### Trust Model
+201
View File
@@ -0,0 +1,201 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { parseAction } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign';
import { parseCommunityEvent } from '@/lib/communityUtils';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { cn } from '@/lib/utils';
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
function InlineShell({
image,
fallbackIcon,
title,
description,
meta,
}: {
image?: string;
fallbackIcon: ReactNode;
title: string;
description?: string;
meta?: ReactNode;
}) {
return (
<div className="mt-3 space-y-3">
<div className="overflow-hidden rounded-xl bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
{image ? (
<img src={image} alt="" loading="lazy" className="aspect-[16/7] w-full object-cover" />
) : (
<div className="flex aspect-[16/7] items-center justify-center text-primary/45">
{fallbackIcon}
</div>
)}
</div>
<div className="space-y-2">
<h3 className="text-base font-bold leading-tight tracking-tight line-clamp-2">{title}</h3>
{description?.trim() ? (
<p className="text-sm leading-relaxed text-muted-foreground line-clamp-3">{description}</p>
) : null}
{meta}
</div>
</div>
);
}
export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
const campaign = parseCampaign(event);
const { data: btcPrice } = useBtcPrice();
const { data: stats } = useCampaignDonations(campaign ?? undefined);
if (!campaign) return null;
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
const countryLabel = getCampaignCountryLabel(campaign);
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
const isSilentPayment = !campaign.wallets.onchain;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
const raisedSats = stats?.totalSats ?? 0;
const raisedLabel = isSilentPayment ? undefined : formatCampaignAmount(raisedSats, btcPrice);
const raisedUsd = isSilentPayment ? undefined : satsToUsd(raisedSats, btcPrice);
const progress = campaign.goalUsd && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / campaign.goalUsd) * 100))
: 0;
return (
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<InlineShell
image={campaign.banner}
fallbackIcon={<HandHeart className="size-12" />}
title={campaign.title}
description={campaign.summary || campaign.story}
meta={(
<div className="space-y-2 pt-1">
{campaign.goalUsd && !isSilentPayment ? (
<div className="h-1.5 overflow-hidden rounded-full bg-foreground/15">
<div className="h-full rounded-full bg-primary" style={{ width: `${progress}%` }} />
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
{isSilentPayment ? (
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
{goalLabel ?? 'Private campaign'}
</span>
) : (
<span className="font-semibold text-foreground">
{raisedLabel}<span className="font-normal text-muted-foreground"> {goalLabel ? `/ ${goalLabel}` : 'raised'}</span>
</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
</div>
)}
/>
</Link>
);
}
export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const pledge = parseAction(event);
const { data: btcPrice } = useBtcPrice();
if (!pledge) return null;
const naddr = nip19.naddrEncode({ kind: 36639, pubkey: pledge.pubkey, identifier: pledge.id });
const countryLabel = pledge.countryCode ? getGeoDisplayName(pledge.countryCode) : undefined;
const deadline = pledge.deadline ? formatCompactPledgeDeadline(pledge.deadline) : undefined;
return (
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<InlineShell
image={pledge.image}
fallbackIcon={<Megaphone className="size-12" />}
title={pledge.title}
description={pledge.description}
meta={(
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
<span className="inline-flex items-baseline gap-1.5">
<span className="font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</span>
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty, btcPrice)}</span>
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
)}
/>
</Link>
);
}
export function GroupInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const group = parseCommunityEvent(event);
if (!group) return null;
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: group.dTag });
const countryLabel = group.countryCode ? getGeoDisplayName(group.countryCode) : undefined;
return (
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<InlineShell
image={group.image}
fallbackIcon={<Users className="size-12" />}
title={group.name}
description={group.description}
meta={(
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Users className="size-3.5" />
{t('groups.create.moderatorsCount', { count: group.moderatorPubkeys.length })}
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
</div>
)}
/>
</Link>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { Trans } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/**
* Unified author display used by every card and detail page that
* surfaces the human behind an event. Standardizes:
*
* - Avatar component (shadcn `Avatar` + initials fallback).
* - Display-name resolution via `getDisplayName` (display_name → name → "Anonymous").
* - Link target via `useProfileUrl` (nip05 path when verified, npub otherwise).
* - i18n: the "by {name}" label uses the shared `common.byAuthor`
* key so every surface ships the same translated string in every
* locale.
* - Click semantics inside a card: when `insideLink` is true the
* byline renders as a `<button>` that calls `navigate()` and stops
* propagation, so an outer `<Link>` keeps wrapping the whole card
* without nesting `<a>` inside `<a>` (invalid HTML, React Router
* warns).
*
* Two visual variants:
*
* - `card` (default): muted text, 20px avatar, sized for the
* bottom row of a feed card.
* - `hero`: white text with drop-shadow, 32px avatar with a soft
* ring; for use on top of dark scrims in detail-page heroes.
*/
interface AuthorBylineProps {
pubkey: string;
/** Visual variant. `card` is the small inline footer style; `hero` is large white-on-dark. */
variant?: 'card' | 'hero';
/**
* True when this byline is rendered inside another `<Link>` (cards
* wrap themselves in one). Renders the byline as a `<button>` that
* navigates and stops propagation, avoiding nested anchors.
*/
insideLink?: boolean;
className?: string;
}
export function AuthorByline({
pubkey,
variant = 'card',
insideLink = false,
className,
}: AuthorBylineProps) {
const navigate = useNavigate();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
const picture = sanitizeUrl(metadata?.picture);
const initials = displayName.slice(0, 2).toUpperCase();
const isHero = variant === 'hero';
const wrapperClass = cn(
'inline-flex items-center gap-2 min-w-0 text-left group/byline',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full',
'motion-safe:transition-colors',
isHero
? 'text-white/90 hover:text-white text-sm sm:text-base gap-2.5'
: 'text-xs text-muted-foreground hover:text-foreground',
className,
);
const avatarClass = cn(
isHero ? 'size-8 sm:size-9 ring-2 ring-white/30 shrink-0' : 'size-5 shrink-0',
);
const fallbackClass = cn(
'text-[10px]',
isHero ? 'bg-white/15 text-white text-xs' : 'bg-secondary text-secondary-foreground',
);
const labelClass = cn(
'truncate min-w-0',
isHero && '[text-shadow:0_1px_3px_rgba(0,0,0,0.7)]',
);
const nameClass = cn(
isHero
? 'font-semibold underline-offset-4 group-hover/byline:underline'
: 'font-medium text-foreground',
);
const inner = (
<>
<Avatar className={avatarClass}>
{picture && <AvatarImage src={picture} alt="" />}
<AvatarFallback className={fallbackClass}>{initials}</AvatarFallback>
</Avatar>
<span className={labelClass}>
<Trans
i18nKey="common.byAuthor"
values={{ name: displayName }}
components={{ 0: <span className={nameClass} /> }}
/>
</span>
</>
);
if (insideLink) {
return (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(profileUrl);
}}
className={wrapperClass}
aria-label={displayName}
>
{inner}
</button>
);
}
return (
<a
href={profileUrl}
onClick={(e) => {
// Use SPA navigation; preserve modifier-clicks for new tabs.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
navigate(profileUrl);
}}
className={wrapperClass}
aria-label={displayName}
>
{inner}
</a>
);
}
+28 -39
View File
@@ -1,17 +1,16 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { AuthorByline } from '@/components/AuthorByline';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { CampaignModerationMenu } from '@/components/CampaignModerationMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { ModerationOverlay } from '@/components/moderation';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import {
type ParsedCampaign,
@@ -20,7 +19,6 @@ import {
parseCampaign,
} from '@/lib/campaign';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -123,8 +121,15 @@ function CampaignPrivateNotice({
interface CampaignCardProps {
campaign: ParsedCampaign;
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
variant?: 'compact' | 'featured';
/**
* Visual variant.
*
* - `compact` — default grid item.
* - `featured` — hero placement (wider, side-by-side on `sm+`).
* - `shelf` — fixed-width card for horizontal scroll rails (e.g. group
* official-activity). Caller no longer hand-rolls the size wrapper.
*/
variant?: 'compact' | 'featured' | 'shelf';
className?: string;
/** Optional footer affordance rendered opposite the author line. */
footerBadge?: ReactNode;
@@ -135,22 +140,17 @@ interface CampaignCardProps {
* `<Link>` to the campaign's naddr-based detail route.
*/
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
const { t } = useTranslation();
const { translatedEvent, translateAction } = useEventTranslation(campaign.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCampaign = parseCampaign(translatedEvent) ?? campaign;
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign);
const { data: btcPrice } = useBtcPrice();
const { data: moderation } = useCampaignModeration();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(displayCampaign.banner);
const creatorName =
author.data?.metadata?.display_name ||
author.data?.metadata?.name ||
genUserName(campaign.pubkey);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
const raisedSats = stats?.totalSats ?? 0;
const countryLabel = getCampaignCountryLabel(campaign);
@@ -159,15 +159,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
const isSilentPayment = !campaign.wallets.onchain;
const isFeaturedVariant = variant === 'featured';
const isApproved = moderation.approvedCoords.has(campaign.aTag);
const isHidden = moderation.hiddenCoords.has(campaign.aTag);
const isFeatured = moderation.featuredCoords.has(campaign.aTag);
const isShelfVariant = variant === 'shelf';
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
isShelfVariant && 'h-[430px] w-[280px] shrink-0',
className,
)}
>
@@ -239,24 +238,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
</div>
)}
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
>
<EyeOff className="size-3.5 mr-1" />
Hidden
</Badge>
)}
<CampaignModerationMenu
coord={campaign.aTag}
campaignTitle={campaign.title}
isApproved={isApproved}
isHidden={isHidden}
isFeatured={isFeatured}
/>
</div>
<ModerationOverlay
coord={campaign.aTag}
entityTitle={campaign.title}
surface="campaign"
axes={['approval', 'hide', 'featured']}
badgeSize="default"
className="absolute top-3 right-3 z-10 flex items-center gap-2"
/>
</div>
{/* Body — deterministic structure: title (1 line, truncates) →
@@ -294,11 +283,11 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
)}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
by <span className="font-medium text-foreground">{creatorName}</span>
<div className="flex min-w-0 items-center gap-2">
<AuthorByline pubkey={campaign.pubkey} insideLink />
{!isSilentPayment && stats && stats.donorCount > 0 && (
<span className="ml-2 text-muted-foreground/80">
· {stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
<span className="shrink-0 text-muted-foreground/80">
· {t('common.donors', { count: stats.donorCount })}
</span>
)}
</div>
-144
View File
@@ -1,144 +0,0 @@
import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
interface CampaignModerationMenuProps {
/** The campaign's `30223:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible label for the campaign (for toast feedback). */
campaignTitle: string;
/** Whether the campaign is currently approved. */
isApproved: boolean;
/** Whether the campaign is currently hidden. */
isHidden: boolean;
/** Whether the campaign is currently featured. */
isFeatured: boolean;
className?: string;
}
/**
* Per-card kebab menu exposing the six moderation actions:
* Approve / Unapprove (axis = approval)
* Hide / Unhide (axis = hide)
* Feature / Unfeature (axis = featured)
*
* Renders `null` for users who are not Team Soapbox pack members. Sits
* inside the clickable `CampaignCard` `<Link>`, so the trigger swallows
* its own click + the dropdown content stops propagation, otherwise every
* menu interaction would navigate to the campaign detail page.
*/
export function CampaignModerationMenu({
coord,
campaignTitle,
isApproved,
isHidden,
isFeatured,
className,
}: CampaignModerationMenuProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { moderate } = useCampaignModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: `${verbPast}`, description: campaignTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate campaign"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isApproved ? (
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed from homepage')}>
<ShieldOff className="h-4 w-4 mr-2" />
Unapprove
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Approved
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('approved', 'Approved for homepage')}>
<ShieldCheck className="h-4 w-4 mr-2" />
Approve
</DropdownMenuItem>
)}
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured on homepage')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,22 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { CampaignCard } from '@/components/CampaignCard';
import { parseCampaign } from '@/lib/campaign';
/**
* Renders a kind 33863 Campaign event inside the activity feed using the
* same polished {@link CampaignCard} component that powers the campaign
* directory. The whole card is a `<Link>` to the campaign's naddr-based
* detail route, so taps from the feed land directly on the campaign page.
*
* Malformed events (missing required fields, invalid wallet endpoint,
* etc.) silently drop — `parseCampaign` returns `null` and we return
* `null` from the component. A future enhancement could render a
* "Malformed campaign" fallback, but for now keeping the feed clean
* wins over surfacing parse errors to viewers.
*/
export function CampaignNoteCardContent({ event }: { event: NostrEvent }) {
const campaign = parseCampaign(event);
if (!campaign) return null;
return <CampaignCard campaign={campaign} className="mt-2" />;
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface CommentsSectionProps {
/** Section heading rendered above the muted panel. */
title: string;
/** Optional count chip rendered opposite the heading. */
countLabel?: ReactNode;
/**
* Panel contents. Composer + threaded list + empty state are owned
* by the caller — this wrapper just provides the canonical visual
* surface so the three detail pages (campaign / community / pledge)
* stop drifting.
*
* The wrap uses `bg-muted/60` with `border-primary/20` accents and
* retints child `<article>` borders so per-note dividers read as a
* single consistent edge color. The composer inside uses `bg-card`
* for its own focused-surface contrast against this backdrop.
*/
children: ReactNode;
className?: string;
}
/**
* Canonical visual surface for the comments section on detail pages.
* Extracted from the previous campaign-detail-only treatment so
* Campaigns, Communities, and Pledges all present comments inside the
* same muted, rounded, primary-tinted panel.
*/
export function CommentsSection({ title, countLabel, children, className }: CommentsSectionProps) {
return (
<div className={cn('mt-4', className)}>
<div className="mb-3 px-1 flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{countLabel ? (
<span className="text-sm text-muted-foreground tabular-nums">{countLabel}</span>
) : null}
</div>
{/* Muted surface wraps the composer and comment list. The wrap
carries the outer L/R/B border so the rounded corners curve
naturally without any 1px gaps at the join. Per-article
`border-b` divides items. The composer's own border closes
the top. */}
<div className="rounded-2xl bg-muted/60 overflow-hidden border-l border-r border-primary/20 [&_article]:border-b-primary/20 [&_article]:bg-background/40">
{children}
</div>
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { useMemo } from 'react';
import { Users, Globe } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// --- Helpers ---
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function parseCommunityEvent(event: NostrEvent) {
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Group';
const description = getTag(event.tags, 'description') || '';
const image = getTag(event.tags, 'image');
// Extract moderators from p tags with "moderator" role
const moderators = event.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey)
.filter(Boolean);
// Extract relays
const relays = event.tags
.filter(([n]) => n === 'relay')
.map(([, url, marker]) => ({ url, marker }))
.filter((r) => !!r.url);
return { name, description, image, moderators, relays };
}
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { name, description, image } = useMemo(
() => parseCommunityEvent(event),
[event],
);
// Extract website URL from description if present
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return sanitizeUrl(urlMatch?.[0]);
}, [description]);
// Description text without trailing URL (if the URL is the last thing)
const descriptionText = useMemo(() => {
if (!descriptionUrl) return description;
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
{image ? (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img
src={image}
alt={name}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for text readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Community name overlaid on image */}
<div className="absolute bottom-0 left-0 right-0 px-4 pb-4">
<h1 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h1>
</div>
</div>
) : (
<div className="relative -mx-4 aspect-[21/9] bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-16 text-primary/20" />
<div className="absolute bottom-0 left-0 right-0 px-4 pb-4">
<h1 className="text-2xl font-bold leading-tight">{name}</h1>
</div>
</div>
)}
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
)}
{/* Website link */}
{descriptionUrl && (
<a
href={descriptionUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<Globe className="size-3.5" />
{descriptionUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
</div>
);
}
+32 -115
View File
@@ -4,7 +4,6 @@ import { Link, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import {
CalendarClock,
CalendarDays,
ChevronLeft,
ChevronRight,
@@ -27,7 +26,9 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { CampaignCard } from '@/components/CampaignCard';
import { DetailReplySkeleton } from '@/components/DetailStory';
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
import { PledgeCard } from '@/components/PledgeCard';
import { PostActionBar } from '@/components/PostActionBar';
import { CommentsSection } from '@/components/CommentsSection';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -71,12 +72,10 @@ import { useEventTranslation } from '@/hooks/useEventTranslation';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { applyCommunityModerationToEvents, parseCommunityEvent } from '@/lib/communityUtils';
import type { ParsedCampaign } from '@/lib/campaign';
import { parseAction, type Action } from '@/hooks/useActions';
import { type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatNumber } from '@/lib/formatNumber';
import { genUserName, getDisplayName } from '@/lib/genUserName';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
@@ -227,96 +226,17 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin
function PledgeShelfCard({ pledge }: { pledge: Action }) {
const { t } = useTranslation();
const { data: btcPrice } = useBtcPrice();
const { translatedEvent, translateAction } = useEventTranslation(pledge.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayPledge = parseAction(translatedEvent) ?? pledge;
const author = useAuthor(pledge.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, pledge.pubkey);
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const sanitizedCover = sanitizeUrl(displayPledge.image);
const coverImage = sanitizedCover && !imageLoadFailed ? sanitizedCover : DEFAULT_COVER_IMAGE;
const deadline = displayPledge.deadline ? formatCompactPledgeDeadline(displayPledge.deadline) : null;
const countryLabel = displayPledge.countryCode ? getGeoDisplayName(displayPledge.countryCode) : undefined;
const naddr = nip19.naddrEncode({
kind: pledge.event.kind,
pubkey: pledge.pubkey,
identifier: pledge.id,
});
return (
<Link
to={`/${naddr}`}
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
{deadline?.isPast && (
<div className="absolute right-3 top-3">
<Badge variant="secondary" className="backdrop-blur bg-background/85 border-border/40 text-muted-foreground">
{t('pledges.card.ended')}
</Badge>
</div>
)}
</div>
<div className="flex flex-col gap-3 p-5 flex-1">
<div className="space-y-2">
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
{displayPledge.title}
</h3>
{displayPledge.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">
{displayPledge.description}
</p>
)}
</div>
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(pledge.bounty, btcPrice)}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
{t('groups.detail.byAuthor', { name: displayName })}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label={t('groups.detail.pledge')} />
{translateAction}
</div>
</div>
</div>
</Card>
</Link>
<PledgeCard
action={pledge}
btcPrice={btcPrice}
variant="shelf"
showAuthor
showTranslate
footerAddon={
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label={t('groups.detail.pledge')} />
}
/>
);
}
@@ -545,13 +465,12 @@ function OfficialActivityShelves({
{mixedActivity.map((item) => {
if (item.type === 'campaign') {
return (
<div key={`campaign:${item.id}`} className="h-[430px] w-[280px] shrink-0">
<CampaignCard
campaign={item.campaign}
className="h-full"
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label={t('groups.detail.campaign')} />}
/>
</div>
<CampaignCard
key={`campaign:${item.id}`}
campaign={item.campaign}
variant="shelf"
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label={t('groups.detail.campaign')} />}
/>
);
}
@@ -1146,21 +1065,19 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
{/* Comments — NIP-22 thread on the community event itself. */}
<div id="org-activity" className="scroll-mt-20">
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">{t('groups.detail.comments')}</h2>
{engagementStats?.replies ? (
<span className="text-sm text-muted-foreground tabular-nums">
{formatNumber(engagementStats.replies)}{' '}
{t('groups.detail.commentNoun', { count: engagementStats.replies })}
</span>
) : null}
</div>
<DetailCommentComposer event={event} className="mb-3" />
<CommentsSection
title={t('groups.detail.comments')}
countLabel={engagementStats?.replies ? (
<>
{formatNumber(engagementStats.replies)}{' '}
{t('groups.detail.commentNoun', { count: engagementStats.replies })}
</>
) : undefined}
>
<DetailCommentComposer event={event} />
{commentsLoading && statsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
<div>
{Array.from({ length: 3 }).map((_, i) => (
<DetailReplySkeleton key={i} />
))}
@@ -1181,7 +1098,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
className="block w-full px-6 py-10 text-center hover:bg-foreground/5 transition-colors"
>
<p className="text-base font-medium text-foreground">
{t('groups.detail.noCommentsTitle')}
@@ -1191,7 +1108,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</p>
</button>
)}
</div>
</CommentsSection>
</div>
</div>
-169
View File
@@ -1,169 +0,0 @@
import { useState } from 'react';
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import type { ModerationLabel } from '@/lib/agoraModeration';
interface CommunityModerationMenuProps {
/** The organization's `34550:<pubkey>:<d>` coordinate. */
coord: string;
/** Visible name for the organization (for toast feedback). */
organizationName: string;
className?: string;
}
function CommunityModerationMenuInner({
coord,
organizationName,
className,
}: CommunityModerationMenuProps) {
const { data: moderation, moderate } = useOrganizationModeration();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
const isHidden = moderation.hiddenCoords.has(coord);
const isFeatured = moderation.featuredCoords.has(coord);
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: verbPast, description: organizationName });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: `Failed to ${action}`,
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label="Moderate group"
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Moderator actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
{isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
<SparklesIcon className="h-4 w-4 mr-2" />
Unfeature
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Featured
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', 'Featured group')}>
<Sparkles className="h-4 w-4 mr-2" />
Feature
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isHidden ? (
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
<Eye className="h-4 w-4 mr-2" />
Unhide
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> Hidden
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', 'Hidden')}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the
* moderator kebab plus a "Hidden" badge when applicable, both
* absolutely-positioned at the card's top-right. Returns `null` for
* non-moderators so non-mod grids never subscribe to the moderation
* query at all.
*
* Pulling the overlay (and its `useOrganizationModeration` subscription)
* out of `CommunityMiniCard` into a single moderator-gated component is
* the perf win that lets `/communities` paint Featured/My orgs
* immediately without waiting for the moderator pack or the label query
* for every card on the page.
*/
export function CommunityModerationOverlay({
coord,
organizationName,
}: {
coord: string;
organizationName: string;
}) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
return (
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
);
}
function CommunityModerationOverlayInner({
coord,
organizationName,
}: {
coord: string;
organizationName: string;
}) {
const { data: moderation } = useOrganizationModeration();
const isHidden = moderation.hiddenCoords.has(coord);
return (
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{isHidden && (
<Badge
variant="secondary"
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
>
<EyeOff className="size-3 mr-1" />
Hidden
</Badge>
)}
{/* The kebab inner uses the same moderation cache subscription, so
no extra round-trip is incurred. */}
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useMemo, useState } from 'react';
import { Check, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { countryCodeToFlag, getAllCountries } from '@/lib/countries';
import { cn } from '@/lib/utils';
interface CountryPickerButtonProps {
/**
* Selected ISO 3166-1 alpha-2 country code (e.g. `"US"`), or `undefined`
* for the global / no-filter state. The button renders a flag emoji
* when a country is selected, otherwise a brand-orange Globe icon —
* matching the affordance the pledges page introduced.
*/
value: string | undefined;
/** Called when the user picks a country, or `undefined` for Global. */
onChange: (next: string | undefined) => void;
/** Extra classes on the trigger button. */
className?: string;
}
/**
* Globe-icon country filter button shared by the discovery pages
* (Campaigns, Communities, Pledges). Opens a searchable country list in
* a popover; the first item is "Global" which clears the filter.
*
* The trigger collapses to the picked country's flag emoji when a
* country is selected, so the active state reads without opening the
* popover. Brand-orange `Globe` icon in the neutral state matches the
* other filter icons in the cluster.
*
* Callers own the selected country (state lives on the page so it can
* be threaded into NIP-50 queries or URL params).
*/
export function CountryPickerButton({ value, onChange, className }: CountryPickerButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const allCountries = useMemo(() => getAllCountries(), []);
const countryOptions = useMemo(() => {
const options: Array<{ value: string; label: string; flag: string }> = [
{ value: 'global', label: t('common.countryGlobal'), flag: '🌍' },
];
for (const country of allCountries) {
options.push({
value: country.code,
label: country.name,
flag: countryCodeToFlag(country.code),
});
}
return options;
}, [allCountries, t]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-auto p-2 hover:bg-muted/50 rounded-lg', className)}
aria-label={t('common.countryFilterAriaLabel')}
>
{value ? (
<span className="text-2xl leading-none">{countryCodeToFlag(value)}</span>
) : (
<Globe className="h-5 w-5 text-primary" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="end">
<Command>
<CommandInput placeholder={t('common.countrySearchPlaceholder')} />
<CommandList>
<CommandEmpty>{t('common.countryNoResults')}</CommandEmpty>
<CommandGroup>
{countryOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
onChange(option.value === 'global' ? undefined : option.value);
setOpen(false);
}}
className="gap-2"
>
<span>{option.flag}</span>
<span className="flex-1">{option.label}</span>
<Check
className={cn(
'h-4 w-4',
(value || 'global') === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function CountrySelect({
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={placeholder ?? t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
+68
View File
@@ -0,0 +1,68 @@
import { Search, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface DebouncedSearchInputProps {
/** Current input value. Parent owns the state so it can debounce into a query. */
value: string;
/** Called on every keystroke. */
onChange: (next: string) => void;
/** Placeholder text. */
placeholder: string;
/** `aria-label` for the input. Required because there's no visible label. */
ariaLabel: string;
/** `aria-label` for the clear button. */
clearLabel: string;
/** Extra classes on the wrapper. */
className?: string;
}
/**
* Search input used on the discovery pages (Campaigns, Communities, Pledges)
* for on-page NIP-50 search. Renders a shadcn `Input` with a left-aligned
* lucide `Search` icon and a right-aligned clear button that appears once
* the user has typed something.
*
* This component owns no state — the caller is expected to pair it with
* `useDebounce` and feed the debounced value into a query hook. Keeping
* it stateless means the same component can be reused for URL-synced
* searches, in-memory searches, or anywhere else.
*/
export function DebouncedSearchInput({
value,
onChange,
placeholder,
ariaLabel,
clearLabel,
className,
}: DebouncedSearchInputProps) {
return (
<div className={cn('relative', className)}>
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
inputMode="search"
autoComplete="off"
aria-label={ariaLabel}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="pl-9 pr-9 h-11 rounded-lg"
/>
{value && (
<button
type="button"
aria-label={clearLabel}
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<X className="size-4" />
</button>
)}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useTranslation } from 'react-i18next';
import { Check, Clock, EyeOff, LayoutGrid, ListFilter, TrendingUp } from 'lucide-react';
import { CountryPickerButton } from '@/components/CountryPickerButton';
import { DebouncedSearchInput } from '@/components/DebouncedSearchInput';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { Nip50Sort } from '@/hooks/useNip50Search';
const SORT_OPTIONS: { value: Nip50Sort; labelKey: string; icon: typeof TrendingUp }[] = [
{ value: 'default', labelKey: 'common.sortDefault', icon: LayoutGrid },
{ value: 'top', labelKey: 'common.sortTop', icon: TrendingUp },
{ value: 'new', labelKey: 'common.sortNew', icon: Clock },
];
interface DiscoverySearchToolbarProps {
/** Search input value (parent state, undebounced). */
query: string;
/** Called on every keystroke. Parent is expected to debounce before querying. */
onQueryChange: (next: string) => void;
/** Active sort. */
sort: Nip50Sort;
/** Called when the user picks a different sort. */
onSortChange: (next: Nip50Sort) => void;
/** Subset of sort options to expose. Defaults to all three. */
sortOptions?: Nip50Sort[];
/** i18n placeholder key for the input, e.g. `pledges.list.searchPlaceholder`. */
searchPlaceholderKey: string;
/** i18n aria-label key for the input, e.g. `pledges.list.searchAriaLabel`. */
searchAriaLabelKey: string;
/**
* Show-hidden switch state + handler. When `undefined`, the show-hidden
* row is omitted from the menu.
*/
showHidden?: {
/** Switch value. */
value: boolean;
/** Called when the user toggles the switch. */
onChange: (next: boolean) => void;
/** Optional count to render next to the label, e.g. (3). */
count?: number;
};
/**
* Selected ISO 3166-1 alpha-2 country code (e.g. `"US"`), or
* `undefined` for the global / no-filter state. Drives the country
* picker button rendered to the right of the filter dropdown.
*/
country?: string;
/** Called when the user picks a country, or `undefined` for Global. */
onCountryChange?: (next: string | undefined) => void;
/** Extra classes on the outer container. */
className?: string;
}
/**
* Filter cluster shared by every discovery page (Campaigns home, All-Campaigns,
* Communities, Pledges). Designed to sit on the **right** of a section
* heading row, paired with an `h2 + tagline` block on the left:
*
* <div className="flex items-end justify-between gap-4">
* <div>
* <h2>…</h2>
* <p>…</p>
* </div>
* <DiscoverySearchToolbar … />
* </div>
*
* Layout: a horizontal cluster with a compact debounced search input
* (left) and a single Filter button (right) whose `DropdownMenu`
* contains the sort options and the optional Show-hidden switch — same
* `ListFilter` icon-button pattern the pledges page already uses for
* its sort dropdown, so the affordance is consistent.
*
* Fully controlled — parent owns search / sort / show-hidden state.
* Keeps URL sync, debounce, and storage decisions where they belong
* (in the page).
*/
export function DiscoverySearchToolbar({
query,
onQueryChange,
sort,
onSortChange,
sortOptions,
searchPlaceholderKey,
searchAriaLabelKey,
showHidden,
country,
onCountryChange,
className,
}: DiscoverySearchToolbarProps) {
const { t } = useTranslation();
const sorts = sortOptions
? SORT_OPTIONS.filter((o) => sortOptions.includes(o.value))
: SORT_OPTIONS;
return (
<div className={cn('flex items-center gap-1 sm:shrink-0', className)}>
<DebouncedSearchInput
value={query}
onChange={onQueryChange}
placeholder={t(searchPlaceholderKey)}
ariaLabel={t(searchAriaLabelKey)}
clearLabel={t('common.clearSearch')}
className="flex-1 sm:flex-none sm:w-64 mr-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label={t('common.filtersAriaLabel')}
className="h-auto p-2 rounded-lg hover:bg-muted/50"
>
<ListFilter className="h-5 w-5 text-primary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs font-semibold text-muted-foreground">
{t('common.sortAriaLabel')}
</DropdownMenuLabel>
{sorts.map(({ value, labelKey, icon: Icon }) => (
<DropdownMenuCheckboxItem
key={value}
checked={sort === value}
onCheckedChange={(checked) => {
if (checked) onSortChange(value);
}}
// The checkbox slot on the left is hidden in favour of an
// explicit `Check` on the right (matches the
// pledges-page sort dropdown). We keep the variant
// because it gives us the radio-like "one checked at a
// time" semantics for free.
className={cn(
'[&>span:first-child]:hidden pl-2',
sort === value && 'bg-primary/10',
)}
>
<Icon className="mr-2 h-4 w-4" />
<span className="flex-1">{t(labelKey)}</span>
{sort === value && <Check className="ml-2 h-4 w-4" />}
</DropdownMenuCheckboxItem>
))}
{showHidden && (
<>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showHidden.value}
onCheckedChange={showHidden.onChange}
className="pl-2"
>
<EyeOff className="mr-2 h-4 w-4" />
<span className="flex-1">{t('common.showHidden')}</span>
{showHidden.count !== undefined && showHidden.count > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
({showHidden.count})
</span>
)}
</DropdownMenuCheckboxItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{onCountryChange && (
<CountryPickerButton value={country} onChange={onCountryChange} />
)}
</div>
);
}
+1 -1
View File
@@ -431,7 +431,7 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
value={idTouched ? identifier : effectiveIdentifier}
onChange={(e) => handleIdChange(e.target.value)}
disabled={isEditMode || isSubmitting}
className={`font-mono text-sm ${isEditMode ? 'text-muted-foreground' : ''}`}
className={`font-mono text-base md:text-sm ${isEditMode ? 'text-muted-foreground' : ''}`}
/>
{isEditMode && (
<p className="text-xs text-muted-foreground">Cannot be changed.</p>
+1 -1
View File
@@ -587,7 +587,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin
placeholder={t('walletSend.recipient.placeholder')}
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
{recipient && (
<p className="text-xs text-muted-foreground">
+21 -12
View File
@@ -33,11 +33,9 @@ import {
PodcastEpisodeContent,
PodcastTrailerContent,
} from "@/components/AudioKindContent";
import { ActionContent } from "@/components/ActionContent";
import { CampaignInlinePreview, GroupInlinePreview, PledgeInlinePreview } from "@/components/AgoraInlinePreview";
import { BadgeAwardCard } from "@/components/BadgeAwardCard";
import { BadgeContent } from "@/components/BadgeContent";
import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
import { CommunityContent } from "@/components/CommunityContent";
import { CalendarEventContent } from "@/components/CalendarEventContent";
import { ColorMomentContent } from "@/components/ColorMomentContent";
import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
@@ -556,7 +554,11 @@ export const NoteCard = memo(function NoteCard({
const isComment = event.kind === 1111;
const isReply = isTextNote && !isComment && isReplyEvent(event);
const { translatedEvent: contentEvent, translateAction } = useEventTranslation(event, { includePlainContent: isTextNote });
const { translatedEvent: contentEvent, translateAction } = useEventTranslation(event, {
includePlainContent: isTextNote,
iconOnly: true,
buttonClassName: "h-9 w-9 p-0",
});
// Find all people being replied to (for "Replying to @user1 and @user2")
const replyToPubkeys = useMemo(() => {
@@ -696,15 +698,15 @@ export const NoteCard = memo(function NoteCard({
) : isBadgeAward ? (
<BadgeAwardCard event={event} />
) : isCommunity ? (
<CommunityContent event={event} />
<GroupInlinePreview event={contentEvent} />
) : isZapGoal ? (
<GoalCard event={event} />
) : isAction ? (
<ActionContent event={event} />
<PledgeInlinePreview event={contentEvent} />
) : isCampaign ? (
<CampaignNoteCardContent event={contentEvent} />
<CampaignInlinePreview event={contentEvent} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
@@ -896,8 +898,6 @@ export const NoteCard = memo(function NoteCard({
<div className="flex-1" />
{translateAction}
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
title={t('feed.actions.share')}
@@ -911,7 +911,12 @@ export const NoteCard = memo(function NoteCard({
>
<Share2 className="size-[18px]" />
</button>
</div>
);
const headerControls = !compact ? (
<div className="ml-auto flex shrink-0 items-center gap-1">
{translateAction}
<button
className="inline-flex items-center justify-center h-9 w-9 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={t('feed.actions.more')}
@@ -923,7 +928,7 @@ export const NoteCard = memo(function NoteCard({
<MoreHorizontal className="size-[18px]" />
</button>
</div>
);
) : null;
// ── Vanish layout (kind 62) — dramatic card, no author row ──
if (isVanish) {
@@ -932,13 +937,14 @@ export const NoteCard = memo(function NoteCard({
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
"relative px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
{headerControls && <div className="absolute right-3 top-2 z-10">{headerControls}</div>}
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
@@ -964,12 +970,13 @@ export const NoteCard = memo(function NoteCard({
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
"relative px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
{headerControls && <div className="absolute right-3 top-2 z-10">{headerControls}</div>}
<VanishCardCompact event={event} />
{!compact && (
<>
@@ -1150,6 +1157,7 @@ export const NoteCard = memo(function NoteCard({
{authorInfo}
</div>
<CountryCommentPill event={event} className="shrink-0 [text-shadow:none]" />
{headerControls}
</div>
{contentBlock}
{actionButtons}
@@ -1230,6 +1238,7 @@ export const NoteCard = memo(function NoteCard({
event={event}
className="shrink-0 [text-shadow:none]"
/>
{headerControls}
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
interface PendingBadgeProps {
/**
* Optional formatted amount (e.g. "$1.23"). When present the badge reads
* "{amount} pending"; when omitted it reads just "pending".
*/
amountLabel?: string;
/** Additional classes appended to the base styling. */
className?: string;
}
/**
* Small orange inline indicator used wherever a Bitcoin amount is awaiting
* mempool confirmation — currently on the wallet headline and on campaign
* donation surfaces. Centralised so the visual treatment (orange + spinning
* RefreshCw) stays consistent across pages.
*/
export function PendingBadge({ amountLabel, className }: PendingBadgeProps) {
const { t } = useTranslation();
return (
<span
className={cn(
'inline-flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400',
className,
)}
>
<RefreshCw className="size-3 animate-spin" />
{amountLabel
? t('wallet.amountPending', { amount: amountLabel })
: t('wallet.pending')}
</span>
);
}
+1 -1
View File
@@ -153,7 +153,7 @@ export function PersonSearch({
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-base md:text-sm"
autoComplete="off"
/>
</div>
+57 -18
View File
@@ -11,6 +11,25 @@ interface PinnedCommentHeaderProps {
children?: ReactNode;
}
/**
* Companion overlay for a note in a `ThreadedReplyList`. Positions the
* pin affordance in the note's top-right corner via the wrapping
* `group/note` container that `ReplyThread` provides. One slot, three
* states:
*
* - Not pinned, can manage → "Pin" button. Hidden until hover on
* fine-pointer devices (mouse / trackpad); always visible on touch
* devices so mobile moderators can find it.
* - Pinned, can manage → "Unpin" button, always visible.
* - Pinned, cannot manage → "Pinned" badge, always visible.
* - Not pinned, cannot manage → nothing rendered.
*
* The previous design rendered a full-width header bar above every
* comment; this slot model removes that vertical noise.
*
* `children` (custom inline badges, e.g. a country flag) is rendered
* as a flow row above the comment, unchanged.
*/
export function PinnedCommentHeader({
isPinned,
canManagePins,
@@ -21,17 +40,14 @@ export function PinnedCommentHeader({
if (!isPinned && !canManagePins && !children) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{isPinned && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
<Pin className="size-3 rotate-45 fill-current" />
Pinned
</span>
)}
{children}
</div>
{canManagePins && (
<>
{children && (
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-0 text-xs text-muted-foreground">
{children}
</div>
)}
{canManagePins ? (
<button
type="button"
onClick={(e) => {
@@ -40,14 +56,37 @@ export function PinnedCommentHeader({
}}
disabled={pinPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
isPinned && 'text-primary',
// Positioned absolutely against the per-note
// `group/note` wrapper from `ReplyThread`.
'absolute top-2 right-2 z-10',
'inline-flex items-center gap-1.5 rounded-full bg-background/95 px-2 py-1 text-xs font-medium shadow-sm backdrop-blur',
'motion-safe:transition-opacity motion-safe:duration-150',
// Pinned: always visible so the state is legible at a
// glance. Not pinned: hidden until hover on hover-capable
// pointers (mouse/trackpad); always visible on coarse
// pointers (touch) so mobile moderators can pin.
isPinned
? 'text-primary opacity-100'
: 'text-muted-foreground opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/note:opacity-100 [@media(hover:hover)]:focus-visible:opacity-100',
'hover:bg-primary/10 hover:text-primary',
'disabled:cursor-not-allowed disabled:opacity-60',
)}
aria-label={isPinned ? 'Unpin comment' : 'Pin comment'}
>
<Pin className={cn('size-3.5 rotate-45', isPinned && 'fill-current')} />
<span>{isPinned ? 'Unpin' : 'Pin'}</span>
</button>
) : isPinned ? (
<span
className={cn(
'absolute top-2 right-2 z-10',
'inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary backdrop-blur',
)}
>
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
{isPinned ? 'Unpin' : 'Pin'}
</button>
)}
</div>
<Pin className="size-3.5 rotate-45 fill-current" />
Pinned
</span>
) : null}
</>
);
}
+174
View File
@@ -0,0 +1,174 @@
import type { ReactNode } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CalendarClock, MapPin } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { AuthorByline } from '@/components/AuthorByline';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { parseAction, type Action } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { cn } from '@/lib/utils';
interface PledgeCardProps {
action: Action;
btcPrice: number | undefined;
/** Presentation variant for standalone surfaces. Inline note cards use PledgeInlinePreview instead. */
variant?: 'grid' | 'shelf' | 'rail';
/** Force an ended badge from a parent that already split active/ended sections. */
isExpired?: boolean;
/** Render author footer. Standalone discovery cards usually do; profile-owned cards usually don't. */
showAuthor?: boolean;
/** Render the translation control in the footer. Use only when no parent shell owns translation. */
showTranslate?: boolean;
/** Menu/badges overlaid on the cover image, e.g. share/delete menu. */
topRight?: ReactNode;
/** Extra footer affordance, e.g. group official-activity type pill. */
footerAddon?: ReactNode;
className?: string;
}
export function PledgeCard({
action,
btcPrice,
variant = 'grid',
isExpired,
showAuthor = false,
showTranslate = false,
topRight,
footerAddon,
className,
}: PledgeCardProps) {
const { t } = useTranslation();
const { translatedEvent, translateAction } = useEventTranslation(action.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayAction = showTranslate ? (parseAction(translatedEvent) ?? action) : action;
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const coverImage = displayAction.image && !imageLoadFailed ? displayAction.image : DEFAULT_COVER_IMAGE;
const deadline = displayAction.deadline ? formatCompactPledgeDeadline(displayAction.deadline) : null;
const ended = isExpired || !!deadline?.isPast;
const countryLabel = displayAction.countryCode ? getGeoDisplayName(displayAction.countryCode) : undefined;
const isRail = variant === 'rail';
const footer = showAuthor || showTranslate || footerAddon;
return (
<Link
to={`/${naddr}`}
className={cn(
'group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
variant === 'shelf' && 'h-[430px] w-[280px] shrink-0',
className,
)}
>
<Card className={cn(
'overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg flex flex-col',
!isRail && 'h-full',
)}>
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
{(ended || topRight) && (
<div className={cn('absolute flex items-center gap-2', isRail ? 'right-2 top-2' : 'right-3 top-3')} onClick={(e) => e.preventDefault()}>
{ended && (
<Badge
variant="secondary"
className={cn(
'backdrop-blur bg-background/85 border-border/40 text-muted-foreground',
isRail && 'text-[10px] uppercase tracking-wide px-1.5 py-0.5',
)}
>
{t('pledges.card.ended')}
</Badge>
)}
{topRight}
</div>
)}
</div>
<div className={cn(isRail ? 'p-3 space-y-1.5' : 'flex flex-col gap-3 p-5 flex-1')}>
<div className={cn(!isRail && 'space-y-2')}>
<h3 className={cn(
'font-bold leading-tight tracking-tight line-clamp-2',
isRail ? 'text-sm font-semibold leading-snug' : 'text-lg',
)}>
{displayAction.title}
</h3>
{!isRail && displayAction.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">{displayAction.description}</p>
)}
</div>
{!isRail && <div className="flex-1" />}
{isRail ? (
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground uppercase tracking-wide font-semibold">{t('pledges.card.pledged')}</span>
<span className="text-foreground font-bold tabular-nums">{formatPledgeAmount(action.bounty, btcPrice)}</span>
</div>
) : (
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(action.bounty, btcPrice)}
</p>
</div>
)}
{(countryLabel || deadline) && (
<div className={cn(
'flex flex-wrap text-muted-foreground',
isRail ? 'gap-x-3 gap-y-1 text-[11px] pt-0.5' : 'gap-x-4 gap-y-1.5 text-xs pt-1',
)}>
{countryLabel && (
<span className={cn('inline-flex items-center gap-1.5', isRail && 'truncate')}>
{!isRail && <MapPin className="size-3.5" />}
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1', deadline.isPast && 'text-destructive')}>
<CalendarClock className={isRail ? 'size-3' : 'size-3.5'} />
{deadline.label}
</span>
)}
</div>
)}
{footer && !isRail && (
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="min-w-0 flex-1 truncate">
{showAuthor ? <AuthorByline pubkey={action.pubkey} insideLink /> : null}
</div>
{(footerAddon || (showTranslate && translateAction)) && (
<div className="flex shrink-0 items-center gap-1.5">
{footerAddon}
{showTranslate && translateAction}
</div>
)}
</div>
)}
</div>
</Card>
</Link>
);
}
+4 -1
View File
@@ -29,6 +29,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
const [open, setOpen] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
const [quoteInitialContent, setQuoteInitialContent] = useState<string | undefined>(undefined);
const [attachQuotedEvent, setAttachQuotedEvent] = useState(true);
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { mutate: deleteEvent } = useDeleteEvent();
@@ -149,12 +150,14 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
const handleQuote = () => {
setQuoteInitialContent(undefined);
setAttachQuotedEvent(true);
setOpen(false);
setQuoteOpen(true);
};
const handleBoost = () => {
setQuoteInitialContent(`\n\n${buildAgoraUrl(encodeEventAddress(event))}`);
setAttachQuotedEvent(false);
setOpen(false);
setQuoteOpen(true);
};
@@ -225,7 +228,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
</PopoverContent>
</Popover>
<ReplyComposeModal
quotedEvent={event}
quotedEvent={attachQuotedEvent ? event : undefined}
open={quoteOpen}
onOpenChange={setQuoteOpen}
initialContent={quoteInitialContent}
+4 -4
View File
@@ -66,7 +66,7 @@ function ReplyThread({
if (shouldCollapse) {
return (
<div>
<div className="relative group/note">
{renderItemHeader?.(node.event)}
<NoteCard
event={node.event}
@@ -81,7 +81,7 @@ function ReplyThread({
if (!hasChildren) {
return (
<div>
<div className="relative group/note">
{renderItemHeader?.(node.event)}
<NoteCard
event={node.event}
@@ -97,7 +97,7 @@ function ReplyThread({
const childDepthless = depthless || expanded;
return (
<div>
<div className="relative group/note">
{renderItemHeader?.(node.event)}
<NoteCard
event={node.event}
@@ -111,7 +111,7 @@ function ReplyThread({
)}
{/* Revealed hidden siblings render as threaded items before the inline child */}
{showHidden && node.hiddenChildren!.map((child) => (
<div key={child.event.id}>
<div key={child.event.id} className="relative group/note">
{renderItemHeader?.(child.event)}
<NoteCard
event={child.event}
+35 -9
View File
@@ -75,14 +75,31 @@ export function TopNav() {
<Menu className="size-5" />
</button>
{/* Brand */}
{/* Brand — bolt + wordmark. The bolt SVG leans ~10° to the left
(the dominant slash runs from top-right (13.4,1) to bottom-left
(9.5,23) within the 24-unit viewBox, ≈ arctan(3.9/22) ≈ 10°).
The wordmark splits the first letter (which mirrors the bolt
silhouette — a steeply leaned capital) from the remaining
letters (a gentler oblique that's easier to read at nav-bar
size). The 0.022em currentColor stroke matches the hero recipe
(Bebas only ships weight 400, so this fattens letterforms
without the fuzz of a synthetic bold). */}
<Link
to="/"
className="flex items-center gap-2 font-bold text-lg tracking-tight text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
className="flex items-center text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
aria-label={t('nav.brandHome', { appName: config.appName })}
>
<LogoIcon className="size-6" />
<span>{config.appName}</span>
<LogoIcon className="size-9" />
<span
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
transformOrigin: '0 100%',
}}
>
{config.appName}
</span>
</Link>
{/* Desktop nav */}
@@ -122,10 +139,19 @@ export function TopNav() {
<Link
to="/"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 font-bold text-lg text-primary"
className="flex items-center text-primary"
>
<LogoIcon className="size-6" />
<span>{config.appName}</span>
<LogoIcon className="size-9" />
<span
className="font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
transformOrigin: '0 100%',
}}
>
{config.appName}
</span>
</Link>
<button
onClick={() => setMobileOpen(false)}
@@ -164,7 +190,7 @@ function NavLinkButton({ item }: { item: NavItem }) {
cn(
'px-3 py-2 rounded-md text-sm font-medium motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive
? 'text-foreground'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
@@ -235,7 +261,7 @@ function MobileLinkList({ items, onClose }: { items: MobileLinkItem[]; onClose:
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium motion-safe:transition-colors',
isActive
? 'bg-primary/10 text-foreground'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
+10 -4
View File
@@ -12,14 +12,20 @@ interface CommunityGridProps {
* organizations wrap onto multiple rows instead of disappearing off the
* right edge.
*
* Column counts are tuned to the page's `max-w-5xl` (~1024px) content
* column so each cell ends up close to the legacy 256px `CommunityMiniCard`
* width at the `lg` breakpoint:
* Column counts are tuned so each cell ends up close to the legacy
* 256px `CommunityMiniCard` width at the `lg` breakpoint:
* - <640px: 1 column
* - sm 640+: 2 columns
* - md 768+: 3 columns
* - lg 1024+: 4 columns
*
* No horizontal padding by design — callers wrap the grid in their own
* `max-w-* mx-auto px-4 sm:px-6` page container, matching the
* Campaigns and Pledges discovery pages so all three surfaces align
* visually. Double-padding (page container + grid) used to leave
* group cards inset further than campaign / pledge cards at `sm`
* breakpoints and up.
*
* Cards passed in should be `w-full` so they fill their grid cell — the
* default `w-64` on `CommunityMiniCard` can be overridden with
* `className="w-full"` thanks to `tailwind-merge`.
@@ -28,7 +34,7 @@ export function CommunityGrid({ children, className }: CommunityGridProps) {
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-4 sm:px-6',
'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4',
className,
)}
>
+9 -25
View File
@@ -2,12 +2,11 @@ import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
import { AuthorByline } from '@/components/AuthorByline';
import { ModerationOverlay } from '@/components/moderation';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import {
@@ -55,13 +54,7 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayCommunity = parseCommunityEvent(translatedEvent) ?? community;
const founder = useAuthor(community.founderPubkey);
const banner = sanitizeUrl(displayCommunity.image);
const founderName =
founder.data?.metadata?.display_name ||
founder.data?.metadata?.name ||
genUserName(community.founderPubkey);
const founderAvatar = sanitizeUrl(founder.data?.metadata?.picture);
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
@@ -95,7 +88,12 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
non-moderators, which is why this component owns the
`useOrganizationModeration` subscription rather than the
card — keeps non-mod grids free of the heavy label query. */}
<CommunityModerationOverlay coord={community.aTag} organizationName={community.name} />
<ModerationOverlay
coord={community.aTag}
entityTitle={community.name}
surface="group"
axes={['hide', 'featured']}
/>
</div>
<div className="flex flex-col gap-2 p-3.5 flex-1">
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
@@ -107,21 +105,7 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
</p>
)}
<div className="mt-auto flex items-center justify-between gap-2 pt-1.5">
<div className="flex min-w-0 items-center gap-2">
{founderAvatar ? (
<img
src={founderAvatar}
alt=""
loading="lazy"
className="size-5 rounded-full object-cover"
/>
) : (
<div className="size-5 rounded-full bg-secondary" />
)}
<span className="truncate text-[11px] text-muted-foreground">
by {founderName}
</span>
</div>
<AuthorByline pubkey={community.founderPubkey} insideLink />
{translateAction}
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
import { EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
/**
* "Hidden" status pill, rendered on cards (and any other surface) where
* a moderator has suppressed an entity from public discovery. Visually
* unified across campaigns, pledges, and organizations — same colors,
* same icon, same copy — so the cue is instantly recognizable wherever
* it appears.
*
* Two size variants:
* - `default` — full-sized chip used on big cards (CampaignCard).
* - `compact` — slim chip for overlay corners on smaller cards
* (ActionCard, CommunityMiniCard).
*/
export function HiddenBadge({
size = 'default',
className,
}: {
size?: 'default' | 'compact';
className?: string;
}) {
const { t } = useTranslation();
return (
<Badge
variant="secondary"
className={cn(
'backdrop-blur bg-destructive/15 text-destructive border-destructive/30',
size === 'compact' && 'h-6 px-1.5 text-[10px]',
className,
)}
>
<EyeOff className={cn('mr-1', size === 'compact' ? 'size-3' : 'size-3.5')} />
{t('moderation.hiddenBadge')}
</Badge>
);
}
@@ -0,0 +1,297 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Check, EyeOff, Eye, MoreHorizontal,
ShieldCheck, ShieldOff, Sparkles, SparklesIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import type { ModerationLabel } from '@/lib/agoraModeration';
/**
* Which moderation surface we're acting on. Each surface routes the
* mutation through its own per-kind hook (different relay invalidations,
* different axis support) but the dropdown shell is identical.
*/
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.
*/
export type ModerationAxis = 'approval' | 'hide' | 'featured';
interface ModerationItemsProps {
/** Addressable coordinate of the entity (`<kind>:<pubkey>:<d>`). */
coord: string;
/** Visible title for the entity, used in toast feedback. */
entityTitle: string;
/** Which surface this acts on. */
surface: ModerationSurface;
/** Which axes to render. */
axes: readonly ModerationAxis[];
}
interface ModerationMenuProps extends ModerationItemsProps {
/** Optional override className applied to the trigger button. */
className?: string;
}
/** Translated label for the trigger's aria-label. */
function ariaLabelKey(surface: ModerationSurface): string {
switch (surface) {
case 'campaign': return 'moderation.menu.ariaCampaign';
case 'pledge': return 'moderation.menu.ariaPledge';
case 'group': return 'moderation.menu.ariaGroup';
}
}
// ─────────────────────────────────────────────────────────────────────
// ModerationMenuItems — the dropdown rows themselves (label + items)
// without the outer DropdownMenu / DropdownMenuTrigger wrapper. Used by
// the standalone ModerationMenu below and by callers (like ActionCard's
// share/delete kebab) that need to embed moderator actions inside
// their own dropdown so the card carries a single kebab.
//
// Returns `null` for non-moderators; callers compose conditionally:
//
// <DropdownMenuContent>
// <DropdownMenuItem onClick={…}>Copy link</DropdownMenuItem>
// <DropdownMenuSeparator />
// <ModerationMenuItems coord={…} surface="pledge" axes={…} entityTitle={…} />
// </DropdownMenuContent>
//
// Callers are responsible for inserting a leading separator when there
// are share/owner items above. The component starts with a
// `DropdownMenuLabel` ("Moderator actions") so the section reads as a
// distinct group either way.
// ─────────────────────────────────────────────────────────────────────
/** Inner rows once moderation state has been resolved. Pure UI. */
function ModerationItemsShell({
coord,
entityTitle,
axes,
moderation,
moderate,
}: {
coord: string;
entityTitle: string;
axes: readonly ModerationAxis[];
moderation: ReturnType<typeof useCampaignModeration>['data'];
moderate: ReturnType<typeof useCampaignModeration>['moderate'];
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [busy, setBusy] = useState<ModerationLabel | null>(null);
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');
const runAction = async (action: ModerationLabel, verbPast: string) => {
if (busy) return;
setBusy(action);
try {
await moderate.mutateAsync({ coord, action });
toast({ title: verbPast, description: entityTitle });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
toast({
title: t('moderation.menu.failedAction', { action }),
description: message,
variant: 'destructive',
});
} finally {
setBusy(null);
}
};
return (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t('moderation.menu.label')}
</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}>
<Eye className="h-4 w-4 mr-2" />
{t('moderation.menu.unhide')}
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> {t('moderation.menu.hiddenState')}
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => runAction('hidden', t('moderation.menu.toastHidden'))}
disabled={!!busy}
className="text-destructive focus:text-destructive"
>
<EyeOff className="h-4 w-4 mr-2" />
{t('moderation.menu.hide')}
</DropdownMenuItem>
)
)}
{hasFeatured && (hasApproval || hasHide) && <DropdownMenuSeparator />}
{hasFeatured && (
isFeatured ? (
<DropdownMenuItem onClick={() => runAction('unfeatured', t('moderation.menu.toastUnfeatured'))} disabled={!!busy}>
<SparklesIcon className="h-4 w-4 mr-2" />
{t('moderation.menu.unfeature')}
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
<Check className="h-3 w-3" /> {t('moderation.menu.featuredState')}
</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => runAction('featured', t('moderation.menu.toastFeatured'))} disabled={!!busy}>
<Sparkles className="h-4 w-4 mr-2" />
{t('moderation.menu.feature')}
</DropdownMenuItem>
)
)}
</>
);
}
// Per-surface inner components. Each mounts only its own moderation
// hook so a pledge card never subscribes to the campaign label query
// (and vice versa).
function CampaignItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
const { data, moderate } = useCampaignModeration();
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
}
function PledgeItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
const { data, moderate } = usePledgeModeration();
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
}
function GroupItemsInner(props: { coord: string; entityTitle: string; axes: readonly ModerationAxis[] }) {
const { data, moderate } = useOrganizationModeration();
return <ModerationItemsShell {...props} moderation={data} moderate={moderate} />;
}
/**
* Renders the moderator-only dropdown rows (label + action items) for
* embedding inside a host `DropdownMenuContent`. Returns `null` for
* non-moderators so the moderation cache is never subscribed on non-mod
* views.
*
* Compose with other items in a single host dropdown when a card needs
* to expose both share/owner actions AND moderator actions in one kebab
* (e.g. `ActionShareMenu` on pledge cards). Insert a
* `<DropdownMenuSeparator />` immediately before this component when
* any preceding items exist, so the moderator section reads as a
* distinct group:
*
* <DropdownMenuContent>
* <DropdownMenuItem onClick={copy}>Copy link</DropdownMenuItem>
* {isOwner && <DropdownMenuItem onClick={del}>Delete</DropdownMenuItem>}
* <DropdownMenuSeparator />
* <ModerationMenuItems coord={…} surface="pledge" axes={…} entityTitle={…} />
* </DropdownMenuContent>
*
* For surfaces that only need the moderator kebab in isolation (no
* share/owner items), use {@link ModerationMenu} or
* {@link ModerationOverlay} — both wrap this component in their own
* trigger.
*/
export function ModerationMenuItems(props: ModerationItemsProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
const inner = { coord: props.coord, entityTitle: props.entityTitle, axes: props.axes };
switch (props.surface) {
case 'campaign': return <CampaignItemsInner {...inner} />;
case 'pledge': return <PledgeItemsInner {...inner} />;
case 'group': return <GroupItemsInner {...inner} />;
}
}
// ─────────────────────────────────────────────────────────────────────
// Standalone moderator kebab. Wraps ModerationMenuItems in its own
// DropdownMenu + trigger. Returns null for non-moderators so the
// trigger and the moderation query are both skipped.
// ─────────────────────────────────────────────────────────────────────
/**
* Per-card / per-surface kebab menu for moderator actions. Returns
* `null` for non-moderators so the moderation cache is never subscribed
* on non-mod views.
*
* Used directly on detail pages (no overlay wrapper). For card grids,
* prefer {@link ModerationOverlay}, which bundles this kebab with a
* "Hidden" badge in an absolutely-positioned corner.
*/
export function ModerationMenu({ className, ...rest }: ModerationMenuProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button
variant="ghost"
size="icon"
aria-label={t(ariaLabelKey(rest.surface))}
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<ModerationMenuItems {...rest} />
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,120 @@
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import { HiddenBadge } from './HiddenBadge';
import { ModerationMenu, type ModerationAxis, type ModerationSurface } from './ModerationMenu';
interface ModerationOverlayProps {
/** Addressable coordinate of the entity (`<kind>:<pubkey>:<d>`). */
coord: string;
/** Visible title for the entity, used in toast feedback. */
entityTitle: string;
/** Which surface this overlay acts on. */
surface: ModerationSurface;
/** Which axes to expose in the kebab menu. */
axes: readonly ModerationAxis[];
/**
* Visual size for the inline Hidden badge. Big cards (CampaignCard's
* featured variant) tend to look better with the default size; small
* grid cards (ActionCard, CommunityMiniCard) use compact.
*/
badgeSize?: 'default' | 'compact';
/**
* When false, the moderator kebab is suppressed and only the
* "Hidden" badge renders. Useful when a card already exposes a
* combined kebab elsewhere (e.g. `ActionShareMenu` on pledge cards
* embeds `ModerationMenuItems` directly into its share/delete
* dropdown so the card carries a single kebab). Defaults to true.
*/
showMenu?: boolean;
/**
* Extra classes overriding the absolutely-positioned wrapper. Most
* callers can omit; campaigns historically used `top-3 right-3` while
* pledges/groups use `top-2 right-2`.
*/
className?: string;
}
/** Shared overlay body once the hide state has been resolved. */
function OverlayBody({
isHidden,
coord,
entityTitle,
surface,
axes,
badgeSize,
showMenu = true,
className,
}: Omit<ModerationOverlayProps, never> & { isHidden: boolean }) {
const wrapperClass = className ?? 'absolute top-2 right-2 z-10 flex items-center gap-1.5';
// When the menu is suppressed AND nothing is hidden, the overlay
// would render an empty positioned div. Skip render entirely so the
// banner stays clean.
if (!showMenu && !isHidden) return null;
return (
<div className={wrapperClass}>
{isHidden && <HiddenBadge size={badgeSize ?? 'compact'} />}
{showMenu && (
<ModerationMenu
coord={coord}
entityTitle={entityTitle}
surface={surface}
axes={axes}
/>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────
// Per-surface inner components. Each component mounts only the
// moderation hook for its surface, so a pledge card never subscribes to
// the campaign label query (and vice versa). Splitting the switch into
// dedicated components keeps the rules of hooks happy.
// ─────────────────────────────────────────────────────────────────────
function CampaignOverlay(props: ModerationOverlayProps) {
const { data } = useCampaignModeration();
return <OverlayBody {...props} isHidden={data.hiddenCoords.has(props.coord)} />;
}
function PledgeOverlay(props: ModerationOverlayProps) {
const { data } = usePledgeModeration();
return <OverlayBody {...props} isHidden={data.hiddenCoords.has(props.coord)} />;
}
function GroupOverlay(props: ModerationOverlayProps) {
const { data } = useOrganizationModeration();
return <OverlayBody {...props} isHidden={data.hiddenCoords.has(props.coord)} />;
}
/**
* Absolutely-positioned overlay for cards: bundles the Hidden badge
* (when the entity is hidden) and the moderator kebab in a single
* top-right corner. Returns `null` for non-moderators so non-mod grids
* never subscribe to the moderation label query at all.
*
* Consistent across campaigns, pledges, and groups — same chip, same
* kebab placement, same moderator gating, same visual order.
*
* Card containers must be `relative` for the absolute positioning to
* anchor correctly.
*/
export function ModerationOverlay(props: ModerationOverlayProps) {
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
if (!isMod) return null;
switch (props.surface) {
case 'campaign': return <CampaignOverlay {...props} />;
case 'pledge': return <PledgeOverlay {...props} />;
case 'group': return <GroupOverlay {...props} />;
}
}
@@ -0,0 +1,123 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown } from 'lucide-react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface ModeratorCollapsibleSectionProps {
/** Section icon, rendered inline with the heading. */
icon: ReactNode;
/** Section heading. */
title: string;
/** One-line subhead under the heading. */
description: string;
/** Number of items rendered in this section. Drives the count chip
* and the "auto-open when short" heuristic. */
count: number;
/** Whether the underlying data is still loading. */
isLoading: boolean;
/** Copy shown in the empty-state card when `count === 0`. */
emptyText: string;
/** Skeleton grid rendered while `isLoading && count === 0`. */
skeleton: ReactNode;
/** The actual grid of cards. Caller chooses the grid layout so the
* section adapts to per-surface card sizes. */
children: ReactNode;
/** Optional tighter heading variant for pages whose other sections
* already use the smaller scale (CommunitiesPage). */
size?: 'default' | 'compact';
/** Optional horizontal padding override for embedded layouts. The
* CommunitiesPage variant wraps the section inside a card list that
* manages its own padding, so the trigger needs `px-4 sm:px-6` to
* align with the rest of the page. CampaignsPage / ActionsPage
* pages render this inside an already-padded `<main>` and pass no
* override. */
triggerPaddingClassName?: string;
}
/**
* Collapsible moderator-only review queue used by the campaigns,
* pledges, and communities index pages. Renders a heading + count chip
* + ChevronDown trigger; the body auto-expands when the list is short
* (≤ 6 items) and starts collapsed when long.
*
* Visually identical across the three surfaces so moderators see the
* same "Pending / Hidden" affordance everywhere.
*/
export function ModeratorCollapsibleSection({
icon,
title,
description,
count,
isLoading,
emptyText,
skeleton,
children,
size = 'default',
triggerPaddingClassName,
}: ModeratorCollapsibleSectionProps) {
const [open, setOpen] = useState(count <= 6);
return (
<Collapsible open={open} onOpenChange={setOpen} asChild>
<section className="space-y-5">
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
'flex w-full items-end justify-between gap-4 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
triggerPaddingClassName,
)}
>
<div>
<h2
className={cn(
'font-bold tracking-tight inline-flex items-center gap-2',
size === 'default' ? 'text-2xl sm:text-3xl' : 'text-xl sm:text-2xl',
)}
>
<span className="text-muted-foreground">{icon}</span>
{title}
<span
className={cn(
'font-medium text-muted-foreground',
size === 'default' ? 'text-base' : 'text-sm',
)}
>
({count})
</span>
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{description}</p>
</div>
<ChevronDown
className={cn(
'size-5 text-muted-foreground motion-safe:transition-transform shrink-0',
open && 'rotate-180',
)}
aria-hidden
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{isLoading && count === 0 ? (
skeleton
) : count === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{emptyText}
</CardContent>
</Card>
) : (
children
)}
</CollapsibleContent>
</section>
</Collapsible>
);
}
+9
View File
@@ -0,0 +1,9 @@
export { HiddenBadge } from './HiddenBadge';
export {
ModerationMenu,
ModerationMenuItems,
type ModerationAxis,
type ModerationSurface,
} from './ModerationMenu';
export { ModerationOverlay } from './ModerationOverlay';
export { ModeratorCollapsibleSection } from './ModeratorCollapsibleSection';
+3 -81
View File
@@ -1,9 +1,8 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, Link as RouterLink } from 'react-router-dom';
import { Link } from 'react-router-dom';
import {
Bitcoin,
CalendarClock,
Globe,
HandHeart,
Megaphone,
@@ -11,13 +10,11 @@ import {
QrCode,
Users,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
@@ -31,6 +28,7 @@ import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/disco
import { EmojifiedText } from '@/components/CustomEmoji';
import { FollowToggleButton } from '@/components/FollowButton';
import { Nip05Badge } from '@/components/Nip05Badge';
import { PledgeCard } from '@/components/PledgeCard';
import { ProfileReactionButton } from '@/components/ProfileReactionButton';
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
@@ -42,9 +40,6 @@ import { formatNumber } from '@/lib/formatNumber';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { Action } from '@/hooks/useActions';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
interface ProfileIdentityRailProps {
pubkey: string;
@@ -806,7 +801,7 @@ function RailLatestPledgeSection({
icon={<HandHeart className="size-4 text-primary" />}
title={t('profile.sections.latestPledge')}
/>
<RailPledgeCard action={latest} btcPrice={btcPrice} />
<PledgeCard action={latest} btcPrice={btcPrice} variant="rail" />
{showSeeAll && (
<button
type="button"
@@ -820,79 +815,6 @@ function RailLatestPledgeSection({
);
}
/**
* Compact pledge card sized for the rail's narrow column. Smaller cover
* aspect, tighter padding, and a single-line pledged amount that doesn't
* dominate the rail.
*/
function RailPledgeCard({
action,
btcPrice,
}: {
action: Action;
btcPrice: number | undefined;
}) {
const { t } = useTranslation();
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const cover = sanitizeUrl(action.image) ?? DEFAULT_COVER_IMAGE;
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
const isExpired = !!deadline?.isPast;
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
return (
<RouterLink
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow group-hover:shadow-md">
<div className="relative aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={cover}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
{isExpired && (
<Badge
variant="secondary"
className="absolute top-2 right-2 backdrop-blur bg-background/85 border-border/40 text-[10px] uppercase tracking-wide text-muted-foreground"
>
{t('profile.badges.ended')}
</Badge>
)}
</div>
<div className="p-3 space-y-1.5">
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{action.title}</h3>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground uppercase tracking-wide font-semibold">{t('profile.badges.pledged')}</span>
<span className="text-foreground font-bold tabular-nums">
{formatPledgeAmount(action.bounty, btcPrice)}
</span>
</div>
{(countryLabel || deadline) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground pt-0.5">
{deadline && (
<span className={cn(
'inline-flex items-center gap-1',
deadline.isPast && 'text-destructive',
)}>
<CalendarClock className="size-3" />
{deadline.label}
</span>
)}
{countryLabel && <span className="truncate">{countryLabel}</span>}
</div>
)}
</div>
</Card>
</RouterLink>
);
}
// ─── Rail Organizations Section ─────────────────────────────────────────────
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
+4 -96
View File
@@ -1,16 +1,10 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link as RouterLink } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { HandHeart } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
import { cn } from '@/lib/utils';
import { PledgeCard } from '@/components/PledgeCard';
import type { Action } from '@/hooks/useActions';
interface ProfilePledgesTabProps {
@@ -96,7 +90,7 @@ export function ProfilePledgesTab({
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{active.map((pledge) => (
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
<PledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
))}
</div>
</section>
@@ -109,7 +103,7 @@ export function ProfilePledgesTab({
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{ended.map((pledge) => (
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
<PledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
))}
</div>
</section>
@@ -118,92 +112,6 @@ export function ProfilePledgesTab({
);
}
function ProfilePledgeCard({
action,
isExpired,
btcPrice,
}: {
action: Action;
isExpired?: boolean;
btcPrice: number | undefined;
}) {
const { t } = useTranslation();
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE;
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
return (
<RouterLink
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
{isExpired && (
<Badge
variant="secondary"
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40 text-muted-foreground"
>
{t('profile.badges.ended')}
</Badge>
)}
</div>
<div className="flex flex-col gap-3 p-5 flex-1">
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
{action.title}
</h3>
{action.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">{action.description}</p>
)}
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('profile.badges.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(action.bounty, btcPrice)}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn(
'inline-flex items-center gap-1.5',
deadline.isPast && 'text-destructive',
)}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
</div>
</Card>
</RouterLink>
);
}
function PledgesGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
+7 -1
View File
@@ -125,6 +125,11 @@ interface UseActionsOptions {
countryCode?: string;
/** Maximum number of events to request from relays. */
limit?: number;
/** When false, the underlying query never fires. Callers that gate
* the list on moderator status (or any other prerequisite) can pass
* `enabled: false` to skip the round-trip until the prerequisite
* resolves. Defaults to true. */
enabled?: boolean;
}
/**
@@ -136,11 +141,12 @@ interface UseActionsOptions {
* Pledges are user-generated. Country filtering only applies when a country
* code is provided.
*/
export function useActions({ countryCode, limit = 50 }: UseActionsOptions = {}) {
export function useActions({ countryCode, limit = 50, enabled = true }: UseActionsOptions = {}) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['agora-actions', countryCode, limit],
enabled,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
+22 -2
View File
@@ -14,6 +14,14 @@ interface UseAllCampaignsOptions {
sort: CampaignSort;
/** Already-debounced free-text search query. Empty string disables search. */
search: string;
/**
* Optional ISO 3166-1 alpha-2 country code to narrow by. When set,
* the relay query is constrained with a NIP-73 `#i` tag filter
* (`iso3166:XX` + legacy `geo:XX`) so only campaigns tagged for that
* country are returned. Picking a country with no typed query still
* produces a useful filtered grid.
*/
countryCode?: string;
/** Maximum events to fetch. Default 200. */
limit?: number;
/** Disable the query (e.g. while waiting on dependent state). */
@@ -55,19 +63,31 @@ const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
export function useAllCampaigns({
sort,
search,
countryCode,
limit = 200,
enabled = true,
}: UseAllCampaignsOptions) {
const { nostr } = useNostr();
const trimmedSearch = search.trim().toLowerCase();
const country = countryCode?.toUpperCase();
// Step 1: fetch the universe of campaigns from the default pool.
const campaignsQuery = useQuery({
queryKey: ['campaigns-all', limit],
queryKey: ['campaigns-all', limit, country ?? null],
enabled,
queryFn: async (c) => {
const filter: { kinds: number[]; limit: number; '#i'?: string[] } = {
kinds: [CAMPAIGN_KIND],
limit,
};
if (country) {
// NIP-73 `i`-tag values for the country. We send both the
// canonical `iso3166:` form and the legacy `geo:` form so
// campaigns tagged either way are returned.
filter['#i'] = [`iso3166:${country}`, `geo:${country}`];
}
const events = await nostr.query(
[{ kinds: [CAMPAIGN_KIND], limit }],
[filter],
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10_000)]) },
);
return parseCampaignEvents(events, { sortByCreatedAt: true });
+20
View File
@@ -16,6 +16,15 @@ interface CampaignDonationStats {
* reduce the number.
*/
totalSats: number;
/**
* Mempool delta in sats — the net unconfirmed amount currently sitting
* in the mempool for the campaign's `w` address. Sourced from Esplora's
* `mempool_stats.funded_txo_sum - mempool_stats.spent_txo_sum`. Counts
* every inbound mempool tx, whether or not a kind 8333 receipt has
* been published for it. Negative when the beneficiary has unconfirmed
* outgoing spends.
*/
pendingSats: number;
/** Number of unique on-chain transactions counted (from verified receipts). */
txCount: number;
/** Number of unique donor pubkeys (from verified receipts). */
@@ -24,6 +33,12 @@ interface CampaignDonationStats {
receipts: NostrEvent[];
/** Verified entries (one per unique txid). */
verified: OnchainZapEntry[];
/**
* Map of `txid → confirmed` for every verified receipt. Lets the donor
* preview / activity list mark individual rows as pending when the
* underlying Bitcoin tx is still in the mempool.
*/
confirmedByTxid: Map<string, boolean>;
/**
* True while underlying queries (address balance + receipt verification)
* are still in flight. Callers may use this to defer rendering
@@ -136,12 +151,15 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
.filter((v): v is OnchainZapEntry => !!v);
const totalSats = hasOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
const pendingSats = hasOnchain ? (addressQuery.data?.pendingBalance ?? 0) : 0;
const txids = new Set<string>();
const donors = new Set<string>();
const confirmedByTxid = new Map<string, boolean>();
for (const v of verified) {
txids.add(v.txid);
donors.add(v.senderPubkey);
confirmedByTxid.set(v.txid, v.confirmed);
}
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
@@ -155,10 +173,12 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
return {
data: {
totalSats,
pendingSats,
txCount: txids.size,
donorCount: donors.size,
receipts: sortedReceipts,
verified,
confirmedByTxid,
isVerifying,
},
isLoading: isVerifying,
+258
View File
@@ -0,0 +1,258 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { DITTO_RELAYS } from '@/lib/appRelays';
/**
* Sort modes the toolbar exposes — drives NIP-50 `sort:*` tokens and
* also whether the hook is active when the query box is empty.
*
* - `'default'`: the curated/default view. Empty query → inactive (the
* page renders its own featured/discovery layout). A typed query
* still activates a search, sorted by the relay's default ranking
* (chronological in practice).
* - `'top'`: NIP-50 `sort:top`. Empty query → active with `sort:top`
* alone, giving an engagement-ranked feed of the entire kind.
* - `'new'`: chronological. Empty query → active with the kind alone
* (no `search` field), giving a chronological "all events of this
* kind" feed. A typed query stays chronological.
*/
export type Nip50Sort = 'default' | 'top' | 'new';
interface UseNip50SearchOptions<T> {
/** Kind to search. NIP-50 search applies to events of this kind only. */
kind: number;
/** Debounced, untrimmed search query. The hook trims and gates internally. */
query: string;
/**
* Parser/validator. Return `null` for events that don't conform to the
* expected shape (missing required tags, etc.). Nulls are dropped from
* the result list.
*/
parse: (event: NostrEvent) => T | null;
/** Sort mode (see {@link Nip50Sort}). Defaults to `'default'`. */
sort?: Nip50Sort;
/** Hard cap on relay results. Default 60. */
limit?: number;
/**
* When `true` (default), addressable-event semantics are applied: results
* are deduped by `(pubkey, d-tag)` keeping the newest revision. Set to
* `false` for non-addressable kinds (e.g. kind 1 notes).
*/
addressable?: boolean;
/**
* Optional NIP-73 `i`-tag values to filter on (e.g.
* `['iso3166:US', 'geo:US']` for a country-scoped search). Forwarded
* as a standard `#i` filter alongside the `search` field, so the
* relay returns the intersection. Any single value matches (relay
* `#i` is OR-of-values).
*
* Supplying a non-empty array also **activates** the hook even when
* the query is empty and the sort is `'default'`, so picking a
* country (with no typed query) drives the page into the search/
* filtered view the same way typing a query does.
*/
iTags?: string[];
/**
* Per-event keyword sources used for client-side keyword matching when
* `query` is non-empty. Many structured kinds (34550 organizations,
* 36639 pledges, 33863 campaigns) carry the title in tags rather than
* `content`, and most NIP-50 implementations only match `content`. We
* widen the net by re-filtering the relay response against a list of
* caller-supplied strings (e.g. `title`/`name`/`summary` tag values +
* `content`). Returning `null` for an event drops it from the results.
*
* Optional — when omitted, no client-side keyword filtering is applied
* (relay results pass through unchanged).
*/
getKeywordHaystack?: (event: NostrEvent) => string[] | null;
}
interface UseNip50SearchResult<T> {
data: T[] | undefined;
isLoading: boolean;
isFetching: boolean;
/** `true` when the search hook is actively driving the page. */
isActive: boolean;
}
/**
* Generic NIP-50 search hook used by the discovery pages (Campaigns,
* Communities, Pledges). Targets the Ditto search-capable relay group
* (`DITTO_RELAYS`) explicitly rather than the default pool because most
* non-Ditto relays either ignore the `search` field (returning everything
* matching the other filters) or return nothing — both modes break the
* UX. Pinning to `nostr.group(DITTO_RELAYS)` gives a predictable result
* set; downside is search quality is bound to Ditto's index.
*
* Hybrid matching. Many structured kinds keep the human-readable
* label in tags (`title`, `name`, `summary`), not in `content`. NIP-50
* relays SHOULD match against `content` and MAY match against other
* fields, so relay-side hits alone can miss obvious matches. When a
* caller supplies `getKeywordHaystack`, the hook post-filters the
* relay response against that haystack (case-insensitive substring
* match) so the title/name/summary tags are searched too. This costs
* a small amount of false-negative recall (we still rely on the relay
* to surface the candidate event in the first place) but fixes the
* "search returns nothing" failure mode for kinds whose title lives
* outside `content`.
*
* Active states (when the hook fires a relay request):
* - keyword + any sort → relay sees `search: '<query>'` (or
* `'<query> sort:top'` for Top); client-side keyword filter runs
* over the response when `getKeywordHaystack` is supplied.
* - empty keyword + Top → `search: 'sort:top'` (a top feed).
* - empty keyword + New → no `search` field (a chronological feed
* of the kind from the relay group).
* - empty keyword + Default → hook is inactive; page renders its
* curated/default layout.
*
* `placeholderData: prev` preserves the previous result list across
* keystrokes for a less janky feel.
*/
export function useNip50Search<T>({
kind,
query,
parse,
sort = 'default',
limit = 60,
addressable = true,
iTags,
getKeywordHaystack,
}: UseNip50SearchOptions<T>): UseNip50SearchResult<T> {
const { nostr } = useNostr();
const trimmed = query.trim();
const hasQuery = trimmed.length >= 1;
const hasITags = !!iTags && iTags.length > 0;
// The hook is "active" — drives the page body — whenever:
// - The user typed something (any sort), OR
// - They picked Top or New as the sort (which both produce a flat
// feed even with an empty box), OR
// - They picked an `i`-tag filter (e.g. a country) with no other
// input — narrowing the kind by external identifier still
// deserves the filtered grid view.
// Empty + Default + no iTags is the curated fall-through case.
const enabled = hasQuery || sort === 'top' || sort === 'new' || hasITags;
// Build the NIP-50 search payload for the active cases. `undefined`
// means "don't send a `search` field at all" which is the chronological
// empty-query case.
const searchPayload: string | undefined = (() => {
if (!enabled) return undefined;
if (sort === 'top') {
return hasQuery ? `${trimmed} sort:top` : 'sort:top';
}
// 'new' or 'default' — both send the raw query when present, and
// for empty-query Top is handled above.
if (hasQuery) return trimmed;
// empty + 'new', or empty + 'default' with iTags only — no search
// field, just the kind filter (+ #i if supplied).
return undefined;
})();
// Lowercased keyword for client-side filter; kept stable across
// keystrokes by reading from the trimmed query directly.
const keyword = hasQuery ? trimmed.toLowerCase() : '';
// Stable key for the `#i` filter — sorted so reordering doesn't bust
// the cache. `null` rather than `undefined`/`[]` so the cache key
// serializes consistently.
const iTagsKey = hasITags ? [...iTags!].sort().join(',') : null;
const result = useQuery<T[]>({
queryKey: ['nip50-search', kind, searchPayload ?? null, limit, addressable, keyword, iTagsKey],
enabled,
queryFn: async ({ signal }) => {
const group = nostr.group(DITTO_RELAYS);
const filter: { kinds: number[]; limit: number; search?: string; '#i'?: string[] } = {
kinds: [kind],
limit,
};
if (searchPayload !== undefined) {
filter.search = searchPayload;
}
if (hasITags) {
filter['#i'] = iTags!;
}
const events = await group.query(
[filter],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
// Step 1: dedupe by (pubkey, d) for addressable kinds, preserving
// relay order so `sort:top` scoring sticks.
let orderedEvents: NostrEvent[];
if (addressable) {
const seenCoord = new Set<string>();
const latestByCoord = new Map<string, NostrEvent>();
// First pass: pick the newest event per coord.
for (const event of events) {
const d = event.tags.find(([n]) => n === 'd')?.[1];
if (!d) continue;
const key = `${event.pubkey}:${d}`;
const prev = latestByCoord.get(key);
if (!prev || event.created_at > prev.created_at) {
latestByCoord.set(key, event);
}
}
// Second pass: walk events in relay order and emit each coord
// once, using its latest revision.
orderedEvents = [];
for (const event of events) {
const d = event.tags.find(([n]) => n === 'd')?.[1];
if (!d) continue;
const key = `${event.pubkey}:${d}`;
if (seenCoord.has(key)) continue;
seenCoord.add(key);
const latest = latestByCoord.get(key);
if (latest) orderedEvents.push(latest);
}
} else {
orderedEvents = events;
}
// Step 2: optional client-side keyword filter. Only runs when the
// caller wired up a haystack AND the user actually typed something
// (empty keyword = nothing to match against, just pass through).
// Each haystack string is matched case-insensitively as a substring
// of the lowercased keyword. Any single match keeps the event;
// events whose haystack returns `null` are dropped (lets the
// caller pre-reject malformed events without parsing twice).
let filteredEvents: NostrEvent[];
if (getKeywordHaystack && keyword) {
filteredEvents = [];
for (const event of orderedEvents) {
const haystack = getKeywordHaystack(event);
if (haystack === null) continue;
const hit = haystack.some((field) =>
field.toLowerCase().includes(keyword),
);
if (hit) filteredEvents.push(event);
}
} else {
filteredEvents = orderedEvents;
}
// Step 3: parse and drop nulls.
const parsed: T[] = [];
for (const event of filteredEvents) {
const next = parse(event);
if (next !== null) parsed.push(next);
}
return parsed;
},
staleTime: 30_000,
placeholderData: (prev) => prev,
});
return {
data: result.data,
isLoading: result.isLoading,
isFetching: result.isFetching,
isActive: enabled,
};
}
+11 -22
View File
@@ -7,15 +7,14 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useEncryptedSettings } from './useEncryptedSettings';
import { useFollowList } from './useFollowActions';
import { LETTER_KIND } from '@/lib/letterTypes';
import { ALL_NOTIFICATION_KINDS, getEnabledNotificationKinds } from '@/lib/notificationKinds';
const PAGE_SIZE = 20;
export interface NotificationItem {
/** The notification event (kind 1, 6, 16, 7, 8, 9735, 8333, 9802, 1111, 1222, 1244, or 8211). */
/** The notification event (kind 1, 6, 16, 7, 9735, 8333, 1111, 1222, or 1244). */
event: NostrEvent;
/** The referenced event (the post that was liked/reposted/zapped/highlighted), if available. */
/** The referenced event (the post that was liked/reposted/zapped), if available. */
referencedEvent?: NostrEvent;
}
@@ -34,7 +33,7 @@ export interface GroupedNotificationItem {
key: string;
/**
* The kind that describes this group.
* 7 = reaction, 6/16 = repost, 9735/8333 = zap, 9802 = highlight, 1 = mention, 1111 = comment.
* 7 = reaction, 6/16 = repost, 9735/8333 = zap, 1 = mention, 1111 = comment.
*/
kind: number;
/** All notification events that belong to this group, newest-first. */
@@ -92,7 +91,7 @@ function getReferencedEventId(event: NostrEvent): string | undefined {
* Returns a stable "group key" for a notification item.
* Events that share the same group key will be condensed into one row.
*
* Reactions, reposts, zaps, and highlights group by (kind-bucket, referencedEventId).
* Reactions, reposts, and zaps group by (kind-bucket, referencedEventId).
* Lightning (9735) and on-chain (8333) zaps share a single "zap" bucket.
* Mentions and comments each stand alone (group key == event id).
*/
@@ -100,7 +99,7 @@ function groupKey(item: NotificationItem): string {
const { event } = item;
const refId = item.referencedEvent?.id ?? getReferencedEventId(event);
if ((event.kind === 7 || event.kind === 6 || event.kind === 16 || event.kind === 9735 || event.kind === 8333 || event.kind === 9802) && refId) {
if ((event.kind === 7 || event.kind === 6 || event.kind === 16 || event.kind === 9735 || event.kind === 8333) && refId) {
// Profile reactions (kind 7 on kind 0) are standalone — users can react
// to a profile multiple times, so each reaction gets its own notification.
const isProfileReaction = event.kind === 7 && (
@@ -110,13 +109,6 @@ function groupKey(item: NotificationItem): string {
if (isProfileReaction) {
return event.id;
}
// Highlights with different excerpts of the same post are conceptually
// distinct events (different quotes). Include the highlight event id in
// the key so multiple excerpts from the same source don't collapse into
// a misleading "X people highlighted this" row.
if (event.kind === 9802) {
return `highlight:${refId}:${event.id}`;
}
// Use a canonical kind bucket so kind-6/16 reposts merge together, and
// lightning (9735) and on-chain (8333) zaps share a single "zap" group.
const bucket = event.kind === 6 || event.kind === 16
@@ -127,7 +119,7 @@ function groupKey(item: NotificationItem): string {
return `${bucket}:${refId}`;
}
// Mentions (kind 1), comments (kind 1111), and letters (8211) are always standalone
// Mentions (kind 1) and comments (kind 1111, 1222, 1244) are always standalone
return event.id;
}
@@ -248,9 +240,9 @@ export function useNotifications(): NotificationData {
// Collect referenced event IDs for batch fetching
const referencedIds: string[] = [];
for (const ev of filtered) {
// kind 1 (mention), voice messages (1222/1244), and letters (8211) ARE the notification content;
// highlights (9802) and kind 1111 (comment) ARE the content but we also fetch the parent for context.
if (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) {
// kind 1 (mention) and voice messages (1222/1244) ARE the notification content;
// kind 1111 (comment) IS the content but we also fetch the parent for context.
if (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244) {
const refId = getReferencedEventId(ev);
if (refId) referencedIds.push(refId);
}
@@ -292,7 +284,7 @@ export function useNotifications(): NotificationData {
// Build notification items, filtering out reactions/reposts on posts the
// user didn't author (i.e. they were only tagged in them).
const items: NotificationItem[] = filtered.flatMap((ev) => {
const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) ? getReferencedEventId(ev) : undefined;
const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244) ? getReferencedEventId(ev) : undefined;
const referencedEvent = refId ? referencedMap.get(refId) : undefined;
// For reactions (7) and reposts (6, 16), only exclude if we know for
@@ -300,10 +292,7 @@ export function useNotifications(): NotificationData {
// referenced event couldn't be fetched (timeout / missing from relay),
// keep the notification — it's better to show a notification with
// missing context than to silently drop it.
//
// Highlights (9802) follow the same rule: only notify when the
// highlighted source was authored by the current user.
if (ev.kind === 7 || ev.kind === 6 || ev.kind === 16 || ev.kind === 9802) {
if (ev.kind === 7 || ev.kind === 6 || ev.kind === 16) {
if (referencedEvent && referencedEvent.pubkey !== user.pubkey) return [];
}
+133
View File
@@ -0,0 +1,133 @@
import { useNostr } from '@nostrify/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from './useNostrPublish';
import { useCampaignModerators } from './useCampaignModerators';
import {
AGORA_MODERATION_NAMESPACE,
EMPTY_MODERATION_DATA,
LABEL_KIND,
type ModerationData,
type ModerationLabel,
foldModerationLabels,
} from '@/lib/agoraModeration';
/** Pledge kind. Pinned here to keep this hook decoupled from useActions. */
export const PLEDGE_KIND = 36639;
/** Surface-scoped alias so call sites read naturally. */
type PledgeModerationData = ModerationData;
/**
* Fetches and folds pledge-moderation label events authored by Team
* Soapbox members. Returns hide / featured rollups per pledge coordinate
* (`36639:<pubkey>:<d>`).
*
* Pledges ride the same `agora.moderation` namespace and the same
* moderator pack as campaigns and organizations; we just narrow the fold
* to labels whose `a` tag points at a kind 36639 coordinate. The
* 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
* them via a Show-hidden toggle so they can unhide.
* - A pledge's detail page remains accessible by direct URL regardless
* of moderation state — moderation only governs discovery surfaces.
* - "My pledges" / author-own surfaces intentionally ignore moderation —
* a user's own pledges always render in their own listing.
* - Hide always wins over featured.
*
* The mutation `moderate({ coord, action })` publishes a single kind
* 1985 event labeling one pledge 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.
*/
export function usePledgeModeration() {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: moderators } = useCampaignModerators();
// Same gating as the other moderation hooks: never fire with an empty
// `authors:` filter, since that would return labels from any author
// and break the trust model (see AGENTS.md `nostr-security`).
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
const moderationQuery = useQuery({
queryKey: ['pledge-moderation', moderatorsKey],
enabled: moderators !== undefined,
queryFn: async ({ signal }): Promise<PledgeModerationData> => {
if (!moderators || moderators.length === 0) {
return { ...EMPTY_MODERATION_DATA, moderators: [] };
}
const events = await nostr.query(
[
{
kinds: [LABEL_KIND],
authors: moderators,
'#L': [AGORA_MODERATION_NAMESPACE],
limit: 2000,
},
],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
);
return foldModerationLabels(events, moderators, PLEDGE_KIND);
},
// Moderation labels change on human timescales. A generous staleTime
// keeps repeat visits to /pledges instant; the `moderate` mutation
// below explicitly invalidates so local changes are immediate.
staleTime: 5 * 60_000,
gcTime: 60 * 60_000,
});
const moderate = useMutation({
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
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: '',
tags: [
['L', AGORA_MODERATION_NAMESPACE],
['l', action, AGORA_MODERATION_NAMESPACE],
['a', coord],
['alt', `Pledge moderation: ${action}`],
],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pledge-moderation'] });
// Discovery queries that paint /pledges read from these caches;
// invalidate so the change reflects immediately.
queryClient.invalidateQueries({ queryKey: ['actions'] });
queryClient.invalidateQueries({ queryKey: ['organization-activity'] });
},
});
return {
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
isPending: moderationQuery.isPending,
isLoading: moderationQuery.isLoading,
isReady: moderationQuery.isSuccess,
moderate,
};
}
-2
View File
@@ -54,8 +54,6 @@ const TEMPLATE_ID_TO_PREF_KEY: Record<string, keyof NonNullable<EncryptedSetting
zaps: 'zaps',
mentions: 'mentions',
comments: 'comments',
badges: 'badges',
letters: 'letters',
};
interface UsePushNotificationsReturn {
+1
View File
@@ -87,6 +87,7 @@ const FAQ_STRUCTURE: FAQCategoryStructure[] = [
{ id: 'connect-wallet' },
{ id: 'export-wallet' },
{ id: 'donations-are-public-general' },
{ id: 'why-donations-pending' },
{ id: 'censorship-resistance' },
{ id: 'why-onchain' },
{ id: 'why-not-silent-payments' },
+1 -5
View File
@@ -7,12 +7,11 @@
*/
import type { EncryptedSettings } from '@/hooks/useEncryptedSettings';
import { LETTER_KIND } from '@/lib/letterTypes';
type NotificationPreferences = NonNullable<EncryptedSettings['notificationPreferences']>;
/** All kinds that can appear as notifications. */
export const ALL_NOTIFICATION_KINDS = [1, 6, 16, 7, 8, 9735, 8333, 9802, 1111, 1222, 1244, LETTER_KIND] as const;
export const ALL_NOTIFICATION_KINDS = [1, 6, 16, 7, 9735, 8333, 1111, 1222, 1244] as const;
/**
* Derives the set of Nostr kinds to request based on per-type preferences.
@@ -29,9 +28,6 @@ export function getEnabledNotificationKinds(
if (p.zaps !== false) kinds.push(9735, 8333);
if (p.mentions !== false) kinds.push(1);
if (p.comments !== false) kinds.push(1111, 1222, 1244);
if (p.badges !== false) kinds.push(8);
if (p.letters !== false) kinds.push(LETTER_KIND);
if (p.highlights !== false) kinds.push(9802);
// Always fall back to all kinds so the query never sends an empty kinds array
return kinds.length > 0 ? kinds : [...ALL_NOTIFICATION_KINDS];
+79 -13
View File
@@ -20,7 +20,20 @@
"goBack": "العودة",
"tryAgain": "يرجى المحاولة مرة أخرى.",
"showLess": "عرض أقل",
"readMore": "قراءة المزيد"
"readMore": "قراءة المزيد",
"byAuthor": "بواسطة <0>{{name}}</0>",
"donors_one": "{{count}} متبرع",
"donors_other": "{{count}} متبرعون",
"sortAriaLabel": "ترتيب الفرز",
"sortDefault": "افتراضي",
"sortTop": "الأبرز",
"sortNew": "الأحدث",
"showHidden": "إظهار المخفية",
"filtersAriaLabel": "مرشّحات البحث",
"countryFilterAriaLabel": "تصفية حسب البلد",
"countrySearchPlaceholder": "ابحث عن البلدان…",
"countryNoResults": "لم يتم العثور على بلدان.",
"countryGlobal": "عالمي"
},
"translate": {
"translate": "ترجمة",
@@ -166,12 +179,19 @@
"showMore": "عرض المزيد ({{count}} إضافية)",
"emptyTitle": "لا توجد تعهدات بعد",
"emptyHint": "كن أول من ينشئ تعهدًا.",
"emptyHintCountry": "كن أول من ينشئ تعهدًا لـ {{country}}."
"emptyHintCountry": "كن أول من ينشئ تعهدًا لـ {{country}}.",
"needsReview": "بحاجة إلى مراجعة",
"needsReviewDesc": "تعهدات {{appName}} التي لم تُميَّز ولم تُخفَ بعد. ارفعها إلى رف المميزة أو أخفِها بزر الإخفاء.",
"needsReviewEmpty": "لا شيء بانتظار المراجعة.",
"hidden": "مخفية",
"hiddenDesc": "تعهدات مُخفاة عن الاكتشاف العام. استخدم قائمة الكباب على البطاقة لإلغاء الإخفاء.",
"hiddenEmpty": "لا توجد تعهدات مخفية حاليًا."
},
"card": {
"ended": "منتهية",
"pledged": "متعهَّد به",
"byAuthor": "بواسطة <0>{{name}}</0>",
"actionsAriaLabel": "إجراءات التعهد",
"deletePledge": "حذف التعهد",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
@@ -186,7 +206,6 @@
"seoDescription": "أنشئ تعهد مانح لإلهام عمل ملموس على {{appName}}.",
"loginGateTitle": "سجّل الدخول لإنشاء تعهد",
"loginGateBody": "التعهدات أحداث Nostr موقّعة. تحتاج إلى تسجيل دخول Nostr لنشر تعهد.",
"backToPledges": "العودة إلى التعهدات",
"heading": "إنشاء تعهد",
"title": "العنوان",
"titlePlaceholder": "توثيق تنظيف شاطئ",
@@ -265,7 +284,9 @@
"createGroupLoginTitle": "سجّل الدخول لإنشاء مجموعة",
"createGroupLoginBody": "إنشاء مجموعة يَنشر حدث Nostr من حسابك.",
"myGroups": "مجموعاتي",
"myGroupsTagline": "المجموعات التي أسستها أو تشرف عليها أو تتابعها.",
"featuredGroups": "المجموعات المميزة",
"featuredGroupsTagline": "مجموعات بارزة تستحق اهتمامك.",
"loginToSeeTitle": "سجّل الدخول لرؤية مجموعاتك",
"loginToSeeBody": "ستظهر هنا المجموعات التي أسستها أو التي تشرف عليها.",
"noGroupsTitle": "لا توجد مجموعات بعد",
@@ -295,7 +316,6 @@
"seoDescriptionEdit": "حدّث مجموعتك على {{appName}}.",
"loginGateTitle": "سجّل الدخول لبدء مجموعة",
"loginGateBody": "المجموعات أحداث Nostr موقّعة. تحتاج إلى تسجيل دخول Nostr لنشر مجموعة.",
"backToGroups": "العودة إلى المجموعات",
"invalidEditTitle": "رابط تعديل غير صالح",
"invalidEditBody": "رابط تعديل المجموعة هذا لا يحمل عنوان مجموعة صالحًا.",
"startNewGroup": "بدء مجموعة جديدة",
@@ -384,7 +404,6 @@
"seoDescriptionEdit": "حدّث حملتك التمويلية على {{appName}}.",
"loginGateTitle": "سجّل الدخول لبدء حملة",
"loginGateBody": "الحملات أحداث Nostr موقّعة. تحتاج إلى تسجيل دخول Nostr لنشر حملة.",
"goHome": "العودة للصفحة الرئيسية",
"invalidEditTitle": "رابط تعديل غير صالح",
"invalidEditBody": "رابط تعديل الحملة هذا لا يحمل عنوان حملة صالحًا.",
"startNewCampaign": "بدء حملة جديدة",
@@ -514,7 +533,6 @@
"seoDescription": "أنشئ حدث تقويم على {{appName}}.",
"loginTitle": "سجّل الدخول لإنشاء حدث",
"loginBody": "الأحداث هي أحداث Nostr موقّعة. تحتاج إلى تسجيل دخول Nostr لنشر حدث.",
"backToEvents": "العودة إلى الأحداث",
"heading": "إنشاء حدث",
"titlePlaceholder": "تنظيف الحي",
"descriptionPlaceholder": "أخبر الناس بما يمكن توقعه، وما يجب إحضاره، ومن ينبغي أن يحضر...",
@@ -615,6 +633,13 @@
"title": "كل الحملات",
"seoTitle": "كل الحملات",
"description": "تصفّح كل الحملات المنشورة على Agora.",
"sectionTagline": "تصفّح كل قضيّة على الشبكة.",
"heroKicker": "الحملات",
"heroHeading": "كل قضيّة،",
"heroHeadingLine2": "في مكان واحد.",
"heroBody": "كل حملة تمويل منشورة على Nostr، مجموعةً في مكان واحد. تصفّح الشبكة بأكملها، اعثر على قضيّة تهمّك، وادعمها مباشرةً بالبتكوين.",
"campaignsCount_one": "حملة على الشبكة",
"campaignsCount_other": "حملات على الشبكة",
"searchAriaLabel": "البحث في الحملات",
"searchPlaceholder": "ابحث في الحملات…",
"clearSearch": "مسح البحث",
@@ -631,6 +656,31 @@
"emptyHint": "لم تُنشَر أي حملة بعد. كن الأول."
}
},
"moderation": {
"hiddenBadge": "مخفية",
"menu": {
"label": "إجراءات المشرف",
"ariaCampaign": "إدارة الحملة",
"ariaPledge": "إدارة التعهد",
"ariaGroup": "إدارة المجموعة",
"failedAction": "فشل {{action}}",
"approve": "اعتماد",
"unapprove": "إلغاء الاعتماد",
"approvedState": "معتمدة",
"hide": "إخفاء",
"unhide": "إلغاء الإخفاء",
"hiddenState": "مخفية",
"feature": "تمييز",
"unfeature": "إلغاء التمييز",
"featuredState": "مميّزة",
"toastApproved": "تم الاعتماد للصفحة الرئيسية",
"toastUnapproved": "أُزيلت من الصفحة الرئيسية",
"toastHidden": "تم الإخفاء",
"toastUnhidden": "تم إلغاء الإخفاء",
"toastFeatured": "تم التمييز",
"toastUnfeatured": "أُزيلت من المميزة"
}
},
"settings": {
"title": "الإعدادات",
"description": "إدارة إعدادات {{appName}} الخاصة بك",
@@ -757,7 +807,7 @@
"scanForNew": "فحص المدفوعات الجديدة",
"scanForPayments": "فحص المدفوعات"
},
"tx": {
"tx": {
"pending": "قيد المعالجة",
"today": "اليوم",
"yesterday": "الأمس",
@@ -1219,7 +1269,7 @@
"tabs": {
"agora": "أغورا",
"nostr": "Nostr",
"accounts": "الحسابات"
"accounts": "المستخدمون"
},
"filters": {
"title": "المرشحات",
@@ -1292,7 +1342,7 @@
"empty": {
"posts": "لا توجد نتائج تطابق بحثك.",
"postsPrompt": "أدخل استعلام بحث للعثور على محتوى Nostr.",
"accounts": "لم يتم العثور على حسابات تطابق بحثك.",
"accounts": "لم يتم العثور على مستخدمين يطابقون بحثك.",
"followsPrompt": "ابحث عن أشخاص بالاسم أو بعنوان NIP-05.",
"agora": "لم يتم العثور على حملات أو تعهّدات أو مجموعات Agora تطابق بحثك.",
"agoraPrompt": "ابحث في حملات وتعهّدات ومجموعات Agora، أو تصفّح الأحدث.",
@@ -1972,10 +2022,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "عن Agora" },
"payments": { "label": "تبرعات البيتكوين على Agora" },
"about-nostr": { "label": "عن Nostr" },
"legacy": { "label": "محتوى قديم" }
"getting-started": {
"label": "عن Agora"
},
"payments": {
"label": "تبرعات البيتكوين على Agora"
},
"about-nostr": {
"label": "عن Nostr"
},
"legacy": {
"label": "محتوى قديم"
}
},
"items": {
"what-is-ditto": {
@@ -2034,6 +2092,14 @@
"عندما تقبل الحملة كلتيهما، تقرر محفظة المتبرع أيّ مسار تستخدم — المحافظ القادرة على المدفوعات الصامتة تدفع خاصةً، والباقي يدفع إلى العنوان العلني. اقرأ **دليل المتبرع** و**دليل الناشط** للحصول على الصورة الكاملة."
]
},
"why-donations-pending": {
"question": "لماذا تظهر بعض التبرعات بحالة \"قيد الانتظار\"؟",
"answer": [
"التبرع **قيد الانتظار** هو بيتكوين حقيقي تم إرساله بالفعل — فقط ينتظر تأكيده من الشبكة. محفظة المتبرع قد بثّت المعاملة، لكنها لم تُدرج بعد في كتلة.",
"ينتج البيتكوين كتلة جديدة كل عشر دقائق تقريبًا، ويختار المعدّنون المعاملات بناءً على الرسوم التي دفعها المتبرع. معظم التبرعات تتأكد خلال ساعة؛ أما تلك ذات الرسوم المنخفضة فقد تستغرق وقتًا أطول عندما تكون الشبكة مزدحمة. يعيد {{appName}} التحقق من حالة التأكيد تلقائيًا، لذا تختفي علامة **قيد الانتظار** من تلقاء نفسها بمجرد إدراج المعاملة في كتلة.",
"الأموال في طريقها — سيرى الناشط التبرع يُحتسب ضمن إجمالي الحملة فور تأكيده. لا يوجد شيء يمكن للناشط أو لـ {{appName}} فعله لتسريع ذلك؛ محفظة المتبرع وحدها هي التي يمكنها رفع الرسوم."
]
},
"censorship-resistance": {
"question": "ماذا تعني \"المقاومة للرقابة\" هنا؟",
"answer": [
+82 -8
View File
@@ -20,7 +20,24 @@
"goBack": "Go back",
"tryAgain": "Please try again.",
"showLess": "Show less",
"readMore": "Read more"
"readMore": "Read more",
"byAuthor": "by <0>{{name}}</0>",
"donors_one": "{{count}} donor",
"donors_other": "{{count}} donors",
"clearSearch": "Clear search",
"searching": "Searching\u2026",
"searchResultsCount_one": "{{count}} result",
"searchResultsCount_other": "{{count}} results",
"sortAriaLabel": "Sort order",
"sortDefault": "Default",
"sortTop": "Top",
"sortNew": "New",
"showHidden": "Show hidden",
"filtersAriaLabel": "Search filters",
"countryFilterAriaLabel": "Filter by country",
"countrySearchPlaceholder": "Search countries\u2026",
"countryNoResults": "No countries found.",
"countryGlobal": "Global"
},
"translate": {
"translate": "Translate",
@@ -573,6 +590,10 @@
"sectionPast": "Past pledges",
"sectionDefault": "Pledges",
"sectionTagline": "Help fund the actions worth making.",
"searchPlaceholder": "Search pledges\u2026",
"searchAriaLabel": "Search pledges",
"noMatch": "No pledges match \u201c{{query}}\u201d",
"noMatchHint": "Try a different search term, or clear the search.",
"sortAriaLabel": "Sort",
"sortBy": "Sort by",
"sortRecent": "Most recent",
@@ -589,12 +610,19 @@
"showMore": "Show more ({{count}} more)",
"emptyTitle": "No pledges yet",
"emptyHint": "Be the first to create a pledge.",
"emptyHintCountry": "Be the first to create a pledge for {{country}}."
"emptyHintCountry": "Be the first to create a pledge for {{country}}.",
"needsReview": "Needs review",
"needsReviewDesc": "{{appName}} pledges that haven't been featured or hidden yet. Lift one into the Featured shelf or suppress it with Hide.",
"needsReviewEmpty": "Nothing awaiting review.",
"hidden": "Hidden",
"hiddenDesc": "Pledges suppressed from public discovery. Use the kebab menu on a card to unhide.",
"hiddenEmpty": "No pledges are currently hidden."
},
"card": {
"ended": "Ended",
"pledged": "Pledged",
"byAuthor": "by <0>{{name}}</0>",
"actionsAriaLabel": "Pledge actions",
"deletePledge": "Delete pledge",
"copyLink": "Copy link",
"linkCopied": "Link copied",
@@ -609,7 +637,6 @@
"seoDescription": "Create a donor pledge to inspire concrete action on {{appName}}.",
"loginGateTitle": "Log in to create a pledge",
"loginGateBody": "Pledges are signed Nostr events. You need a Nostr login to publish one.",
"backToPledges": "Back to pledges",
"heading": "Create pledge",
"title": "Title",
"titlePlaceholder": "Document a beach cleanup",
@@ -688,7 +715,13 @@
"createGroupLoginTitle": "Log in to create a group",
"createGroupLoginBody": "Creating a group publishes a Nostr event from your account.",
"myGroups": "My groups",
"myGroupsTagline": "Groups you've founded, moderate, or follow.",
"featuredGroups": "Featured groups",
"featuredGroupsTagline": "Standout groups worth your attention.",
"searchPlaceholder": "Search groups\u2026",
"searchAriaLabel": "Search groups",
"noMatch": "No groups match \u201c{{query}}\u201d",
"noMatchHint": "Try a different search term, or clear the search.",
"loginToSeeTitle": "Log in to see your groups",
"loginToSeeBody": "Groups you've founded or moderate will appear here.",
"noGroupsTitle": "No groups yet",
@@ -718,7 +751,6 @@
"seoDescriptionEdit": "Update your group on {{appName}}.",
"loginGateTitle": "Log in to start a group",
"loginGateBody": "Groups are signed Nostr events. You need a Nostr login to publish one.",
"backToGroups": "Back to groups",
"invalidEditTitle": "Invalid edit link",
"invalidEditBody": "This group edit link is missing a valid group address.",
"startNewGroup": "Start a new group",
@@ -807,7 +839,6 @@
"seoDescriptionEdit": "Update your fundraising campaign on {{appName}}.",
"loginGateTitle": "Log in to start a campaign",
"loginGateBody": "Campaigns are signed Nostr events. You need a Nostr login to publish one.",
"goHome": "Go home",
"invalidEditTitle": "Invalid edit link",
"invalidEditBody": "This campaign edit link is missing a valid campaign address.",
"startNewCampaign": "Start a new campaign",
@@ -937,7 +968,6 @@
"seoDescription": "Create a calendar event on {{appName}}.",
"loginTitle": "Log in to create an event",
"loginBody": "Events are signed Nostr events. You need a Nostr login to publish one.",
"backToEvents": "Back to events",
"heading": "Create event",
"titlePlaceholder": "Neighborhood cleanup",
"descriptionPlaceholder": "Tell people what to expect, what to bring, and who should attend...",
@@ -1023,6 +1053,10 @@
"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.",
@@ -1038,6 +1072,13 @@
"title": "All Campaigns",
"seoTitle": "All campaigns",
"description": "Browse every campaign published on Agora.",
"sectionTagline": "Browse every cause on the network.",
"heroKicker": "Campaigns",
"heroHeading": "Every cause,",
"heroHeadingLine2": "in one place.",
"heroBody": "Every fundraiser published on Nostr, gathered into one place. Browse the full network, find a cause that matters to you, and back it directly with Bitcoin.",
"campaignsCount_one": "campaign on the network",
"campaignsCount_other": "campaigns on the network",
"searchAriaLabel": "Search campaigns",
"searchPlaceholder": "Search campaigns…",
"clearSearch": "Clear search",
@@ -1054,6 +1095,31 @@
"emptyHint": "No campaigns have been published yet. Be the first."
}
},
"moderation": {
"hiddenBadge": "Hidden",
"menu": {
"label": "Moderator actions",
"ariaCampaign": "Moderate campaign",
"ariaPledge": "Moderate pledge",
"ariaGroup": "Moderate group",
"failedAction": "Failed to {{action}}",
"approve": "Approve",
"unapprove": "Unapprove",
"approvedState": "Approved",
"hide": "Hide",
"unhide": "Unhide",
"hiddenState": "Hidden",
"feature": "Feature",
"unfeature": "Unfeature",
"featuredState": "Featured",
"toastApproved": "Approved for homepage",
"toastUnapproved": "Removed from homepage",
"toastHidden": "Hidden",
"toastUnhidden": "Unhidden",
"toastFeatured": "Featured",
"toastUnfeatured": "Removed from featured"
}
},
"settings": {
"title": "Settings",
"description": "Manage your {{appName}} settings",
@@ -1642,7 +1708,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Accounts"
"accounts": "Users"
},
"filters": {
"title": "Filters",
@@ -1715,7 +1781,7 @@
"empty": {
"posts": "No results found matching your search.",
"postsPrompt": "Enter a search query to find Nostr content.",
"accounts": "No accounts found matching your search.",
"accounts": "No users found matching your search.",
"followsPrompt": "Search for people by name or NIP-05 address.",
"agora": "No Agora campaigns, pledges, or groups found matching your search.",
"agoraPrompt": "Search Agora campaigns, pledges, and groups, or browse the latest.",
@@ -2025,6 +2091,14 @@
"When a campaign accepts both, the donor's wallet decides which path to use — silent-payment-capable wallets pay privately, others pay the public address. Read the **Donor Guide** and **Activist Guide** for the full picture."
]
},
"why-donations-pending": {
"question": "Why do some donations say \"pending\"?",
"answer": [
"A **pending** donation is real Bitcoin that's already been sent — it's just waiting to be confirmed by the network. The donor's wallet has broadcast the transaction, but it hasn't been included in a block yet.",
"Bitcoin produces a new block roughly every 10 minutes, and miners pick transactions based on the fee the donor paid. Most donations confirm within an hour; lower-fee ones can take longer when the network is busy. {{appName}} re-checks confirmation status automatically, so the **pending** label disappears on its own once a block lands.",
"The funds are on the way — the activist will see the donation count toward the campaign total as soon as it confirms. There's nothing the activist or {{appName}} can do to speed it up; only the donor's wallet can bump the fee."
]
},
"censorship-resistance": {
"question": "What does \"censorship-resistant\" mean here?",
"answer": [
+84 -10
View File
@@ -20,7 +20,24 @@
"goBack": "Volver",
"tryAgain": "Por favor, inténtalo de nuevo.",
"showLess": "Mostrar menos",
"readMore": "Leer más"
"readMore": "Leer más",
"byAuthor": "por <0>{{name}}</0>",
"donors_one": "{{count}} donante",
"donors_other": "{{count}} donantes",
"clearSearch": "Borrar búsqueda",
"searching": "Buscando…",
"searchResultsCount_one": "{{count}} resultado",
"searchResultsCount_other": "{{count}} resultados",
"sortAriaLabel": "Orden",
"sortDefault": "Predeterminado",
"sortTop": "Destacadas",
"sortNew": "Nuevas",
"showHidden": "Mostrar ocultas",
"filtersAriaLabel": "Filtros de búsqueda",
"countryFilterAriaLabel": "Filtrar por país",
"countrySearchPlaceholder": "Buscar países…",
"countryNoResults": "No se encontraron países.",
"countryGlobal": "Global"
},
"translate": {
"translate": "Traducir",
@@ -166,12 +183,23 @@
"showMore": "Mostrar más ({{count}} más)",
"emptyTitle": "Aún no hay promesas",
"emptyHint": "Sé el primero en crear una promesa.",
"emptyHintCountry": "Sé el primero en crear una promesa para {{country}}."
"emptyHintCountry": "Sé el primero en crear una promesa para {{country}}.",
"searchPlaceholder": "Buscar promesas…",
"searchAriaLabel": "Buscar promesas",
"noMatch": "Ninguna promesa coincide con «{{query}}»",
"noMatchHint": "Prueba con otro término de búsqueda o bórrala.",
"needsReview": "Pendiente de revisión",
"needsReviewDesc": "Promesas de {{appName}} que aún no han sido destacadas ni ocultadas. Súbelas a la sección destacada o suprímelas con Ocultar.",
"needsReviewEmpty": "Nada pendiente de revisión.",
"hidden": "Ocultas",
"hiddenDesc": "Promesas suprimidas del descubrimiento público. Usa el menú de la tarjeta para mostrarlas de nuevo.",
"hiddenEmpty": "No hay promesas ocultas actualmente."
},
"card": {
"ended": "Finalizada",
"pledged": "Prometido",
"byAuthor": "por <0>{{name}}</0>",
"actionsAriaLabel": "Acciones de la promesa",
"deletePledge": "Eliminar promesa",
"copyLink": "Copiar enlace",
"linkCopied": "Enlace copiado",
@@ -186,7 +214,6 @@
"seoDescription": "Crea una promesa de donante para inspirar acciones concretas en {{appName}}.",
"loginGateTitle": "Inicia sesión para crear una promesa",
"loginGateBody": "Las promesas son eventos firmados de Nostr. Necesitas una sesión de Nostr para publicar una.",
"backToPledges": "Volver a promesas",
"heading": "Crear promesa",
"title": "Título",
"titlePlaceholder": "Documentar una limpieza de playa",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "Inicia sesión para crear un grupo",
"createGroupLoginBody": "Crear un grupo publica un evento de Nostr desde tu cuenta.",
"myGroups": "Mis grupos",
"myGroupsTagline": "Grupos que fundaste, moderas o sigues.",
"featuredGroups": "Grupos destacados",
"featuredGroupsTagline": "Grupos destacados que merecen tu atención.",
"loginToSeeTitle": "Inicia sesión para ver tus grupos",
"loginToSeeBody": "Los grupos que fundaste o moderas aparecerán aquí.",
"noGroupsTitle": "Aún no hay grupos",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "grupo destacado en Nostr",
"tickerFeaturedGroups_other": "grupos destacados en Nostr",
"tickerCountries_one": "país publicando hoy",
"tickerCountries_other": "países publicando hoy"
"tickerCountries_other": "países publicando hoy",
"searchPlaceholder": "Buscar grupos…",
"searchAriaLabel": "Buscar grupos",
"noMatch": "Ningún grupo coincide con «{{query}}»",
"noMatchHint": "Prueba con otro término de búsqueda o bórrala."
},
"create": {
"seoTitleCreate": "Crear grupo",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "Actualiza tu grupo en {{appName}}.",
"loginGateTitle": "Inicia sesión para crear un grupo",
"loginGateBody": "Los grupos son eventos firmados de Nostr. Necesitas una sesión de Nostr para publicar uno.",
"backToGroups": "Volver a grupos",
"invalidEditTitle": "Enlace de edición inválido",
"invalidEditBody": "Este enlace de edición de grupo no incluye una dirección válida.",
"startNewGroup": "Crear un grupo nuevo",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "Actualiza tu campaña de recaudación en {{appName}}.",
"loginGateTitle": "Inicia sesión para iniciar una campaña",
"loginGateBody": "Las campañas son eventos firmados de Nostr. Necesitas una sesión de Nostr para publicar una.",
"goHome": "Ir al inicio",
"invalidEditTitle": "Enlace de edición inválido",
"invalidEditBody": "Este enlace de edición de campaña no incluye una dirección válida.",
"startNewCampaign": "Iniciar una campaña nueva",
@@ -514,7 +545,6 @@
"seoDescription": "Crea un evento de calendario en {{appName}}.",
"loginTitle": "Inicia sesión para crear un evento",
"loginBody": "Los eventos son eventos firmados de Nostr. Necesitas iniciar sesión en Nostr para publicar uno.",
"backToEvents": "Volver a eventos",
"heading": "Crear evento",
"titlePlaceholder": "Limpieza del barrio",
"descriptionPlaceholder": "Cuenta qué esperar, qué llevar y quién debería asistir...",
@@ -609,12 +639,23 @@
"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.",
"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."
"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…",
"searchAriaLabel": "Buscar campañas",
"noMatch": "Ninguna campaña coincide con «{{query}}»",
"noMatchHint": "Prueba con otro término de búsqueda o bórrala."
},
"all": {
"title": "Todas las campañas",
"seoTitle": "Todas las campañas",
"description": "Explora todas las campañas publicadas en Agora.",
"sectionTagline": "Explora cada causa en la red.",
"heroKicker": "Campañas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "en un solo lugar.",
"heroBody": "Cada recaudación publicada en Nostr, reunida en un solo lugar. Explora toda la red, encuentra una causa que te importe y apóyala directamente con Bitcoin.",
"campaignsCount_one": "campaña en la red",
"campaignsCount_other": "campañas en la red",
"searchAriaLabel": "Buscar campañas",
"searchPlaceholder": "Buscar campañas…",
"clearSearch": "Borrar búsqueda",
@@ -631,6 +672,31 @@
"emptyHint": "Todavía no se ha publicado ninguna campaña. Sé el primero."
}
},
"moderation": {
"hiddenBadge": "Oculto",
"menu": {
"label": "Acciones de moderador",
"ariaCampaign": "Moderar campaña",
"ariaPledge": "Moderar promesa",
"ariaGroup": "Moderar grupo",
"failedAction": "No se pudo {{action}}",
"approve": "Aprobar",
"unapprove": "Desaprobar",
"approvedState": "Aprobado",
"hide": "Ocultar",
"unhide": "Mostrar",
"hiddenState": "Oculto",
"feature": "Destacar",
"unfeature": "Quitar de destacados",
"featuredState": "Destacado",
"toastApproved": "Aprobado para la página de inicio",
"toastUnapproved": "Eliminado de la página de inicio",
"toastHidden": "Ocultado",
"toastUnhidden": "Restaurado",
"toastFeatured": "Destacado",
"toastUnfeatured": "Eliminado de destacados"
}
},
"settings": {
"title": "Ajustes",
"description": "Administra tus ajustes de {{appName}}",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "Ágora",
"nostr": "Nostr",
"accounts": "Cuentas"
"accounts": "Usuarios"
},
"filters": {
"title": "Filtros",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "No se encontraron resultados para tu búsqueda.",
"postsPrompt": "Introduce una consulta para buscar contenido de Nostr.",
"accounts": "No se encontraron cuentas para tu búsqueda.",
"accounts": "No se encontraron usuarios para tu búsqueda.",
"followsPrompt": "Busca personas por nombre o dirección NIP-05.",
"agora": "No se encontraron campañas, pledges ni grupos de Ágora para tu búsqueda.",
"agoraPrompt": "Busca campañas, pledges y grupos de Ágora, o explora lo más reciente.",
@@ -2042,6 +2108,14 @@
"Cuando una campaña acepta ambos, la wallet del donante decide qué vía usar — las wallets compatibles con silent payments pagan de forma privada, las demás pagan la dirección pública. Lee la **Guía del donante** y la **Guía del activista** para el panorama completo."
]
},
"why-donations-pending": {
"question": "¿Por qué algunas donaciones aparecen como «pendientes»?",
"answer": [
"Una donación **pendiente** es Bitcoin real que ya se ha enviado — solo está esperando a que la red lo confirme. La wallet del donante ya ha transmitido la transacción, pero todavía no se ha incluido en un bloque.",
"Bitcoin produce un bloque nuevo aproximadamente cada 10 minutos, y los mineros eligen las transacciones según la comisión que pagó el donante. La mayoría de las donaciones se confirman en menos de una hora; las que tienen una comisión más baja pueden tardar más cuando la red está congestionada. {{appName}} vuelve a comprobar el estado de la confirmación automáticamente, por lo que la etiqueta de **pendiente** desaparece sola en cuanto entra un bloque.",
"Los fondos están en camino — el activista verá la donación contabilizada en el total de la campaña en cuanto se confirme. No hay nada que el activista ni {{appName}} puedan hacer para acelerarlo; solo la wallet del donante puede subir la comisión."
]
},
"censorship-resistance": {
"question": "¿Qué significa aquí \"resistente a la censura\"?",
"answer": [
+97 -15
View File
@@ -20,7 +20,24 @@
"goBack": "بازگشت",
"tryAgain": "لطفاً دوباره تلاش کنید.",
"showLess": "نمایش کمتر",
"readMore": "بیشتر بخوانید"
"readMore": "بیشتر بخوانید",
"byAuthor": "توسط <0>{{name}}</0>",
"donors_one": "{{count}} اهداکننده",
"donors_other": "{{count}} اهداکننده",
"clearSearch": "پاک کردن جستجو",
"searching": "در حال جستجو…",
"searchResultsCount_one": "{{count}} نتیجه",
"searchResultsCount_other": "{{count}} نتیجه",
"sortAriaLabel": "ترتیب",
"sortDefault": "پیش‌فرض",
"sortTop": "برتر",
"sortNew": "جدید",
"showHidden": "نمایش پنهان‌شده‌ها",
"filtersAriaLabel": "فیلترهای جستجو",
"countryFilterAriaLabel": "فیلتر بر اساس کشور",
"countrySearchPlaceholder": "جستجوی کشورها…",
"countryNoResults": "هیچ کشوری پیدا نشد.",
"countryGlobal": "جهانی"
},
"translate": {
"translate": "ترجمه",
@@ -166,12 +183,23 @@
"showMore": "نمایش بیشتر ({{count}} مورد دیگر)",
"emptyTitle": "هنوز تعهدی نیست",
"emptyHint": "اولین کسی باش که تعهد می‌سازد.",
"emptyHintCountry": "اولین کسی باش که برای {{country}} تعهدی می‌سازد."
"emptyHintCountry": "اولین کسی باش که برای {{country}} تعهدی می‌سازد.",
"searchPlaceholder": "جستجوی تعهدها…",
"searchAriaLabel": "جستجوی تعهدها",
"noMatch": "هیچ تعهدی با «{{query}}» مطابقت ندارد",
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید.",
"needsReview": "نیازمند بررسی",
"needsReviewDesc": "تعهدهای {{appName}} که هنوز ویژه یا پنهان نشده‌اند. آن‌ها را به قفسهٔ ویژه ببر یا با پنهان‌سازی سرکوب کن.",
"needsReviewEmpty": "چیزی منتظر بررسی نیست.",
"hidden": "پنهان",
"hiddenDesc": "تعهدهای پنهان‌شده از کشف عمومی. از منوی کبابی روی کارت برای آشکارسازی استفاده کن.",
"hiddenEmpty": "در حال حاضر تعهد پنهانی وجود ندارد."
},
"card": {
"ended": "پایان‌یافته",
"pledged": "متعهد شده",
"byAuthor": "توسط <0>{{name}}</0>",
"actionsAriaLabel": "کنش‌های تعهد",
"deletePledge": "حذف تعهد",
"copyLink": "کپی کردن پیوند",
"linkCopied": "پیوند کپی شد",
@@ -186,7 +214,6 @@
"seoDescription": "برای الهام‌بخشی به کنشی مشخص در {{appName}}، یک تعهد اهداکننده بسازید.",
"loginGateTitle": "برای ایجاد تعهد وارد شوید",
"loginGateBody": "تعهدها رویدادهای امضاشدهٔ Nostr هستند. برای انتشار یک تعهد به ورود Nostr نیاز دارید.",
"backToPledges": "بازگشت به تعهدها",
"heading": "ایجاد تعهد",
"title": "عنوان",
"titlePlaceholder": "مستندسازی پاکسازی ساحل",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "برای ساخت گروه وارد شوید",
"createGroupLoginBody": "ساخت گروه یک رویداد Nostr از حساب شما منتشر می‌کند.",
"myGroups": "گروه‌های من",
"myGroupsTagline": "گروه‌هایی که ساخته‌ای، مدیریت می‌کنی یا دنبال می‌کنی.",
"featuredGroups": "گروه‌های ویژه",
"featuredGroupsTagline": "گروه‌های برجسته‌ای که ارزش توجه تو را دارند.",
"loginToSeeTitle": "برای دیدن گروه‌هایت وارد شو",
"loginToSeeBody": "گروه‌هایی که ساخته‌ای یا مدیریت می‌کنی اینجا ظاهر می‌شوند.",
"noGroupsTitle": "هنوز گروهی نیست",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "گروه ویژه در Nostr",
"tickerFeaturedGroups_other": "گروه ویژه در Nostr",
"tickerCountries_one": "کشور امروز در حال انتشار",
"tickerCountries_other": "کشور امروز در حال انتشار"
"tickerCountries_other": "کشور امروز در حال انتشار",
"searchPlaceholder": "جستجوی گروه‌ها…",
"searchAriaLabel": "جستجوی گروه‌ها",
"noMatch": "هیچ گروهی با «{{query}}» مطابقت ندارد",
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید."
},
"create": {
"seoTitleCreate": "ساخت گروه",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "گروهت را در {{appName}} به‌روز کن.",
"loginGateTitle": "برای ساخت گروه وارد شو",
"loginGateBody": "گروه‌ها رویدادهای امضاشدهٔ Nostr هستند. برای انتشار به ورود Nostr نیاز داری.",
"backToGroups": "بازگشت به گروه‌ها",
"invalidEditTitle": "پیوند ویرایش نامعتبر",
"invalidEditBody": "این پیوند ویرایش گروه نشانی معتبر گروه را ندارد.",
"startNewGroup": "ساخت گروه تازه",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "کمپین جمع‌آوری مالی‌ات را روی {{appName}} به‌روز کن.",
"loginGateTitle": "برای شروع کمپین وارد شو",
"loginGateBody": "کمپین‌ها رویدادهای امضاشدهٔ Nostr هستند. برای انتشار به ورود Nostr نیاز داری.",
"goHome": "رفتن به خانه",
"invalidEditTitle": "پیوند ویرایش نامعتبر",
"invalidEditBody": "این پیوند ویرایش کمپین نشانی معتبر کمپین را ندارد.",
"startNewCampaign": "شروع کمپین تازه",
@@ -514,7 +545,6 @@
"seoDescription": "یک رویداد تقویمی در {{appName}} ایجاد کنید.",
"loginTitle": "برای ایجاد رویداد وارد شوید",
"loginBody": "رویدادها، رویدادهای امضاشدهٔ Nostr هستند. برای انتشار باید وارد Nostr شوید.",
"backToEvents": "بازگشت به رویدادها",
"heading": "ایجاد رویداد",
"titlePlaceholder": "پاک‌سازی محله",
"descriptionPlaceholder": "به مردم بگویید چه انتظاری داشته باشند، چه چیزی بیاورند، و چه کسانی باید شرکت کنند...",
@@ -609,12 +639,23 @@
"yourCampaigns": "کمپین‌های شما",
"yourCampaignsDesc": "کمپین‌های شما در Nostr فعال هستند و کمک‌های مالی از طریق لینک کار می‌کنند. به محض تأیید توسط یک ناظر تیم Soapbox، در صفحه اصلی ظاهر می‌شوند.",
"empty": "هنوز کمپینی وجود ندارد",
"emptyHint": "اولین نفری باشید که در {{appName}} کمپین راه‌اندازی می‌کند. داستان خود را بگویید، ذی‌نفعان را انتخاب کنید، و لینک را به اشتراک بگذارید."
"emptyHint": "اولین نفری باشید که در {{appName}} کمپین راه‌اندازی می‌کند. داستان خود را بگویید، ذی‌نفعان را انتخاب کنید، و لینک را به اشتراک بگذارید.",
"searchPlaceholder": "جستجوی کمپین‌ها…",
"searchAriaLabel": "جستجوی کمپین‌ها",
"noMatch": "هیچ کمپینی با «{{query}}» مطابقت ندارد",
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید."
},
"all": {
"title": "همه کمپین‌ها",
"seoTitle": "همه کمپین‌ها",
"description": "همه کمپین‌های منتشرشده در Agora را مرور کنید.",
"sectionTagline": "هر هدفی را در شبکه مرور کنید.",
"heroKicker": "کمپین‌ها",
"heroHeading": "هر هدف،",
"heroHeadingLine2": "در یک جا.",
"heroBody": "هر کمپین جذب کمک مالی که روی Nostr منتشر شده، گرد هم در یک جا. کل شبکه را مرور کنید، هدفی را که برایتان مهم است پیدا کنید و مستقیماً با بیت‌کوین از آن حمایت کنید.",
"campaignsCount_one": "کمپین در شبکه",
"campaignsCount_other": "کمپین در شبکه",
"searchAriaLabel": "جستجوی کمپین‌ها",
"searchPlaceholder": "جستجوی کمپین‌ها…",
"clearSearch": "پاک کردن جستجو",
@@ -631,6 +672,31 @@
"emptyHint": "هنوز هیچ کمپینی منتشر نشده است. اولین نفر باشید."
}
},
"moderation": {
"hiddenBadge": "پنهان",
"menu": {
"label": "اقدامات ناظر",
"ariaCampaign": "نظارت بر کمپین",
"ariaPledge": "نظارت بر تعهد",
"ariaGroup": "نظارت بر گروه",
"failedAction": "{{action}} ناموفق بود",
"approve": "تأیید",
"unapprove": "لغو تأیید",
"approvedState": "تأییدشده",
"hide": "پنهان کردن",
"unhide": "آشکار کردن",
"hiddenState": "پنهان",
"feature": "ویژه کردن",
"unfeature": "لغو ویژه‌سازی",
"featuredState": "ویژه",
"toastApproved": "برای صفحه اصلی تأیید شد",
"toastUnapproved": "از صفحه اصلی حذف شد",
"toastHidden": "پنهان شد",
"toastUnhidden": "آشکار شد",
"toastFeatured": "ویژه شد",
"toastUnfeatured": "از فهرست ویژه‌ها حذف شد"
}
},
"settings": {
"title": "تنظیمات",
"description": "مدیریت تنظیمات {{appName}} شما",
@@ -757,7 +823,7 @@
"scanForNew": "پویش پرداخت‌های جدید",
"scanForPayments": "پویش پرداخت‌ها"
},
"tx": {
"tx": {
"pending": "در انتظار",
"today": "امروز",
"yesterday": "دیروز",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "آگورا",
"nostr": "Nostr",
"accounts": "حساب‌ها"
"accounts": "کاربران"
},
"filters": {
"title": "فیلترها",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "هیچ نتیجه‌ای مطابق جستجوی شما پیدا نشد.",
"postsPrompt": "برای یافتن محتوای Nostr یک عبارت جستجو وارد کنید.",
"accounts": "هیچ حسابی مطابق جستجوی شما پیدا نشد.",
"accounts": "هیچ کاربری مطابق جستجوی شما پیدا نشد.",
"followsPrompt": "افراد را با نام یا نشانی NIP-05 جستجو کنید.",
"agora": "هیچ کمپین، pledge یا گروهی از آگورا مطابق جستجوی شما پیدا نشد.",
"agoraPrompt": "کمپین‌ها، pledgeها و گروه‌های آگورا را جستجو کنید یا جدیدترین‌ها را مرور کنید.",
@@ -1972,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "دربارهٔ Agora" },
"payments": { "label": "اهدای بیت‌کوین در Agora" },
"about-nostr": { "label": "دربارهٔ Nostr" },
"legacy": { "label": "محتوای پیشین" }
"getting-started": {
"label": "دربارهٔ Agora"
},
"payments": {
"label": "اهدای بیت‌کوین در Agora"
},
"about-nostr": {
"label": "دربارهٔ Nostr"
},
"legacy": {
"label": "محتوای پیشین"
}
},
"items": {
"what-is-ditto": {
@@ -2034,6 +2108,14 @@
"وقتی یک کارزار هر دو را می‌پذیرد، کیف پول اهداکننده تصمیم می‌گیرد از کدام مسیر استفاده شود — کیف پول‌های توانمند به پرداخت خاموش به‌صورت خصوصی پرداخت می‌کنند، بقیه به آدرس عمومی. برای تصویر کامل، **راهنمای اهداکننده** و **راهنمای فعال** را بخوانید."
]
},
"why-donations-pending": {
"question": "چرا برخی اهداها «**در انتظار**» نشان داده می‌شوند؟",
"answer": [
"اهدای **در انتظار** بیت‌کوین واقعی است که پیش‌تر ارسال شده — فقط منتظر تأیید شدن توسط شبکه است. کیف پول اهداکننده تراکنش را پخش کرده، اما هنوز در بلاکی گنجانده نشده است.",
"بیت‌کوین تقریباً هر ۱۰ دقیقه یک بلاک جدید تولید می‌کند، و ماینرها تراکنش‌ها را بر اساس کارمزدی که اهداکننده پرداخته انتخاب می‌کنند. بیشتر اهداها در عرض یک ساعت تأیید می‌شوند؛ اهداهای با کارمزد پایین‌تر وقتی شبکه شلوغ است می‌توانند بیشتر طول بکشند. {{appName}} وضعیت تأیید را به‌صورت خودکار دوباره بررسی می‌کند، بنابراین برچسب **در انتظار** به محض اینکه بلاکی فرود بیاید خودبه‌خود ناپدید می‌شود.",
"وجوه در راه هستند — فعال به محض تأیید شدن، اهدا را در جمع کلی کارزار خواهد دید. هیچ کاری از دست فعال یا {{appName}} برای تسریع آن برنمی‌آید؛ فقط کیف پول اهداکننده می‌تواند کارمزد را افزایش دهد."
]
},
"censorship-resistance": {
"question": "«مقاومت در برابر سانسور» اینجا یعنی چه؟",
"answer": [
+83 -10
View File
@@ -20,7 +20,23 @@
"goBack": "Retour",
"tryAgain": "Veuillez réessayer.",
"showLess": "Réduire",
"readMore": "Lire plus"
"readMore": "Lire plus",
"byAuthor": "par <0>{{name}}</0>",
"donors_one": "{{count}} donateur",
"donors_other": "{{count}} donateurs",
"clearSearch": "Effacer la recherche",
"searching": "Recherche…",
"searchResultsCount_one": "{{count}} résultat",
"searchResultsCount_other": "{{count}} résultats",
"sortAriaLabel": "Ordre de tri",
"sortTop": "Populaires",
"sortNew": "Récentes",
"showHidden": "Afficher les masquées",
"filtersAriaLabel": "Filtres de recherche",
"countryFilterAriaLabel": "Filtrer par pays",
"countrySearchPlaceholder": "Rechercher des pays…",
"countryNoResults": "Aucun pays trouvé.",
"countryGlobal": "Mondial"
},
"translate": {
"translate": "Traduire",
@@ -589,12 +605,23 @@
"showMore": "Afficher plus ({{count}} de plus)",
"emptyTitle": "Aucune promesse pour l'instant",
"emptyHint": "Soyez le premier à créer une promesse.",
"emptyHintCountry": "Soyez le premier à créer une promesse pour {{country}}."
"emptyHintCountry": "Soyez le premier à créer une promesse pour {{country}}.",
"searchPlaceholder": "Rechercher des promesses…",
"searchAriaLabel": "Rechercher des promesses",
"noMatch": "Aucune promesse ne correspond à « {{query}} »",
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche.",
"needsReview": "À examiner",
"needsReviewDesc": "Promesses {{appName}} qui n'ont pas encore été mises en avant ou masquées. Élevez-en une dans la sélection Mises en avant ou masquez-la.",
"needsReviewEmpty": "Rien en attente d'examen.",
"hidden": "Masquées",
"hiddenDesc": "Promesses supprimées de la découverte publique. Utilisez le menu en kebab d'une carte pour les démasquer.",
"hiddenEmpty": "Aucune promesse n'est actuellement masquée."
},
"card": {
"ended": "Terminée",
"pledged": "Promis",
"byAuthor": "par <0>{{name}}</0>",
"actionsAriaLabel": "Actions de la promesse",
"deletePledge": "Supprimer la promesse",
"copyLink": "Copier le lien",
"linkCopied": "Lien copié",
@@ -609,7 +636,6 @@
"seoDescription": "Créez une promesse de donateur pour inspirer des actions concrètes sur {{appName}}.",
"loginGateTitle": "Connectez-vous pour créer une promesse",
"loginGateBody": "Les promesses sont des événements Nostr signés. Vous avez besoin d'une connexion Nostr pour en publier une.",
"backToPledges": "Retour aux promesses",
"heading": "Créer une promesse",
"title": "Titre",
"titlePlaceholder": "Documenter un nettoyage de plage",
@@ -688,7 +714,9 @@
"createGroupLoginTitle": "Connectez-vous pour créer un groupe",
"createGroupLoginBody": "Créer un groupe publie un événement Nostr depuis votre compte.",
"myGroups": "Mes groupes",
"myGroupsTagline": "Les groupes que vous avez fondés, modérez ou suivez.",
"featuredGroups": "Groupes mis en avant",
"featuredGroupsTagline": "Des groupes remarquables qui méritent votre attention.",
"loginToSeeTitle": "Connectez-vous pour voir vos groupes",
"loginToSeeBody": "Les groupes que vous avez fondés ou modérés apparaîtront ici.",
"noGroupsTitle": "Aucun groupe pour l'instant",
@@ -709,7 +737,11 @@
"tickerFeaturedGroups_one": "groupe mis en avant sur Nostr",
"tickerFeaturedGroups_other": "groupes mis en avant sur Nostr",
"tickerCountries_one": "pays publiant aujourd'hui",
"tickerCountries_other": "pays publiant aujourd'hui"
"tickerCountries_other": "pays publiant aujourd'hui",
"searchPlaceholder": "Rechercher des groupes…",
"searchAriaLabel": "Rechercher des groupes",
"noMatch": "Aucun groupe ne correspond à « {{query}} »",
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche."
},
"create": {
"seoTitleCreate": "Créer un groupe",
@@ -718,7 +750,6 @@
"seoDescriptionEdit": "Mettez à jour votre groupe sur {{appName}}.",
"loginGateTitle": "Connectez-vous pour démarrer un groupe",
"loginGateBody": "Les groupes sont des événements Nostr signés. Vous avez besoin d'une connexion Nostr pour en publier un.",
"backToGroups": "Retour aux groupes",
"invalidEditTitle": "Lien d'édition invalide",
"invalidEditBody": "Ce lien d'édition de groupe ne contient pas d'adresse de groupe valide.",
"startNewGroup": "Démarrer un nouveau groupe",
@@ -807,7 +838,6 @@
"seoDescriptionEdit": "Mettez à jour votre campagne de collecte de fonds sur {{appName}}.",
"loginGateTitle": "Connectez-vous pour démarrer une campagne",
"loginGateBody": "Les campagnes sont des événements Nostr signés. Vous avez besoin d'une connexion Nostr pour en publier une.",
"goHome": "Accueil",
"invalidEditTitle": "Lien d'édition invalide",
"invalidEditBody": "Ce lien d'édition de campagne ne contient pas d'adresse de campagne valide.",
"startNewCampaign": "Démarrer une nouvelle campagne",
@@ -937,7 +967,6 @@
"seoDescription": "Créer un événement de calendrier sur {{appName}}.",
"loginTitle": "Connectez-vous pour créer un événement",
"loginBody": "Les événements sont des événements Nostr signés. Vous avez besoin d'une connexion Nostr pour en publier un.",
"backToEvents": "Retour aux événements",
"heading": "Créer un événement",
"titlePlaceholder": "Nettoyage du quartier",
"descriptionPlaceholder": "Dites aux gens à quoi s'attendre, ce qu'il faut apporter et qui devrait venir...",
@@ -1032,12 +1061,23 @@
"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.",
"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."
"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…",
"searchAriaLabel": "Rechercher des campagnes",
"noMatch": "Aucune campagne ne correspond à « {{query}} »",
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche."
},
"all": {
"title": "Toutes les campagnes",
"seoTitle": "Toutes les campagnes",
"description": "Parcourez toutes les campagnes publiées sur Agora.",
"sectionTagline": "Parcourez toutes les causes du réseau.",
"heroKicker": "Campagnes",
"heroHeading": "Chaque cause,",
"heroHeadingLine2": "au même endroit.",
"heroBody": "Toutes les collectes de fonds publiées sur Nostr, rassemblées en un seul endroit. Parcourez l'ensemble du réseau, trouvez une cause qui vous tient à cœur et soutenez-la directement en Bitcoin.",
"campaignsCount_one": "campagne sur le réseau",
"campaignsCount_other": "campagnes sur le réseau",
"searchAriaLabel": "Rechercher des campagnes",
"searchPlaceholder": "Rechercher des campagnes…",
"clearSearch": "Effacer la recherche",
@@ -1054,6 +1094,31 @@
"emptyHint": "Aucune campagne n'a encore été publiée. Soyez le premier."
}
},
"moderation": {
"hiddenBadge": "Masquée",
"menu": {
"label": "Actions de modération",
"ariaCampaign": "Modérer la campagne",
"ariaPledge": "Modérer la promesse",
"ariaGroup": "Modérer le groupe",
"failedAction": "Échec de l'action {{action}}",
"approve": "Approuver",
"unapprove": "Désapprouver",
"approvedState": "Approuvée",
"hide": "Masquer",
"unhide": "Démasquer",
"hiddenState": "Masquée",
"feature": "Mettre en avant",
"unfeature": "Retirer de la sélection",
"featuredState": "Mise en avant",
"toastApproved": "Approuvée pour la page d'accueil",
"toastUnapproved": "Retirée de la page d'accueil",
"toastHidden": "Masquée",
"toastUnhidden": "Démasquée",
"toastFeatured": "Mise en avant",
"toastUnfeatured": "Retirée de la sélection"
}
},
"settings": {
"title": "Paramètres",
"description": "Gérez vos paramètres {{appName}}",
@@ -1642,7 +1707,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Comptes"
"accounts": "Utilisateurs"
},
"filters": {
"title": "Filtres",
@@ -1715,7 +1780,7 @@
"empty": {
"posts": "Aucun résultat correspondant à votre recherche.",
"postsPrompt": "Saisissez une requête pour trouver du contenu Nostr.",
"accounts": "Aucun compte correspondant à votre recherche.",
"accounts": "Aucun utilisateur correspondant à votre recherche.",
"followsPrompt": "Recherchez des personnes par nom ou adresse NIP-05.",
"agora": "Aucune campagne, promesse ou groupe Agora correspondant à votre recherche.",
"agoraPrompt": "Recherchez des campagnes, promesses et groupes Agora, ou parcourez les dernières.",
@@ -2025,6 +2090,14 @@
"Quand une campagne accepte les deux, le portefeuille du donateur décide quel chemin utiliser — les portefeuilles compatibles paient en privé, les autres paient l'adresse publique. Lisez le **Guide du donateur** et le **Guide de l'activiste** pour le tableau complet."
]
},
"why-donations-pending": {
"question": "Pourquoi certains dons indiquent-ils « en attente » ?",
"answer": [
"Un don **en attente** est du vrai Bitcoin qui a déjà été envoyé — il attend simplement d'être confirmé par le réseau. Le portefeuille du donateur a diffusé la transaction, mais elle n'a pas encore été incluse dans un bloc.",
"Bitcoin produit un nouveau bloc environ toutes les 10 minutes, et les mineurs sélectionnent les transactions en fonction des frais payés par le donateur. La plupart des dons sont confirmés en moins d'une heure ; ceux avec des frais plus bas peuvent prendre plus de temps lorsque le réseau est saturé. {{appName}} revérifie automatiquement l'état de confirmation, donc l'étiquette **en attente** disparaît d'elle-même dès qu'un bloc arrive.",
"Les fonds sont en route — l'activiste verra le don compter dans le total de la campagne dès qu'il sera confirmé. Ni l'activiste ni {{appName}} ne peuvent accélérer le processus ; seul le portefeuille du donateur peut augmenter les frais."
]
},
"censorship-resistance": {
"question": "Que signifie « résistant à la censure » ici ?",
"answer": [
+108 -17
View File
@@ -20,7 +20,24 @@
"goBack": "वापस जाएँ",
"tryAgain": "कृपया दोबारा कोशिश करें।",
"showLess": "कम दिखाएँ",
"readMore": "और पढ़ें"
"readMore": "और पढ़ें",
"byAuthor": "<0>{{name}}</0> द्वारा",
"donors_one": "{{count}} दानदाता",
"donors_other": "{{count}} दानदाता",
"clearSearch": "खोज साफ़ करें",
"searching": "खोज हो रही है…",
"searchResultsCount_one": "{{count}} परिणाम",
"searchResultsCount_other": "{{count}} परिणाम",
"sortAriaLabel": "क्रम",
"sortDefault": "डिफ़ॉल्ट",
"sortTop": "टॉप",
"sortNew": "नए",
"showHidden": "छुपे हुए दिखाएँ",
"filtersAriaLabel": "खोज फ़िल्टर",
"countryFilterAriaLabel": "देश के अनुसार फ़िल्टर करें",
"countrySearchPlaceholder": "देश खोजें…",
"countryNoResults": "कोई देश नहीं मिला.",
"countryGlobal": "वैश्विक"
},
"translate": {
"translate": "अनुवाद करें",
@@ -210,9 +227,18 @@
"unknown": "अज्ञात"
},
"kindHeader": {
"photo": { "action": "ने शेयर की एक", "noun": "फ़ोटो" },
"encryptedMessage": { "action": "ने भेजा एक", "noun": "एन्क्रिप्टेड मैसेज" },
"letter": { "action": "ने भेजी एक", "noun": "चिट्ठी" },
"photo": {
"action": "ने शेयर की एक",
"noun": "फ़ोटो"
},
"encryptedMessage": {
"action": "ने भेजा एक",
"noun": "एन्क्रिप्टेड मैसेज"
},
"letter": {
"action": "ने भेजी एक",
"noun": "चिट्ठी"
},
"treasureHidCreated": "ने छुपाया एक",
"treasureHidUpdated": "ने अपडेट किया एक",
"treasureNoun": "ख़ज़ाना",
@@ -589,12 +615,23 @@
"showMore": "और दिखाएँ ({{count}} और)",
"emptyTitle": "अभी कोई प्लेज नहीं",
"emptyHint": "प्लेज बनाने वाले पहले व्यक्ति बनें।",
"emptyHintCountry": "{{country}} के लिए प्लेज बनाने वाले पहले व्यक्ति बनें।"
"emptyHintCountry": "{{country}} के लिए प्लेज बनाने वाले पहले व्यक्ति बनें।",
"searchPlaceholder": "प्लेज खोजें…",
"searchAriaLabel": "प्लेज खोजें",
"noMatch": "“{{query}}” से मेल खाने वाला कोई प्लेज नहीं",
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।",
"needsReview": "समीक्षा की ज़रूरत",
"needsReviewDesc": "{{appName}} प्लेज जिन्हें अभी फ़ीचर या छुपाया नहीं गया है। किसी एक को Featured shelf में लाएँ या Hide से दबा दें।",
"needsReviewEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
"hidden": "छुपा हुआ",
"hiddenDesc": "सार्वजनिक खोज से दबाए गए प्लेज। कार्ड के kebab मेन्यू से अनहाइड करें।",
"hiddenEmpty": "अभी कोई प्लेज छुपा हुआ नहीं है।"
},
"card": {
"ended": "समाप्त",
"pledged": "प्लेज किया गया",
"byAuthor": "<0>{{name}}</0> द्वारा",
"actionsAriaLabel": "प्लेज क्रियाएँ",
"deletePledge": "प्लेज डिलीट करें",
"copyLink": "लिंक कॉपी करें",
"linkCopied": "लिंक कॉपी हो गया",
@@ -609,7 +646,6 @@
"seoDescription": "{{appName}} पर ठोस कार्रवाई के लिए प्रेरित करने हेतु डोनर प्लेज बनाएँ।",
"loginGateTitle": "प्लेज बनाने के लिए लॉग इन करें",
"loginGateBody": "प्लेज साइन किए गए Nostr events हैं। एक पब्लिश करने के लिए आपको Nostr लॉगिन चाहिए।",
"backToPledges": "प्लेज पर वापस",
"heading": "प्लेज बनाएँ",
"title": "शीर्षक",
"titlePlaceholder": "एक beach cleanup का दस्तावेज़",
@@ -688,7 +724,9 @@
"createGroupLoginTitle": "ग्रुप बनाने के लिए लॉग इन करें",
"createGroupLoginBody": "ग्रुप बनाने से आपके अकाउंट से एक Nostr event पब्लिश होता है।",
"myGroups": "मेरे ग्रुप",
"myGroupsTagline": "जिन ग्रुप को आपने बनाया, मॉडरेट किया, या फ़ॉलो किया है।",
"featuredGroups": "फ़ीचर्ड ग्रुप",
"featuredGroupsTagline": "आपके ध्यान के लायक ख़ास ग्रुप।",
"loginToSeeTitle": "अपने ग्रुप देखने के लिए लॉग इन करें",
"loginToSeeBody": "आपने जो ग्रुप बनाए या मॉडरेट किए हैं वे यहाँ दिखेंगे।",
"noGroupsTitle": "अभी कोई ग्रुप नहीं",
@@ -709,7 +747,11 @@
"tickerFeaturedGroups_one": "Nostr पर फ़ीचर्ड ग्रुप",
"tickerFeaturedGroups_other": "Nostr पर फ़ीचर्ड ग्रुप",
"tickerCountries_one": "देश आज पोस्ट कर रहा है",
"tickerCountries_other": "देश आज पोस्ट कर रहे हैं"
"tickerCountries_other": "देश आज पोस्ट कर रहे हैं",
"searchPlaceholder": "ग्रुप खोजें…",
"searchAriaLabel": "ग्रुप खोजें",
"noMatch": "“{{query}}” से मेल खाने वाला कोई ग्रुप नहीं",
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
},
"create": {
"seoTitleCreate": "ग्रुप बनाएँ",
@@ -718,7 +760,6 @@
"seoDescriptionEdit": "{{appName}} पर अपना ग्रुप अपडेट करें।",
"loginGateTitle": "ग्रुप शुरू करने के लिए लॉग इन करें",
"loginGateBody": "ग्रुप साइन किए गए Nostr events हैं। एक पब्लिश करने के लिए Nostr लॉगिन चाहिए।",
"backToGroups": "ग्रुप पर वापस",
"invalidEditTitle": "अमान्य एडिट लिंक",
"invalidEditBody": "इस ग्रुप एडिट लिंक में मान्य ग्रुप एड्रेस नहीं है।",
"startNewGroup": "नया ग्रुप शुरू करें",
@@ -807,7 +848,6 @@
"seoDescriptionEdit": "{{appName}} पर अपना फंडरेज़िंग कैंपेन अपडेट करें।",
"loginGateTitle": "कैंपेन शुरू करने के लिए लॉग इन करें",
"loginGateBody": "कैंपेन साइन किए गए Nostr events हैं। एक पब्लिश करने के लिए Nostr लॉगिन चाहिए।",
"goHome": "होम जाएँ",
"invalidEditTitle": "अमान्य एडिट लिंक",
"invalidEditBody": "इस कैंपेन एडिट लिंक में मान्य कैंपेन एड्रेस नहीं है।",
"startNewCampaign": "नया कैंपेन शुरू करें",
@@ -937,7 +977,6 @@
"seoDescription": "{{appName}} पर एक कैलेंडर इवेंट बनाएँ।",
"loginTitle": "इवेंट बनाने के लिए लॉग इन करें",
"loginBody": "इवेंट साइन किए गए Nostr events हैं। एक पब्लिश करने के लिए Nostr लॉगिन चाहिए।",
"backToEvents": "इवेंट पर वापस",
"heading": "इवेंट बनाएँ",
"titlePlaceholder": "मोहल्ले की सफ़ाई",
"descriptionPlaceholder": "लोगों को बताएँ क्या उम्मीद करें, क्या लाएँ, और किसे आना चाहिए...",
@@ -1032,12 +1071,23 @@
"yourCampaigns": "आपके कैंपेन",
"yourCampaignsDesc": "आपके कैंपेन Nostr पर लाइव हैं और डोनेशन कैंपेन लिंक से काम करते हैं। ये होमपेज पर तब दिखेंगे जब Team Soapbox का कोई मॉडरेटर इन्हें मंज़ूरी देगा।",
"empty": "अभी कोई कैंपेन नहीं",
"emptyHint": "{{appName}} पर फंडरेज़र शुरू करने वाले पहले बनें। अपनी कहानी बताएँ, लाभार्थी चुनें, और लिंक शेयर करें।"
"emptyHint": "{{appName}} पर फंडरेज़र शुरू करने वाले पहले बनें। अपनी कहानी बताएँ, लाभार्थी चुनें, और लिंक शेयर करें।",
"searchPlaceholder": "कैंपेन खोजें…",
"searchAriaLabel": "कैंपेन खोजें",
"noMatch": "“{{query}}” से मेल खाने वाला कोई कैंपेन नहीं",
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
},
"all": {
"title": "सभी कैंपेन",
"seoTitle": "सभी कैंपेन",
"description": "Agora पर पब्लिश हुए हर कैंपेन को देखें।",
"sectionTagline": "नेटवर्क पर हर मक़सद को देखें।",
"heroKicker": "कैंपेन",
"heroHeading": "हर मक़सद,",
"heroHeadingLine2": "एक ही जगह।",
"heroBody": "Nostr पर पब्लिश हुआ हर फंडरेज़र, एक ही जगह जुटा हुआ। पूरे नेटवर्क को देखें, अपने लिए ज़रूरी मक़सद ढूँढें, और सीधे Bitcoin से उसका साथ दें।",
"campaignsCount_one": "कैंपेन नेटवर्क पर",
"campaignsCount_other": "कैंपेन नेटवर्क पर",
"searchAriaLabel": "कैंपेन खोजें",
"searchPlaceholder": "कैंपेन खोजें…",
"clearSearch": "खोज साफ़ करें",
@@ -1054,6 +1104,31 @@
"emptyHint": "अभी कोई कैंपेन पब्लिश नहीं हुआ है। पहले बनें।"
}
},
"moderation": {
"hiddenBadge": "छुपा हुआ",
"menu": {
"label": "मॉडरेटर कार्रवाइयाँ",
"ariaCampaign": "कैंपेन मॉडरेट करें",
"ariaPledge": "प्लेज मॉडरेट करें",
"ariaGroup": "ग्रुप मॉडरेट करें",
"failedAction": "{{action}} नहीं हो सका",
"approve": "मंज़ूरी दें",
"unapprove": "मंज़ूरी हटाएँ",
"approvedState": "मंज़ूर",
"hide": "छुपाएँ",
"unhide": "अनहाइड करें",
"hiddenState": "छुपा हुआ",
"feature": "फ़ीचर करें",
"unfeature": "फ़ीचर से हटाएँ",
"featuredState": "फ़ीचर्ड",
"toastApproved": "होमपेज के लिए मंज़ूरी दी गई",
"toastUnapproved": "होमपेज से हटाया गया",
"toastHidden": "छुपा दिया गया",
"toastUnhidden": "अनहाइड कर दिया गया",
"toastFeatured": "फ़ीचर कर दिया गया",
"toastUnfeatured": "फ़ीचर से हटाया गया"
}
},
"settings": {
"title": "सेटिंग्स",
"description": "अपनी {{appName}} सेटिंग्स प्रबंधित करें",
@@ -1578,7 +1653,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "अकाउंट"
"accounts": "उपयोगकर्ता"
},
"filters": {
"title": "फ़िल्टर",
@@ -1651,7 +1726,7 @@
"empty": {
"posts": "आपकी खोज से मेल खाता कोई परिणाम नहीं मिला।",
"postsPrompt": "Nostr कंटेंट ढूँढने के लिए खोज दर्ज करें।",
"accounts": "आपकी खोज से मेल खाता कोई अकाउंट नहीं मिला।",
"accounts": "आपकी खोज से मेल खाता कोई उपयोगकर्ता नहीं मिला।",
"followsPrompt": "नाम या NIP-05 एड्रेस से लोगों को खोजें।",
"agora": "आपकी खोज से मेल खाते कोई Agora कैंपेन, प्लेज, या ग्रुप नहीं मिले।",
"agoraPrompt": "Agora कैंपेन, प्लेज, और ग्रुप खोजें, या नवीनतम देखें।",
@@ -1899,10 +1974,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "Agora के बारे में" },
"payments": { "label": "Agora पर Bitcoin डोनेशन" },
"about-nostr": { "label": "Nostr के बारे में" },
"legacy": { "label": "Legacy" }
"getting-started": {
"label": "Agora के बारे में"
},
"payments": {
"label": "Agora पर Bitcoin डोनेशन"
},
"about-nostr": {
"label": "Nostr के बारे में"
},
"legacy": {
"label": "Legacy"
}
},
"items": {
"what-is-ditto": {
@@ -1961,6 +2044,14 @@
"जब कैंपेन दोनों स्वीकार करता है, तो डोनर का वॉलेट तय करता है कि किस रास्ते का इस्तेमाल हो — साइलेंट-पेमेंट-सक्षम वॉलेट निजी तौर पर भुगतान करते हैं, अन्य सार्वजनिक एड्रेस पर भुगतान करते हैं। पूरी तस्वीर के लिए **डोनर गाइड** और **एक्टिविस्ट गाइड** पढ़ें।"
]
},
"why-donations-pending": {
"question": "कुछ डोनेशन \"**pending**\" क्यों दिखाते हैं?",
"answer": [
"**pending** डोनेशन असली Bitcoin है जो पहले ही भेजा जा चुका है — बस नेटवर्क द्वारा कन्फ़र्म होने का इंतज़ार कर रहा है। डोनर के वॉलेट ने transaction ब्रॉडकास्ट कर दिया है, लेकिन यह अभी तक किसी ब्लॉक में शामिल नहीं हुआ है।",
"Bitcoin हर लगभग 10 मिनट में एक नया ब्लॉक बनाता है, और माइनर डोनर द्वारा दी गई fee के आधार पर transactions चुनते हैं। ज़्यादातर डोनेशन एक घंटे के अंदर कन्फ़र्म हो जाते हैं; जब नेटवर्क व्यस्त हो तो कम fee वाले डोनेशन को ज़्यादा समय लग सकता है। {{appName}} कन्फ़र्मेशन स्टेटस को अपने आप दोबारा जाँचता है, इसलिए जैसे ही एक ब्लॉक आता है, **pending** लेबल अपने आप ग़ायब हो जाता है।",
"फंड रास्ते में है — कन्फ़र्म होते ही सक्रियकर्ता को डोनेशन कैंपेन के कुल में जुड़ता दिखेगा। इसे तेज़ करने के लिए सक्रियकर्ता या {{appName}} कुछ नहीं कर सकता; केवल डोनर का वॉलेट ही fee बढ़ा सकता है।"
]
},
"censorship-resistance": {
"question": "यहाँ \"सेंसरशिप-प्रतिरोधी\" का क्या मतलब है?",
"answer": [
+84 -10
View File
@@ -20,7 +20,24 @@
"goBack": "Kembali",
"tryAgain": "Silakan coba lagi.",
"showLess": "Tampilkan lebih sedikit",
"readMore": "Selengkapnya"
"readMore": "Selengkapnya",
"byAuthor": "oleh <0>{{name}}</0>",
"donors_one": "{{count}} donatur",
"donors_other": "{{count}} donatur",
"clearSearch": "Bersihkan pencarian",
"searching": "Mencari…",
"searchResultsCount_one": "{{count}} hasil",
"searchResultsCount_other": "{{count}} hasil",
"sortAriaLabel": "Urutan",
"sortDefault": "Bawaan",
"sortTop": "Teratas",
"sortNew": "Baru",
"showHidden": "Tampilkan yang tersembunyi",
"filtersAriaLabel": "Filter pencarian",
"countryFilterAriaLabel": "Saring berdasarkan negara",
"countrySearchPlaceholder": "Cari negara…",
"countryNoResults": "Tidak ada negara yang ditemukan.",
"countryGlobal": "Global"
},
"translate": {
"translate": "Terjemahkan",
@@ -598,12 +615,23 @@
"showMore": "Tampilkan lebih banyak ({{count}} lagi)",
"emptyTitle": "Belum ada ikrar",
"emptyHint": "Jadilah yang pertama membuat ikrar.",
"emptyHintCountry": "Jadilah yang pertama membuat ikrar untuk {{country}}."
"emptyHintCountry": "Jadilah yang pertama membuat ikrar untuk {{country}}.",
"searchPlaceholder": "Cari ikrar…",
"searchAriaLabel": "Cari ikrar",
"noMatch": "Tidak ada ikrar yang cocok dengan “{{query}}”",
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian.",
"needsReview": "Perlu ditinjau",
"needsReviewDesc": "Ikrar {{appName}} yang belum diunggulkan atau disembunyikan. Naikkan ke rak Unggulan atau sembunyikan dengan Hide.",
"needsReviewEmpty": "Tidak ada yang menunggu peninjauan.",
"hidden": "Tersembunyi",
"hiddenDesc": "Ikrar yang disembunyikan dari penemuan publik. Gunakan menu kebab pada kartu untuk menampilkannya kembali.",
"hiddenEmpty": "Tidak ada ikrar yang sedang disembunyikan."
},
"card": {
"ended": "Berakhir",
"pledged": "Diikrarkan",
"byAuthor": "oleh <0>{{name}}</0>",
"actionsAriaLabel": "Tindakan ikrar",
"deletePledge": "Hapus ikrar",
"copyLink": "Salin tautan",
"linkCopied": "Tautan disalin",
@@ -618,7 +646,6 @@
"seoDescription": "Buat ikrar donatur untuk menginspirasi aksi konkret di {{appName}}.",
"loginGateTitle": "Masuk untuk membuat ikrar",
"loginGateBody": "Ikrar adalah event Nostr yang ditandatangani. Anda perlu masuk Nostr untuk memublikasikannya.",
"backToPledges": "Kembali ke ikrar",
"heading": "Buat ikrar",
"title": "Judul",
"titlePlaceholder": "Mendokumentasikan pembersihan pantai",
@@ -697,7 +724,9 @@
"createGroupLoginTitle": "Masuk untuk membuat grup",
"createGroupLoginBody": "Membuat grup memublikasikan event Nostr dari akun Anda.",
"myGroups": "Grup saya",
"myGroupsTagline": "Grup yang Anda dirikan, moderasi, atau ikuti.",
"featuredGroups": "Grup unggulan",
"featuredGroupsTagline": "Grup menonjol yang layak Anda perhatikan.",
"loginToSeeTitle": "Masuk untuk melihat grup Anda",
"loginToSeeBody": "Grup yang Anda dirikan atau moderasi akan muncul di sini.",
"noGroupsTitle": "Belum ada grup",
@@ -718,7 +747,11 @@
"tickerFeaturedGroups_one": "grup unggulan di Nostr",
"tickerFeaturedGroups_other": "grup unggulan di Nostr",
"tickerCountries_one": "negara memposting hari ini",
"tickerCountries_other": "negara memposting hari ini"
"tickerCountries_other": "negara memposting hari ini",
"searchPlaceholder": "Cari grup…",
"searchAriaLabel": "Cari grup",
"noMatch": "Tidak ada grup yang cocok dengan “{{query}}”",
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
},
"create": {
"seoTitleCreate": "Buat grup",
@@ -727,7 +760,6 @@
"seoDescriptionEdit": "Perbarui grup Anda di {{appName}}.",
"loginGateTitle": "Masuk untuk memulai grup",
"loginGateBody": "Grup adalah event Nostr yang ditandatangani. Anda perlu masuk Nostr untuk memublikasikannya.",
"backToGroups": "Kembali ke grup",
"invalidEditTitle": "Tautan edit tidak valid",
"invalidEditBody": "Tautan edit grup ini kehilangan alamat grup yang valid.",
"startNewGroup": "Mulai grup baru",
@@ -816,7 +848,6 @@
"seoDescriptionEdit": "Perbarui kampanye penggalangan dana Anda di {{appName}}.",
"loginGateTitle": "Masuk untuk memulai kampanye",
"loginGateBody": "Kampanye adalah event Nostr yang ditandatangani. Anda perlu masuk Nostr untuk memublikasikannya.",
"goHome": "Ke beranda",
"invalidEditTitle": "Tautan edit tidak valid",
"invalidEditBody": "Tautan edit kampanye ini kehilangan alamat kampanye yang valid.",
"startNewCampaign": "Mulai kampanye baru",
@@ -946,7 +977,6 @@
"seoDescription": "Buat acara kalender di {{appName}}.",
"loginTitle": "Masuk untuk membuat acara",
"loginBody": "Acara adalah event Nostr yang ditandatangani. Anda perlu masuk Nostr untuk memublikasikannya.",
"backToEvents": "Kembali ke acara",
"heading": "Buat acara",
"titlePlaceholder": "Bersih-bersih lingkungan",
"descriptionPlaceholder": "Beri tahu orang apa yang diharapkan, apa yang harus dibawa, dan siapa yang sebaiknya hadir...",
@@ -1041,12 +1071,23 @@
"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.",
"empty": "Belum ada kampanye",
"emptyHint": "Jadilah yang pertama memulai penggalangan dana di {{appName}}. Ceritakan kisah Anda, pilih penerima manfaat, dan bagikan tautannya."
"emptyHint": "Jadilah yang pertama memulai penggalangan dana di {{appName}}. Ceritakan kisah Anda, pilih penerima manfaat, dan bagikan tautannya.",
"searchPlaceholder": "Cari kampanye…",
"searchAriaLabel": "Cari kampanye",
"noMatch": "Tidak ada kampanye yang cocok dengan “{{query}}”",
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
},
"all": {
"title": "Semua Kampanye",
"seoTitle": "Semua kampanye",
"description": "Telusuri setiap kampanye yang dipublikasikan di Agora.",
"sectionTagline": "Jelajahi setiap aksi di jaringan.",
"heroKicker": "Kampanye",
"heroHeading": "Setiap aksi,",
"heroHeadingLine2": "dalam satu tempat.",
"heroBody": "Setiap penggalangan dana yang dipublikasikan di Nostr, dikumpulkan dalam satu tempat. Telusuri seluruh jaringan, temukan aksi yang penting bagi Anda, dan dukung langsung dengan Bitcoin.",
"campaignsCount_one": "kampanye di jaringan",
"campaignsCount_other": "kampanye di jaringan",
"searchAriaLabel": "Cari kampanye",
"searchPlaceholder": "Cari kampanye…",
"clearSearch": "Bersihkan pencarian",
@@ -1063,6 +1104,31 @@
"emptyHint": "Belum ada kampanye yang dipublikasikan. Jadilah yang pertama."
}
},
"moderation": {
"hiddenBadge": "Tersembunyi",
"menu": {
"label": "Tindakan moderator",
"ariaCampaign": "Moderasi kampanye",
"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",
"toastUnfeatured": "Dihapus dari unggulan"
}
},
"settings": {
"title": "Pengaturan",
"description": "Kelola pengaturan {{appName}} Anda",
@@ -1587,7 +1653,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Akun"
"accounts": "Pengguna"
},
"filters": {
"title": "Filter",
@@ -1660,7 +1726,7 @@
"empty": {
"posts": "Tidak ada hasil yang cocok dengan pencarian Anda.",
"postsPrompt": "Masukkan kueri pencarian untuk menemukan konten Nostr.",
"accounts": "Tidak ada akun yang cocok dengan pencarian Anda.",
"accounts": "Tidak ada pengguna yang cocok dengan pencarian Anda.",
"followsPrompt": "Cari orang berdasarkan nama atau alamat NIP-05.",
"agora": "Tidak ada kampanye, ikrar, atau grup Agora yang cocok dengan pencarian Anda.",
"agoraPrompt": "Cari kampanye, ikrar, dan grup Agora, atau telusuri yang terbaru.",
@@ -1978,6 +2044,14 @@
"Saat kampanye menerima keduanya, dompet donatur memutuskan jalur mana yang digunakan — dompet yang mendukung silent payments membayar secara privat, yang lain membayar alamat publik. Baca **Panduan Donatur** dan **Panduan Aktivis** untuk gambaran lengkap."
]
},
"why-donations-pending": {
"question": "Mengapa beberapa donasi tertulis \"pending\"?",
"answer": [
"Donasi **pending** adalah Bitcoin asli yang sudah dikirim — ia hanya menunggu untuk dikonfirmasi oleh jaringan. Dompet donatur telah menyiarkan transaksinya, tetapi belum dimasukkan ke dalam blok.",
"Bitcoin menghasilkan blok baru kira-kira setiap 10 menit, dan penambang memilih transaksi berdasarkan biaya yang dibayar donatur. Sebagian besar donasi terkonfirmasi dalam satu jam; donasi dengan biaya lebih rendah bisa memakan waktu lebih lama saat jaringan sedang sibuk. {{appName}} memeriksa ulang status konfirmasi secara otomatis, sehingga label **pending** hilang dengan sendirinya begitu blok masuk.",
"Dananya sedang dalam perjalanan — aktivis akan melihat donasi tersebut dihitung ke dalam total kampanye segera setelah terkonfirmasi. Tidak ada yang bisa dilakukan aktivis atau {{appName}} untuk mempercepatnya; hanya dompet donatur yang bisa menaikkan fee."
]
},
"censorship-resistance": {
"question": "Apa yang dimaksud dengan \"anti-sensor\" di sini?",
"answer": [
+96 -14
View File
@@ -20,7 +20,24 @@
"goBack": "ត្រឡប់ក្រោយ",
"tryAgain": "សូមព្យាយាមម្តងទៀត។",
"showLess": "បង្ហាញតិចជាងមុន",
"readMore": "អានបន្ថែម"
"readMore": "អានបន្ថែម",
"byAuthor": "ដោយ <0>{{name}}</0>",
"donors_one": "អ្នកបរិច្ចាគ {{count}} នាក់",
"donors_other": "អ្នកបរិច្ចាគ {{count}} នាក់",
"clearSearch": "សម្អាតការស្វែងរក",
"searching": "កំពុងស្វែងរក…",
"searchResultsCount_one": "លទ្ធផល {{count}}",
"searchResultsCount_other": "លទ្ធផល {{count}}",
"sortAriaLabel": "លំដាប់តម្រៀប",
"sortDefault": "លំនាំដើម",
"sortTop": "ល្បីបំផុត",
"sortNew": "ថ្មី",
"showHidden": "បង្ហាញដែលលាក់",
"filtersAriaLabel": "តម្រងស្វែងរក",
"countryFilterAriaLabel": "ច្រោះតាមប្រទេស",
"countrySearchPlaceholder": "ស្វែងរកប្រទេស…",
"countryNoResults": "រកមិនឃើញប្រទេសទេ។",
"countryGlobal": "សកល"
},
"translate": {
"translate": "បកប្រែ",
@@ -166,12 +183,23 @@
"showMore": "បង្ហាញបន្ថែម ({{count}} ទៀត)",
"emptyTitle": "មិនទាន់មានការសន្យាទេ",
"emptyHint": "ក្លាយជាមនុស្សដំបូងបង្កើតការសន្យា។",
"emptyHintCountry": "ក្លាយជាមនុស្សដំបូងបង្កើតការសន្យាសម្រាប់ {{country}}។"
"emptyHintCountry": "ក្លាយជាមនុស្សដំបូងបង្កើតការសន្យាសម្រាប់ {{country}}។",
"needsReview": "ត្រូវការត្រួតពិនិត្យ",
"needsReviewDesc": "ការសន្យា {{appName}} ដែលមិនទាន់ត្រូវបានលេចធ្លោ ឬលាក់នៅឡើយ។ លើកទៅធ្នើរលេចធ្លោ ឬបង្ក្រាបវាដោយលាក់។",
"needsReviewEmpty": "មិនមានអ្វីរង់ចាំការត្រួតពិនិត្យទេ។",
"hidden": "បានលាក់",
"hiddenDesc": "ការសន្យាដែលបានបង្ក្រាបពីការស្វែងរកសាធារណៈ។ ប្រើម៉ឺនុយ kebab លើកាតដើម្បីបង្ហាញឡើងវិញ។",
"hiddenEmpty": "បច្ចុប្បន្នមិនមានការសន្យាត្រូវបានលាក់ទេ។",
"searchPlaceholder": "ស្វែងរកការសន្យា…",
"searchAriaLabel": "ស្វែងរកការសន្យា",
"noMatch": "គ្មានការសន្យាណាដែលត្រូវនឹង «{{query}}»",
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
},
"card": {
"ended": "បានបញ្ចប់",
"pledged": "បានសន្យា",
"byAuthor": "ដោយ <0>{{name}}</0>",
"actionsAriaLabel": "សកម្មភាពកិច្ចសន្យា",
"deletePledge": "លុបការសន្យា",
"copyLink": "ចម្លងតំណ",
"linkCopied": "បានចម្លងតំណ",
@@ -186,7 +214,6 @@
"seoDescription": "បង្កើតការសន្យារបស់អ្នកបរិច្ចាគ ដើម្បីជំរុញសកម្មភាពជាក់ស្តែងនៅលើ {{appName}}។",
"loginGateTitle": "ចូលដើម្បីបង្កើតការសន្យា",
"loginGateBody": "ការសន្យាគឺជាព្រឹត្តិការណ៍ Nostr ដែលបានចុះហត្ថលេខា។ អ្នកត្រូវការការចូល Nostr ដើម្បីផ្សព្វផ្សាយ។",
"backToPledges": "ត្រឡប់ទៅការសន្យា",
"heading": "បង្កើតការសន្យា",
"title": "ចំណងជើង",
"titlePlaceholder": "កត់ត្រាការសម្អាតឆ្នេរ",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "ចូលដើម្បីបង្កើតក្រុម",
"createGroupLoginBody": "ការបង្កើតក្រុមផ្សព្វផ្សាយព្រឹត្តិការណ៍ Nostr ពីគណនីរបស់អ្នក។",
"myGroups": "ក្រុមរបស់ខ្ញុំ",
"myGroupsTagline": "ក្រុមដែលអ្នកបានបង្កើត គ្រប់គ្រង ឬដាក់តាមដាន។",
"featuredGroups": "ក្រុមលេចធ្លោ",
"featuredGroupsTagline": "ក្រុមលេចធ្លោដែលសក្តិសមនឹងការយកចិត្តទុកដាក់របស់អ្នក។",
"loginToSeeTitle": "ចូលដើម្បីមើលក្រុមរបស់អ្នក",
"loginToSeeBody": "ក្រុមដែលអ្នកបានបង្កើត ឬគ្រប់គ្រងនឹងបង្ហាញនៅទីនេះ។",
"noGroupsTitle": "មិនទាន់មានក្រុមទេ",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "ក្រុមលេចធ្លោនៅលើ Nostr",
"tickerFeaturedGroups_other": "ក្រុមលេចធ្លោនៅលើ Nostr",
"tickerCountries_one": "ប្រទេសកំពុងបង្ហោះថ្ងៃនេះ",
"tickerCountries_other": "ប្រទេសកំពុងបង្ហោះថ្ងៃនេះ"
"tickerCountries_other": "ប្រទេសកំពុងបង្ហោះថ្ងៃនេះ",
"searchPlaceholder": "ស្វែងរកក្រុម…",
"searchAriaLabel": "ស្វែងរកក្រុម",
"noMatch": "គ្មានក្រុមណាដែលត្រូវនឹង «{{query}}»",
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
},
"create": {
"seoTitleCreate": "បង្កើតក្រុម",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "ធ្វើបច្ចុប្បន្នភាពក្រុមរបស់អ្នកនៅលើ {{appName}}។",
"loginGateTitle": "ចូលដើម្បីចាប់ផ្តើមក្រុម",
"loginGateBody": "ក្រុមគឺជាព្រឹត្តិការណ៍ Nostr ដែលបានចុះហត្ថលេខា។ អ្នកត្រូវការការចូល Nostr ដើម្បីផ្សព្វផ្សាយ។",
"backToGroups": "ត្រឡប់ទៅក្រុម",
"invalidEditTitle": "តំណកែសម្រួលមិនត្រឹមត្រូវ",
"invalidEditBody": "តំណកែសម្រួលក្រុមនេះមិនមានអាសយដ្ឋានក្រុមត្រឹមត្រូវ។",
"startNewGroup": "ចាប់ផ្តើមក្រុមថ្មី",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "ធ្វើបច្ចុប្បន្នភាពយុទ្ធនាការរបស់អ្នកនៅលើ {{appName}}។",
"loginGateTitle": "ចូលដើម្បីចាប់ផ្តើមយុទ្ធនាការ",
"loginGateBody": "យុទ្ធនាការគឺជាព្រឹត្តិការណ៍ Nostr ដែលបានចុះហត្ថលេខា។ អ្នកត្រូវការការចូល Nostr ដើម្បីផ្សព្វផ្សាយ។",
"goHome": "ត្រឡប់ទៅទំព័រដើម",
"invalidEditTitle": "តំណកែសម្រួលមិនត្រឹមត្រូវ",
"invalidEditBody": "តំណកែសម្រួលយុទ្ធនាការនេះមិនមានអាសយដ្ឋានយុទ្ធនាការត្រឹមត្រូវ។",
"startNewCampaign": "ចាប់ផ្តើមយុទ្ធនាការថ្មី",
@@ -514,7 +545,6 @@
"seoDescription": "បង្កើតព្រឹត្តិការណ៍ប្រតិទិនលើ {{appName}}។",
"loginTitle": "ចូលគណនីដើម្បីបង្កើតព្រឹត្តិការណ៍",
"loginBody": "ព្រឹត្តិការណ៍គឺជាព្រឹត្តិការណ៍ Nostr ដែលបានចុះហត្ថលេខា។ អ្នកត្រូវការចូល Nostr ដើម្បីផ្សព្វផ្សាយ។",
"backToEvents": "ត្រឡប់ទៅព្រឹត្តិការណ៍",
"heading": "បង្កើតព្រឹត្តិការណ៍",
"titlePlaceholder": "សម្អាតសង្កាត់",
"descriptionPlaceholder": "ប្រាប់អ្នកចូលរួមអំពីអ្វីដែលនឹងកើតឡើង អ្វីត្រូវនាំមក និងអ្នកណាគួរចូលរួម...",
@@ -609,12 +639,23 @@
"yourCampaigns": "យុទ្ធនាការរបស់អ្នក",
"yourCampaignsDesc": "យុទ្ធនាការរបស់អ្នកនៅរស់នៅលើ Nostr ហើយការបរិច្ចាគដំណើរការតាមរយៈតំណយុទ្ធនាការ។ វានឹងបង្ហាញនៅទំព័រដើម នៅពេលដែលអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត។",
"empty": "មិនទាន់មានយុទ្ធនាការនៅឡើយ",
"emptyHint": "ធ្វើជាមនុស្សដំបូងដែលចាប់ផ្ដើមការប្រមូលមូលនិធិនៅ {{appName}}។ ប្រាប់រឿងរបស់អ្នក ជ្រើសរើសអ្នកទទួលផល និងចែករំលែកតំណ។"
"emptyHint": "ធ្វើជាមនុស្សដំបូងដែលចាប់ផ្ដើមការប្រមូលមូលនិធិនៅ {{appName}}។ ប្រាប់រឿងរបស់អ្នក ជ្រើសរើសអ្នកទទួលផល និងចែករំលែកតំណ។",
"searchPlaceholder": "ស្វែងរកយុទ្ធនាការ…",
"searchAriaLabel": "ស្វែងរកយុទ្ធនាការ",
"noMatch": "គ្មានយុទ្ធនាការណាដែលត្រូវនឹង «{{query}}»",
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
},
"all": {
"title": "យុទ្ធនាការទាំងអស់",
"seoTitle": "យុទ្ធនាការទាំងអស់",
"description": "រកមើលរាល់យុទ្ធនាការដែលផ្សព្វផ្សាយលើ Agora។",
"sectionTagline": "រកមើលរាល់បុព្វហេតុនៅលើបណ្ដាញ។",
"heroKicker": "យុទ្ធនាការ",
"heroHeading": "រាល់បុព្វហេតុ",
"heroHeadingLine2": "នៅកន្លែងតែមួយ។",
"heroBody": "រាល់យុទ្ធនាការប្រមូលមូលនិធិដែលផ្សព្វផ្សាយលើ Nostr ត្រូវបានប្រមូលផ្ដុំនៅកន្លែងតែមួយ។ រកមើលបណ្ដាញទាំងមូល ស្វែងរកបុព្វហេតុដែលសក្តិសមសម្រាប់អ្នក និងគាំទ្រវាដោយផ្ទាល់ជាមួយ Bitcoin។",
"campaignsCount_one": "យុទ្ធនាការនៅលើបណ្ដាញ",
"campaignsCount_other": "យុទ្ធនាការនៅលើបណ្ដាញ",
"searchAriaLabel": "ស្វែងរកយុទ្ធនាការ",
"searchPlaceholder": "ស្វែងរកយុទ្ធនាការ…",
"clearSearch": "សម្អាតការស្វែងរក",
@@ -631,6 +672,31 @@
"emptyHint": "មិនទាន់មានយុទ្ធនាការផ្សព្វផ្សាយនៅឡើយទេ។ ធ្វើជាមនុស្សដំបូង។"
}
},
"moderation": {
"hiddenBadge": "បានលាក់",
"menu": {
"label": "សកម្មភាពអ្នកសម្របសម្រួល",
"ariaCampaign": "សម្របសម្រួលយុទ្ធនាការ",
"ariaPledge": "សម្របសម្រួលការសន្យា",
"ariaGroup": "សម្របសម្រួលក្រុម",
"failedAction": "បរាជ័យក្នុងការ {{action}}",
"approve": "អនុម័ត",
"unapprove": "ដកការអនុម័ត",
"approvedState": "បានអនុម័ត",
"hide": "លាក់",
"unhide": "ឈប់លាក់",
"hiddenState": "បានលាក់",
"feature": "លេចធ្លោ",
"unfeature": "ដកការលេចធ្លោ",
"featuredState": "បានលេចធ្លោ",
"toastApproved": "បានអនុម័តសម្រាប់ទំព័រដើម",
"toastUnapproved": "បានដកចេញពីទំព័រដើម",
"toastHidden": "បានលាក់",
"toastUnhidden": "បានឈប់លាក់",
"toastFeatured": "បានលេចធ្លោ",
"toastUnfeatured": "បានដកចេញពីការលេចធ្លោ"
}
},
"settings": {
"title": "ការកំណត់",
"description": "គ្រប់គ្រងការកំណត់ {{appName}} របស់អ្នក",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "អាហ្គូរ៉ា",
"nostr": "Nostr",
"accounts": "គណនី"
"accounts": "អ្នកប្រើប្រាស់"
},
"filters": {
"title": "តម្រង",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "រកមិនឃើញលទ្ធផលដែលត្រូវនឹងការស្វែងរករបស់អ្នកទេ។",
"postsPrompt": "បញ្ចូលពាក្យស្វែងរកដើម្បីរកមាតិកា Nostr។",
"accounts": "រកមិនឃើញគណនីដែលត្រូវនឹងការស្វែងរករបស់អ្នកទេ។",
"accounts": "រកមិនឃើញអ្នកប្រើប្រាស់ដែលត្រូវនឹងការស្វែងរករបស់អ្នកទេ។",
"followsPrompt": "ស្វែងរកមនុស្សតាមឈ្មោះ ឬអាសយដ្ឋាន NIP-05។",
"agora": "រកមិនឃើញយុទ្ធនាការ pledges ឬក្រុម Agora ដែលត្រូវនឹងការស្វែងរករបស់អ្នកទេ។",
"agoraPrompt": "ស្វែងរកយុទ្ធនាការ pledges និងក្រុម Agora ឬរុករកអ្វីដែលថ្មីបំផុត។",
@@ -1972,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "អំពី Agora" },
"payments": { "label": "ការបរិច្ចាគ Bitcoin នៅលើ Agora" },
"about-nostr": { "label": "អំពី Nostr" },
"legacy": { "label": "មាតិកាចាស់" }
"getting-started": {
"label": "អំពី Agora"
},
"payments": {
"label": "ការបរិច្ចាគ Bitcoin នៅលើ Agora"
},
"about-nostr": {
"label": "អំពី Nostr"
},
"legacy": {
"label": "មាតិកាចាស់"
}
},
"items": {
"what-is-ditto": {
@@ -2034,6 +2108,14 @@
"នៅពេលដែលយុទ្ធនាការទទួលយកទាំងពីរ កាបូបរបស់អ្នកបរិច្ចាគសម្រេចថាតើផ្លូវមួយណាដែលត្រូវប្រើ — កាបូបដែលគាំទ្រការទូទាត់ស្ងាត់បង់ប្រាក់ឯកជន ឯផ្សេងទៀតបង់ប្រាក់ទៅអាសយដ្ឋានសាធារណៈ។ សូមអាន **មគ្គុទេសក៍អ្នកបរិច្ចាគ** និង **មគ្គុទេសក៍សកម្មនិយម** សម្រាប់ទិដ្ឋភាពពេញលេញ។"
]
},
"why-donations-pending": {
"question": "ហេតុអ្វីបានជាការបរិច្ចាគខ្លះបង្ហាញថា \"pending\"?",
"answer": [
"ការបរិច្ចាគ **pending** គឺជា Bitcoin ពិតប្រាកដដែលត្រូវបានផ្ញើរួចហើយ — វាគ្រាន់តែកំពុងរង់ចាំការបញ្ជាក់ដោយបណ្តាញប៉ុណ្ណោះ។ កាបូបរបស់អ្នកបរិច្ចាគបានផ្សព្វផ្សាយប្រតិបត្តិការនេះហើយ ប៉ុន្តែវាមិនទាន់ត្រូវបានបញ្ចូលក្នុងប្លុកនៅឡើយទេ។",
"Bitcoin បង្កើតប្លុកថ្មីប្រហែលរៀងរាល់ ១០ នាទីម្ដង ហើយអ្នករុករករ៉ែជ្រើសរើសប្រតិបត្តិការផ្អែកលើថ្លៃដែលអ្នកបរិច្ចាគបានបង់។ ការបរិច្ចាគភាគច្រើនត្រូវបានបញ្ជាក់ក្នុងមួយម៉ោង; ការបរិច្ចាគដែលមានថ្លៃទាបអាចចំណាយពេលយូរជាងនេះនៅពេលដែលបណ្តាញមានភាពមមាញឹក។ {{appName}} នឹងពិនិត្យឡើងវិញនូវស្ថានភាពនៃការបញ្ជាក់ដោយស្វ័យប្រវត្តិ ដូច្នេះស្លាក **pending** នឹងបាត់ដោយខ្លួនឯងនៅពេលដែលប្លុកមួយចេញមកដល់។",
"មូលនិធិកំពុងនៅផ្លូវ — អ្នកសកម្មនិយមនឹងឃើញការបរិច្ចាគនេះត្រូវបានរាប់បញ្ចូលក្នុងសរុបនៃយុទ្ធនាការភ្លាមៗនៅពេលដែលវាត្រូវបានបញ្ជាក់។ គ្មានអ្វីដែលអ្នកសកម្មនិយម ឬ {{appName}} អាចធ្វើបានដើម្បីពន្លឿនវាឡើយ; មានតែកាបូបរបស់អ្នកបរិច្ចាគប៉ុណ្ណោះដែលអាចបង្កើនថ្លៃបាន។"
]
},
"censorship-resistance": {
"question": "តើ «ការប្រឆាំងការត្រួតពិនិត្យ» នៅទីនេះមានន័យអ្វី?",
"answer": [
+96 -14
View File
@@ -20,7 +20,24 @@
"goBack": "بېرته تلل",
"tryAgain": "مهرباني وکړئ بیا هڅه وکړئ.",
"showLess": "لږ وښایاست",
"readMore": "نور ولولئ"
"readMore": "نور ولولئ",
"byAuthor": "د <0>{{name}}</0> لخوا",
"donors_one": "{{count}} بسپنه ورکوونکی",
"donors_other": "{{count}} بسپنه ورکوونکي",
"clearSearch": "لټون پاکول",
"searching": "لټون کېږي…",
"searchResultsCount_one": "{{count}} پایله",
"searchResultsCount_other": "{{count}} پایلې",
"sortAriaLabel": "د ترتیب اساس",
"sortDefault": "اصلي",
"sortTop": "غوره",
"sortNew": "نوي",
"showHidden": "پټ ښودل",
"filtersAriaLabel": "د لټون فلټرونه",
"countryFilterAriaLabel": "د هیواد له مخې فلټر",
"countrySearchPlaceholder": "هیوادونه ولټوئ…",
"countryNoResults": "هیڅ هیواد ونه موندل شو.",
"countryGlobal": "نړیوال"
},
"translate": {
"translate": "ژباړل",
@@ -166,12 +183,23 @@
"showMore": "نور ښودل ({{count}} نور)",
"emptyTitle": "تر اوسه هیڅ ژمنه نشته",
"emptyHint": "د ژمنې جوړولو لومړنی شه.",
"emptyHintCountry": "د {{country}} لپاره د ژمنې جوړولو لومړنی شه."
"emptyHintCountry": "د {{country}} لپاره د ژمنې جوړولو لومړنی شه.",
"searchPlaceholder": "د ژمنو لټون…",
"searchAriaLabel": "د ژمنو لټون",
"noMatch": "هیڅ ژمنه له «{{query}}» سره سمون نه لري",
"noMatchHint": "بل لټون وکارئ، یا لټون پاک کړئ.",
"needsReview": "بیاکتنه ته اړتیا لري",
"needsReviewDesc": "د {{appName}} ژمنې چې تر اوسه نه ځانګړې شوې او نه پټې شوې. یوه یې ځانګړو ته پورته کړئ یا یې په پټولو سره ضعیفه کړئ.",
"needsReviewEmpty": "د بیاکتنې په تمه څه نشته.",
"hidden": "پټې",
"hiddenDesc": "د عامه موندنې څخه پټې ژمنې. د کارت په کباب مینو کې د پټولو لرې کولو لپاره وکاروئ.",
"hiddenEmpty": "اوس مهال هیڅ ژمنه پټه نه ده."
},
"card": {
"ended": "پای ته رسیدلې",
"pledged": "ژمنه شوې",
"byAuthor": "د <0>{{name}}</0> له لوري",
"actionsAriaLabel": "د ژمنې کړنې",
"deletePledge": "ژمنه ړنګول",
"copyLink": "د لینک کاپي",
"linkCopied": "لینک کاپي شو",
@@ -186,7 +214,6 @@
"seoDescription": "په {{appName}} کې د ټاکلې کړنې د الهامولو لپاره د بسپنه ورکوونکي ژمنه جوړه کړئ.",
"loginGateTitle": "د ژمنې جوړولو لپاره ننوځئ",
"loginGateBody": "ژمنې د لاسلیک‌شویو Nostr پیښو په توګه دي. د ژمنې د خپرولو لپاره د Nostr ننوتلو ته اړتیا لرئ.",
"backToPledges": "ژمنو ته بیرته",
"heading": "د ژمنې جوړول",
"title": "سرلیک",
"titlePlaceholder": "د ساحل پاکولو مستندول",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "د ډلې جوړولو لپاره ننوځئ",
"createGroupLoginBody": "د ډلې جوړول ستاسو له حساب څخه د Nostr پیښه خپروي.",
"myGroups": "زما ډلې",
"myGroupsTagline": "هغه ډلې چې تاسو یې جوړې کړي، اداره کوئ، یا یې تعقیبوئ.",
"featuredGroups": "ځانګړې ډلې",
"featuredGroupsTagline": "ځانګړې ډلې چې ستاسو د پاملرنې وړ دي.",
"loginToSeeTitle": "د خپلو ډلو لیدلو لپاره ننوځئ",
"loginToSeeBody": "هغه ډلې چې تاسو یې جوړې کړي یا یې اداره کوئ به دلته راڅرګندې شي.",
"noGroupsTitle": "تر اوسه ډلې نشته",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "په Nostr کې ځانګړې ډله",
"tickerFeaturedGroups_other": "په Nostr کې ځانګړې ډلې",
"tickerCountries_one": "نن ورځ خپروونکی هیواد",
"tickerCountries_other": "نن ورځ خپروونکي هیوادونه"
"tickerCountries_other": "نن ورځ خپروونکي هیوادونه",
"searchPlaceholder": "د ډلو لټون…",
"searchAriaLabel": "د ډلو لټون",
"noMatch": "هیڅ ډله له «{{query}}» سره سمون نه لري",
"noMatchHint": "بل لټون وکارئ، یا لټون پاک کړئ."
},
"create": {
"seoTitleCreate": "د ډلې جوړول",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "په {{appName}} کې خپله ډله تازه کړئ.",
"loginGateTitle": "د ډلې پیلولو لپاره ننوځئ",
"loginGateBody": "ډلې د لاسلیک‌شویو Nostr پیښو په توګه دي. د خپرولو لپاره د Nostr ننوتلو ته اړتیا لرئ.",
"backToGroups": "ډلو ته بیرته",
"invalidEditTitle": "د سمولو ناسم لینک",
"invalidEditBody": "د دې ډلې د سمولو لینک د سمې پتې نه لري.",
"startNewGroup": "نوې ډله پیل کړئ",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "په {{appName}} کې خپل تمویل کمپاین تازه کړئ.",
"loginGateTitle": "د کمپاین پیلولو لپاره ننوځئ",
"loginGateBody": "کمپاینونه د لاسلیک‌شویو Nostr پیښو په توګه دي. د خپرولو لپاره د Nostr ننوتلو ته اړتیا لرئ.",
"goHome": "کور ته لاړ شه",
"invalidEditTitle": "د سمولو ناسم لینک",
"invalidEditBody": "د دې کمپاین د سمولو لینک د سمې پتې نه لري.",
"startNewCampaign": "نوی کمپاین پیل کړئ",
@@ -514,7 +545,6 @@
"seoDescription": "په {{appName}} کې د تقویم پېښه جوړه کړئ.",
"loginTitle": "د پېښې جوړولو لپاره ننوځئ",
"loginBody": "پېښې لاسلیک شوي Nostr پېښې دي. د خپرولو لپاره Nostr ننوتل اړین دي.",
"backToEvents": "پېښو ته ستنېدل",
"heading": "پېښه جوړول",
"titlePlaceholder": "د ګاونډ پاکوالی",
"descriptionPlaceholder": "خلکو ته ووایاست څه تمه ولري، څه راوړي، او څوک باید ګډون وکړي...",
@@ -609,12 +639,23 @@
"yourCampaigns": "ستاسو کمپاینونه",
"yourCampaignsDesc": "ستاسو کمپاینونه په Nostr کې ژوندي دي او مرستې د کمپاین له لینک څخه کار کوي. کله چې د Soapbox ټیم یو مدیر یې ومني، په کور پاڼه کې به څرګند شي.",
"empty": "تر اوسه کوم کمپاین نشته",
"emptyHint": "په {{appName}} کې د مرستو راټولولو کمپاین پیل کوونکی لومړی شئ. خپله کیسه ووایاست، ګټه اخیستونکي وټاکئ، او لینک شریک کړئ."
"emptyHint": "په {{appName}} کې د مرستو راټولولو کمپاین پیل کوونکی لومړی شئ. خپله کیسه ووایاست، ګټه اخیستونکي وټاکئ، او لینک شریک کړئ.",
"searchPlaceholder": "د کمپاینونو لټون…",
"searchAriaLabel": "د کمپاینونو لټون",
"noMatch": "هیڅ کمپاین له «{{query}}» سره سمون نه لري",
"noMatchHint": "بل لټون وکارئ، یا لټون پاک کړئ."
},
"all": {
"heroKicker": "کمپاینونه",
"heroHeading": "هر هدف،",
"heroHeadingLine2": "په یوه ځای کې.",
"heroBody": "په Nostr کې خپور شوی هر د مرستو راټولولو کمپاین په یوه ځای کې راټول شوی. د بشپړې شبکې لټون وکړئ، هغه هدف ومومئ چې درته اهمیت لري، او په مستقیم ډول یې د بټکوین له لارې ملاتړ وکړئ.",
"campaignsCount_one": "په شبکه کې کمپاین",
"campaignsCount_other": "په شبکه کې کمپاینونه",
"title": "ټول کمپاینونه",
"seoTitle": "ټول کمپاینونه",
"description": "په Agora کې خپور شوي ټول کمپاینونه وګورئ.",
"sectionTagline": "په شبکه کې هر هدف وپلټئ.",
"searchAriaLabel": "د کمپاینونو لټون",
"searchPlaceholder": "د کمپاینونو لټون…",
"clearSearch": "د لټون پاکول",
@@ -631,6 +672,31 @@
"emptyHint": "تر اوسه کوم کمپاین نه دی خپور شوی. لومړی شئ."
}
},
"moderation": {
"hiddenBadge": "پټ شوی",
"menu": {
"label": "د څارونکي کړنې",
"ariaCampaign": "د کمپاین څارنه",
"ariaPledge": "د ژمنې څارنه",
"ariaGroup": "د ډلې څارنه",
"failedAction": "په {{action}} کې پاتې راغی",
"approve": "منل",
"unapprove": "د منلو لرې کول",
"approvedState": "منل شوی",
"hide": "پټول",
"unhide": "بېرته ښودل",
"hiddenState": "پټ شوی",
"feature": "ځانګړي کول",
"unfeature": "د ځانګړي حالت لرې کول",
"featuredState": "ځانګړی",
"toastApproved": "د کور پاڼې لپاره منل شوی",
"toastUnapproved": "د کور پاڼې څخه لرې شوی",
"toastHidden": "پټ شوی",
"toastUnhidden": "بېرته ښودل شوی",
"toastFeatured": "ځانګړی شوی",
"toastUnfeatured": "د ځانګړو څخه لرې شوی"
}
},
"settings": {
"title": "تنظیمات",
"description": "د {{appName}} تنظیماتو اداره کول",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "اګورا",
"nostr": "Nostr",
"accounts": "حسابونه"
"accounts": "کاروونکي"
},
"filters": {
"title": "فلټرونه",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "ستاسو د لټون سره مطابق هېڅ پایلې ونه موندل شوې.",
"postsPrompt": "د Nostr د منځپانګې موندلو لپاره د لټون عبارت دننه کړئ.",
"accounts": "ستاسو د لټون سره مطابق هېڅ حسابونه ونه موندل شول.",
"accounts": "ستاسو د لټون سره مطابق هېڅ کاروونکي ونه موندل شول.",
"followsPrompt": "د نوم یا د NIP-05 پته په واسطه د خلکو لټون وکړئ.",
"agora": "ستاسو د لټون سره مطابق د Agora کمپاینونه، pledges، یا ډلې ونه موندل شوې.",
"agoraPrompt": "د Agora کمپاینونه، pledges، او ډلې ولټوئ، یا تازه یې وګورئ.",
@@ -1972,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "د Agora په اړه" },
"payments": { "label": "په Agora کې د بیټکوین مرستې" },
"about-nostr": { "label": "د Nostr په اړه" },
"legacy": { "label": "زاړه محتوا" }
"getting-started": {
"label": "د Agora په اړه"
},
"payments": {
"label": "په Agora کې د بیټکوین مرستې"
},
"about-nostr": {
"label": "د Nostr په اړه"
},
"legacy": {
"label": "زاړه محتوا"
}
},
"items": {
"what-is-ditto": {
@@ -2034,6 +2108,14 @@
"کله چې کمپاین دواړه ومني، د مرستندوی والټ پرېکړه کوي چې کومه لار وکارول شي — د خاموش پرداختونو وړ والټونه په خصوصي ډول پرداخت کوي، نور د عامه پته ته پرداخت کوي. د بشپړ انځور لپاره **د مرستندوی لارښود** او **د فعال لارښود** ولولئ."
]
},
"why-donations-pending": {
"question": "ولې ځینې مرستې «**pending**» ښیي؟",
"answer": [
"یوه **پاتې (pending)** مرسته ریښتینی بیټکوین دی چې لا دمخه لیږل شوی — یوازې د شبکې له خوا د تاییدېدو په تمه ده. د مرستندوی والټ معامله خپره کړې، خو لا تر اوسه په کوم بلاک کې نه ده شامله شوې.",
"بیټکوین نږدې هرې ۱۰ دقیقې وروسته یو نوی بلاک تولیدوي، او مایننګ کوونکي معاملې د هغې فیس له مخې غوره کوي چې مرستندوی ورکړې وي. ډېرې مرستې په یوه ساعت کې تاییدېږي؛ د ټیټې فیس لرونکې مرستې کېدای شي اوږد وخت ونیسي کله چې شبکه بوخته وي. {{appName}} په خپله د تاییدېدو حالت بیا څاري، نو د **پاتې (pending)** نښه پخپله ورکېږي کله چې بلاک ښکته شي.",
"پیسې په لاره دي — فعال به د کمپاین په ټول مجموعه کې دا مرسته وویني همدې شیبه چې تاییدېږي. نه فعال او نه {{appName}} کولای شي چې دا چټک کړي؛ یوازې د مرستندوی والټ کولای شي چې فیس لوړ کړي."
]
},
"censorship-resistance": {
"question": "دلته «د سانسور په وړاندې مقاوم» څه معنی لري؟",
"answer": [
+116 -21
View File
@@ -20,7 +20,24 @@
"goBack": "Voltar",
"tryAgain": "Por favor, tente novamente.",
"showLess": "Mostrar menos",
"readMore": "Ler mais"
"readMore": "Ler mais",
"byAuthor": "por <0>{{name}}</0>",
"donors_one": "{{count}} doador",
"donors_other": "{{count}} doadores",
"clearSearch": "Limpar pesquisa",
"searching": "Pesquisando…",
"searchResultsCount_one": "{{count}} resultado",
"searchResultsCount_other": "{{count}} resultados",
"sortAriaLabel": "Ordem de classificação",
"sortDefault": "Padrão",
"sortTop": "Top",
"sortNew": "Novo",
"showHidden": "Mostrar ocultos",
"filtersAriaLabel": "Filtros de pesquisa",
"countryFilterAriaLabel": "Filtrar por país",
"countrySearchPlaceholder": "Pesquisar países…",
"countryNoResults": "Nenhum país encontrado.",
"countryGlobal": "Global"
},
"translate": {
"translate": "Traduzir",
@@ -210,9 +227,18 @@
"unknown": "DESCONHECIDO"
},
"kindHeader": {
"photo": { "action": "compartilhou uma", "noun": "foto" },
"encryptedMessage": { "action": "enviou uma", "noun": "mensagem criptografada" },
"letter": { "action": "enviou uma", "noun": "carta" },
"photo": {
"action": "compartilhou uma",
"noun": "foto"
},
"encryptedMessage": {
"action": "enviou uma",
"noun": "mensagem criptografada"
},
"letter": {
"action": "enviou uma",
"noun": "carta"
},
"treasureHidCreated": "escondeu um",
"treasureHidUpdated": "atualizou um",
"treasureNoun": "tesouro",
@@ -589,12 +615,23 @@
"showMore": "Mostrar mais ({{count}} mais)",
"emptyTitle": "Nenhuma promessa ainda",
"emptyHint": "Seja o primeiro a criar uma promessa.",
"emptyHintCountry": "Seja o primeiro a criar uma promessa para {{country}}."
"emptyHintCountry": "Seja o primeiro a criar uma promessa para {{country}}.",
"searchPlaceholder": "Pesquisar promessas…",
"searchAriaLabel": "Pesquisar promessas",
"noMatch": "Nenhuma promessa corresponde a “{{query}}”",
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa.",
"needsReview": "Precisa de revisão",
"needsReviewDesc": "Promessas do {{appName}} que ainda não foram destacadas ou ocultadas. Eleve uma para a prateleira em Destaque ou suprima com Ocultar.",
"needsReviewEmpty": "Nada aguardando revisão.",
"hidden": "Ocultas",
"hiddenDesc": "Promessas suprimidas da descoberta pública. Use o menu de três pontos em um cartão para reexibir.",
"hiddenEmpty": "Nenhuma promessa está oculta atualmente."
},
"card": {
"ended": "Encerrada",
"pledged": "Prometido",
"byAuthor": "por <0>{{name}}</0>",
"actionsAriaLabel": "Ações da promessa",
"deletePledge": "Excluir promessa",
"copyLink": "Copiar link",
"linkCopied": "Link copiado",
@@ -609,7 +646,6 @@
"seoDescription": "Crie uma promessa de doador para inspirar ações concretas no {{appName}}.",
"loginGateTitle": "Entre para criar uma promessa",
"loginGateBody": "Promessas são eventos Nostr assinados. Você precisa de um login Nostr para publicar uma.",
"backToPledges": "Voltar para promessas",
"heading": "Criar promessa",
"title": "Título",
"titlePlaceholder": "Documentar uma limpeza de praia",
@@ -688,7 +724,9 @@
"createGroupLoginTitle": "Entre para criar um grupo",
"createGroupLoginBody": "Criar um grupo publica um evento Nostr da sua conta.",
"myGroups": "Meus grupos",
"myGroupsTagline": "Grupos que você fundou, modera ou segue.",
"featuredGroups": "Grupos em destaque",
"featuredGroupsTagline": "Grupos que se destacam e merecem sua atenção.",
"loginToSeeTitle": "Entre para ver seus grupos",
"loginToSeeBody": "Grupos que você fundou ou modera aparecerão aqui.",
"noGroupsTitle": "Nenhum grupo ainda",
@@ -709,7 +747,11 @@
"tickerFeaturedGroups_one": "grupo em destaque no Nostr",
"tickerFeaturedGroups_other": "grupos em destaque no Nostr",
"tickerCountries_one": "país publicando hoje",
"tickerCountries_other": "países publicando hoje"
"tickerCountries_other": "países publicando hoje",
"searchPlaceholder": "Pesquisar grupos…",
"searchAriaLabel": "Pesquisar grupos",
"noMatch": "Nenhum grupo corresponde a “{{query}}”",
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa."
},
"create": {
"seoTitleCreate": "Criar grupo",
@@ -718,7 +760,6 @@
"seoDescriptionEdit": "Atualize seu grupo no {{appName}}.",
"loginGateTitle": "Entre para iniciar um grupo",
"loginGateBody": "Grupos são eventos Nostr assinados. Você precisa de um login Nostr para publicar um.",
"backToGroups": "Voltar para grupos",
"invalidEditTitle": "Link de edição inválido",
"invalidEditBody": "Este link de edição de grupo não contém um endereço de grupo válido.",
"startNewGroup": "Iniciar um novo grupo",
@@ -807,7 +848,6 @@
"seoDescriptionEdit": "Atualize sua campanha de arrecadação no {{appName}}.",
"loginGateTitle": "Entre para iniciar uma campanha",
"loginGateBody": "Campanhas são eventos Nostr assinados. Você precisa de um login Nostr para publicar uma.",
"goHome": "Ir para o início",
"invalidEditTitle": "Link de edição inválido",
"invalidEditBody": "Este link de edição de campanha não contém um endereço de campanha válido.",
"startNewCampaign": "Iniciar uma nova campanha",
@@ -937,7 +977,6 @@
"seoDescription": "Crie um evento de calendário no {{appName}}.",
"loginTitle": "Entre para criar um evento",
"loginBody": "Eventos são eventos Nostr assinados. Você precisa de um login Nostr para publicar um.",
"backToEvents": "Voltar para eventos",
"heading": "Criar evento",
"titlePlaceholder": "Limpeza do bairro",
"descriptionPlaceholder": "Diga às pessoas o que esperar, o que trazer e quem deve comparecer...",
@@ -1032,12 +1071,23 @@
"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.",
"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."
"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…",
"searchAriaLabel": "Pesquisar campanhas",
"noMatch": "Nenhuma campanha corresponde a “{{query}}”",
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa."
},
"all": {
"title": "Todas as campanhas",
"seoTitle": "Todas as campanhas",
"description": "Navegue por todas as campanhas publicadas no Agora.",
"sectionTagline": "Conheça todas as causas da rede.",
"heroKicker": "Campanhas",
"heroHeading": "Cada causa,",
"heroHeadingLine2": "em um só lugar.",
"heroBody": "Cada arrecadação publicada no Nostr, reunida em um só lugar. Navegue pela rede inteira, encontre uma causa que importa para você e apoie diretamente com Bitcoin.",
"campaignsCount_one": "campanha na rede",
"campaignsCount_other": "campanhas na rede",
"searchAriaLabel": "Pesquisar campanhas",
"searchPlaceholder": "Pesquisar campanhas…",
"clearSearch": "Limpar pesquisa",
@@ -1046,14 +1096,39 @@
"sortNew": "Novas",
"showHidden": "Mostrar ocultas",
"startCampaign": "Iniciar uma campanha",
"noMatch": "Nenhuma campanha corresponde a \u201c{{query}}\u201d",
"noMatch": "Nenhuma campanha corresponde a {{query}}",
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa para ver todas as campanhas.",
"allHidden": "Nenhuma campanha para mostrar",
"allHiddenHint": "Todas as campanhas na rede foram ocultadas pelos moderadores. Ative \u201cMostrar ocultas\u201d para visualizá-las.",
"allHiddenHint": "Todas as campanhas na rede foram ocultadas pelos moderadores. Ative Mostrar ocultas para visualizá-las.",
"empty": "Nenhuma campanha ainda",
"emptyHint": "Nenhuma campanha foi publicada ainda. Seja o primeiro."
}
},
"moderation": {
"hiddenBadge": "Oculto",
"menu": {
"label": "Ações de moderador",
"ariaCampaign": "Moderar campanha",
"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",
"toastUnfeatured": "Removido dos destaques"
}
},
"settings": {
"title": "Configurações",
"description": "Gerencie suas configurações do {{appName}}",
@@ -1194,8 +1269,12 @@
"openMenu": "Menu da carteira"
},
"walletSettings": {
"backup": { "label": "Fazer backup da carteira" },
"legacy": { "label": "Recuperação de carteira antiga" }
"backup": {
"label": "Fazer backup da carteira"
},
"legacy": {
"label": "Recuperação de carteira antiga"
}
},
"walletLegacy": {
"seoTitle": "Recuperação de carteira antiga",
@@ -1638,7 +1717,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Contas"
"accounts": "Usuários"
},
"filters": {
"title": "Filtros",
@@ -1711,7 +1790,7 @@
"empty": {
"posts": "Nenhum resultado encontrado correspondendo à sua pesquisa.",
"postsPrompt": "Digite uma consulta de pesquisa para encontrar conteúdo Nostr.",
"accounts": "Nenhuma conta encontrada correspondendo à sua pesquisa.",
"accounts": "Nenhum usuário encontrado correspondendo à sua pesquisa.",
"followsPrompt": "Pesquise pessoas por nome ou endereço NIP-05.",
"agora": "Nenhuma campanha, promessa ou grupo Agora correspondendo à sua pesquisa.",
"agoraPrompt": "Pesquise campanhas, promessas e grupos Agora, ou navegue pelos mais recentes.",
@@ -1959,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "Sobre o Agora" },
"payments": { "label": "Doações em Bitcoin no Agora" },
"about-nostr": { "label": "Sobre o Nostr" },
"legacy": { "label": "Legado" }
"getting-started": {
"label": "Sobre o Agora"
},
"payments": {
"label": "Doações em Bitcoin no Agora"
},
"about-nostr": {
"label": "Sobre o Nostr"
},
"legacy": {
"label": "Legado"
}
},
"items": {
"what-is-ditto": {
@@ -2021,6 +2108,14 @@
"Quando uma campanha aceita ambos, a carteira do doador decide qual caminho usar — carteiras capazes de pagamentos silenciosos pagam com privacidade, outras pagam o endereço público. Leia o **Guia do Doador** e o **Guia do Ativista** para o quadro completo."
]
},
"why-donations-pending": {
"question": "Por que algumas doações aparecem como \"pendentes\"?",
"answer": [
"Uma doação **pendente** é Bitcoin real que já foi enviado — está apenas aguardando confirmação pela rede. A carteira do doador transmitiu a transação, mas ela ainda não foi incluída em um bloco.",
"O Bitcoin produz um novo bloco a cada 10 minutos, aproximadamente, e os mineradores escolhem as transações com base na taxa que o doador pagou. A maioria das doações confirma em até uma hora; as com taxa mais baixa podem demorar mais quando a rede está congestionada. O {{appName}} verifica o status de confirmação automaticamente, então o rótulo **pendente** desaparece sozinho assim que um bloco é minerado.",
"Os fundos estão a caminho — o ativista verá a doação ser contabilizada no total da campanha assim que ela for confirmada. Não há nada que o ativista ou o {{appName}} possam fazer para acelerar; apenas a carteira do doador pode aumentar a taxa."
]
},
"censorship-resistance": {
"question": "O que significa \"resistente à censura\" aqui?",
"answer": [
+108 -17
View File
@@ -20,7 +20,24 @@
"goBack": "Вернуться",
"tryAgain": "Пожалуйста, попробуйте ещё раз.",
"showLess": "Свернуть",
"readMore": "Читать дальше"
"readMore": "Читать дальше",
"byAuthor": "от <0>{{name}}</0>",
"donors_one": "{{count}} донор",
"donors_other": "{{count}} доноров",
"clearSearch": "Очистить поиск",
"searching": "Поиск…",
"searchResultsCount_one": "{{count}} результат",
"searchResultsCount_other": "{{count}} результатов",
"sortAriaLabel": "Порядок сортировки",
"sortDefault": "По умолчанию",
"sortTop": "Топ",
"sortNew": "Новые",
"showHidden": "Показать скрытые",
"filtersAriaLabel": "Фильтры поиска",
"countryFilterAriaLabel": "Фильтр по стране",
"countrySearchPlaceholder": "Поиск стран…",
"countryNoResults": "Страны не найдены.",
"countryGlobal": "Глобально"
},
"translate": {
"translate": "Перевести",
@@ -210,9 +227,18 @@
"unknown": "НЕИЗВЕСТНО"
},
"kindHeader": {
"photo": { "action": "поделился(ась)", "noun": "фото" },
"encryptedMessage": { "action": "отправил(а)", "noun": "зашифрованное сообщение" },
"letter": { "action": "отправил(а)", "noun": "письмо" },
"photo": {
"action": "поделился(ась)",
"noun": "фото"
},
"encryptedMessage": {
"action": "отправил(а)",
"noun": "зашифрованное сообщение"
},
"letter": {
"action": "отправил(а)",
"noun": "письмо"
},
"treasureHidCreated": "спрятал(а)",
"treasureHidUpdated": "обновил(а)",
"treasureNoun": "клад",
@@ -589,12 +615,23 @@
"showMore": "Показать ещё ({{count}})",
"emptyTitle": "Пока нет обещаний",
"emptyHint": "Будьте первым, кто создаст обещание.",
"emptyHintCountry": "Будьте первым, кто создаст обещание для {{country}}."
"emptyHintCountry": "Будьте первым, кто создаст обещание для {{country}}.",
"searchPlaceholder": "Поиск обещаний…",
"searchAriaLabel": "Поиск обещаний",
"noMatch": "Ни одно обещание не соответствует «{{query}}»",
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск.",
"needsReview": "Требуется проверка",
"needsReviewDesc": "Обещания {{appName}}, которые ещё не были выделены или скрыты. Поднимите одно на полку Избранное или подавите его, скрыв.",
"needsReviewEmpty": "Ничего не ждёт проверки.",
"hidden": "Скрытые",
"hiddenDesc": "Обещания, скрытые от публичного обнаружения. Используйте меню с тремя точками на карточке, чтобы вернуть.",
"hiddenEmpty": "В настоящее время нет скрытых обещаний."
},
"card": {
"ended": "Завершено",
"pledged": "Обещано",
"byAuthor": "от <0>{{name}}</0>",
"actionsAriaLabel": "Действия с обещанием",
"deletePledge": "Удалить обещание",
"copyLink": "Копировать ссылку",
"linkCopied": "Ссылка скопирована",
@@ -609,7 +646,6 @@
"seoDescription": "Создайте обещание жертвователя, чтобы вдохновить конкретные действия на {{appName}}.",
"loginGateTitle": "Войдите, чтобы создать обещание",
"loginGateBody": "Обещания — это подписанные события Nostr. Вам нужен вход Nostr, чтобы опубликовать его.",
"backToPledges": "Назад к обещаниям",
"heading": "Создать обещание",
"title": "Название",
"titlePlaceholder": "Задокументировать уборку пляжа",
@@ -688,7 +724,9 @@
"createGroupLoginTitle": "Войдите, чтобы создать группу",
"createGroupLoginBody": "Создание группы публикует событие Nostr от вашего аккаунта.",
"myGroups": "Мои группы",
"myGroupsTagline": "Группы, которые вы основали, модерируете или на которые подписаны.",
"featuredGroups": "Избранные группы",
"featuredGroupsTagline": "Заметные группы, достойные вашего внимания.",
"loginToSeeTitle": "Войдите, чтобы увидеть свои группы",
"loginToSeeBody": "Группы, которые вы основали или модерируете, появятся здесь.",
"noGroupsTitle": "Пока нет групп",
@@ -709,7 +747,11 @@
"tickerFeaturedGroups_one": "избранная группа в Nostr",
"tickerFeaturedGroups_other": "избранных групп в Nostr",
"tickerCountries_one": "страна публикует сегодня",
"tickerCountries_other": "стран публикуют сегодня"
"tickerCountries_other": "стран публикуют сегодня",
"searchPlaceholder": "Поиск групп…",
"searchAriaLabel": "Поиск групп",
"noMatch": "Ни одна группа не соответствует «{{query}}»",
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск."
},
"create": {
"seoTitleCreate": "Создать группу",
@@ -718,7 +760,6 @@
"seoDescriptionEdit": "Обновите свою группу на {{appName}}.",
"loginGateTitle": "Войдите, чтобы создать группу",
"loginGateBody": "Группы — это подписанные события Nostr. Вам нужен вход Nostr, чтобы опубликовать её.",
"backToGroups": "Назад к группам",
"invalidEditTitle": "Недействительная ссылка редактирования",
"invalidEditBody": "В этой ссылке редактирования группы отсутствует действительный адрес группы.",
"startNewGroup": "Создать новую группу",
@@ -807,7 +848,6 @@
"seoDescriptionEdit": "Обновите свою кампанию по сбору средств на {{appName}}.",
"loginGateTitle": "Войдите, чтобы запустить кампанию",
"loginGateBody": "Кампании — это подписанные события Nostr. Вам нужен вход Nostr, чтобы опубликовать её.",
"goHome": "На главную",
"invalidEditTitle": "Недействительная ссылка редактирования",
"invalidEditBody": "В этой ссылке редактирования кампании отсутствует действительный адрес кампании.",
"startNewCampaign": "Запустить новую кампанию",
@@ -937,7 +977,6 @@
"seoDescription": "Создать событие календаря на {{appName}}.",
"loginTitle": "Войдите, чтобы создать событие",
"loginBody": "События — это подписанные события Nostr. Вам нужен вход Nostr, чтобы опубликовать его.",
"backToEvents": "Назад к событиям",
"heading": "Создать событие",
"titlePlaceholder": "Уборка района",
"descriptionPlaceholder": "Расскажите людям, чего ожидать, что взять с собой и кто должен прийти...",
@@ -1032,12 +1071,23 @@
"yourCampaigns": "Ваши кампании",
"yourCampaignsDesc": "Ваши кампании в эфире в Nostr, и пожертвования работают через ссылку кампании. Они появляются на главной странице, когда модератор Team Soapbox их одобряет.",
"empty": "Пока нет кампаний",
"emptyHint": "Будьте первым, кто запустит сбор средств на {{appName}}. Расскажите свою историю, выберите бенефициаров и поделитесь ссылкой."
"emptyHint": "Будьте первым, кто запустит сбор средств на {{appName}}. Расскажите свою историю, выберите бенефициаров и поделитесь ссылкой.",
"searchPlaceholder": "Поиск кампаний…",
"searchAriaLabel": "Поиск кампаний",
"noMatch": "Ни одна кампания не соответствует «{{query}}»",
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск."
},
"all": {
"title": "Все кампании",
"seoTitle": "Все кампании",
"description": "Просмотрите все кампании, опубликованные на Agora.",
"sectionTagline": "Просмотрите каждое дело в сети.",
"heroKicker": "Кампании",
"heroHeading": "Каждое дело —",
"heroHeadingLine2": "в одном месте.",
"heroBody": "Каждый сбор средств, опубликованный в Nostr, собран в одном месте. Просматривайте всю сеть, найдите дело, которое важно для вас, и поддержите его напрямую биткоином.",
"campaignsCount_one": "кампания в сети",
"campaignsCount_other": "кампаний в сети",
"searchAriaLabel": "Поиск кампаний",
"searchPlaceholder": "Поиск кампаний…",
"clearSearch": "Очистить поиск",
@@ -1054,6 +1104,31 @@
"emptyHint": "Кампаний пока не было опубликовано. Будьте первым."
}
},
"moderation": {
"hiddenBadge": "Скрыто",
"menu": {
"label": "Действия модератора",
"ariaCampaign": "Модерировать кампанию",
"ariaPledge": "Модерировать обещание",
"ariaGroup": "Модерировать группу",
"failedAction": "Не удалось выполнить действие: {{action}}",
"approve": "Одобрить",
"unapprove": "Отозвать одобрение",
"approvedState": "Одобрено",
"hide": "Скрыть",
"unhide": "Показать",
"hiddenState": "Скрыто",
"feature": "Выделить",
"unfeature": "Убрать из избранного",
"featuredState": "Избранное",
"toastApproved": "Одобрено для главной страницы",
"toastUnapproved": "Удалено с главной страницы",
"toastHidden": "Скрыто",
"toastUnhidden": "Показано",
"toastFeatured": "Добавлено в избранное",
"toastUnfeatured": "Удалено из избранного"
}
},
"settings": {
"title": "Настройки",
"description": "Управляйте настройками {{appName}}",
@@ -1642,7 +1717,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Аккаунты"
"accounts": "Пользователи"
},
"filters": {
"title": "Фильтры",
@@ -1715,7 +1790,7 @@
"empty": {
"posts": "Не найдено результатов, соответствующих вашему запросу.",
"postsPrompt": "Введите поисковый запрос, чтобы найти контент Nostr.",
"accounts": "Не найдено аккаунтов, соответствующих вашему запросу.",
"accounts": "Не найдено пользователей, соответствующих вашему запросу.",
"followsPrompt": "Ищите людей по имени или адресу NIP-05.",
"agora": "Не найдено кампаний Agora, обещаний или групп, соответствующих вашему запросу.",
"agoraPrompt": "Ищите кампании, обещания и группы Agora или просматривайте последние.",
@@ -1963,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "Об Agora" },
"payments": { "label": "Bitcoin-пожертвования на Agora" },
"about-nostr": { "label": "О Nostr" },
"legacy": { "label": "Наследие" }
"getting-started": {
"label": "Об Agora"
},
"payments": {
"label": "Bitcoin-пожертвования на Agora"
},
"about-nostr": {
"label": "О Nostr"
},
"legacy": {
"label": "Наследие"
}
},
"items": {
"what-is-ditto": {
@@ -2025,6 +2108,14 @@
"Когда кампания принимает оба, кошелёк жертвователя решает, какой путь использовать — кошельки, поддерживающие тихие платежи, платят приватно, другие платят на публичный адрес. Прочитайте **Руководство для жертвователя** и **Руководство для активиста**, чтобы увидеть полную картину."
]
},
"why-donations-pending": {
"question": "Почему некоторые пожертвования отмечены как «в ожидании»?",
"answer": [
"Пожертвование **в ожидании** — это настоящий Bitcoin, который уже отправлен — он просто ждёт подтверждения сетью. Кошелёк жертвователя транслировал транзакцию, но она ещё не включена в блок.",
"Сеть Bitcoin создаёт новый блок примерно каждые 10 минут, и майнеры выбирают транзакции исходя из комиссии, которую заплатил жертвователь. Большинство пожертвований подтверждается в течение часа; пожертвования с низкой комиссией могут идти дольше, когда сеть загружена. {{appName}} автоматически перепроверяет статус подтверждения, поэтому метка **в ожидании** исчезает сама, как только транзакция попадает в блок.",
"Средства уже в пути — активист увидит, как пожертвование зачтётся в общую сумму кампании, как только оно подтвердится. Ни активист, ни {{appName}} не могут ускорить этот процесс; только кошелёк жертвователя может повысить комиссию."
]
},
"censorship-resistance": {
"question": "Что здесь означает «устойчивый к цензуре»?",
"answer": [
+84 -10
View File
@@ -20,7 +20,24 @@
"goBack": "Dzokera",
"tryAgain": "Ndapota edza zvakare.",
"showLess": "Ratidza zvishoma",
"readMore": "Verenga zvimwe"
"readMore": "Verenga zvimwe",
"byAuthor": "na <0>{{name}}</0>",
"donors_one": "mupi {{count}}",
"donors_other": "vapi {{count}}",
"clearSearch": "Bvisa kutsvaga",
"searching": "Kutsvaga…",
"searchResultsCount_one": "{{count}} chakawanikwa",
"searchResultsCount_other": "{{count}} zvakawanikwa",
"sortAriaLabel": "Hurongwa hwekuronga",
"sortDefault": "Yakajairwa",
"sortTop": "Yepamusoro",
"sortNew": "Itsva",
"showHidden": "Ratidza zvakavanzwa",
"filtersAriaLabel": "Masefa ekutsvaga",
"countryFilterAriaLabel": "Sefa nenyika",
"countrySearchPlaceholder": "Tsvaga nyika…",
"countryNoResults": "Hapana nyika dzakawanikwa.",
"countryGlobal": "Pasi rose"
},
"translate": {
"translate": "Dudzira",
@@ -166,12 +183,23 @@
"showMore": "Ratidza zvimwe ({{count}} zvimwe)",
"emptyTitle": "Hapana zvitsidziro parizvino",
"emptyHint": "Iva wekutanga kugadzira chitsidziro.",
"emptyHintCountry": "Iva wekutanga kugadzira chitsidziro che{{country}}."
"emptyHintCountry": "Iva wekutanga kugadzira chitsidziro che{{country}}.",
"searchPlaceholder": "Tsvaga zvitsidziro…",
"searchAriaLabel": "Tsvaga zvitsidziro",
"noMatch": "Hapana chitsidziro chinoenderana ne«{{query}}»",
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga.",
"needsReview": "Inoda kuongororwa",
"needsReviewDesc": "Zvitsidziro zve{{appName}} zvisati zvanyorwa kana kuvanzwa. Simudza chimwe kushelf yanokurumbira kana kuchiderera nokuvigwa.",
"needsReviewEmpty": "Hapana chiri kumirira kuongororwa.",
"hidden": "Zvakavanzwa",
"hiddenDesc": "Zvitsidziro zvakadzvinyirirwa kubva mukuwanikwa pachena. Shandisa menu yekebab pakadhi kuti ubvise kuvanzwa.",
"hiddenEmpty": "Hapana zvitsidziro zvakavanzwa parizvino."
},
"card": {
"ended": "Zvapera",
"pledged": "Zvakatsidzirwa",
"byAuthor": "na <0>{{name}}</0>",
"actionsAriaLabel": "Zviito zvechitsidziro",
"deletePledge": "Bvisa chitsidziro",
"copyLink": "Kopa rink",
"linkCopied": "Rink yakopwa",
@@ -186,7 +214,6 @@
"seoDescription": "Gadzira chitsidziro chemupi kuti ukurudzire chiito chinokosha pa{{appName}}.",
"loginGateTitle": "Pinda kuti ugadzire chitsidziro",
"loginGateBody": "Zvitsidziro zviitiko zveNostr zvakasaina. Unoda kupinda muNostr kuti uburitse chimwe.",
"backToPledges": "Dzokera kuzvitsidziro",
"heading": "Gadzira chitsidziro",
"title": "Musoro",
"titlePlaceholder": "Nyora kuchenesa kwemhenderekedzo",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "Pinda kuti ugadzire boka",
"createGroupLoginBody": "Kugadzira boka kunoburitsa chiitiko cheNostr kubva muakaundi yako.",
"myGroups": "Mapoka angu",
"myGroupsTagline": "Mapoka awakatanga, aunotungamira, kana aunotevera.",
"featuredGroups": "Mapoka anokurumbira",
"featuredGroupsTagline": "Mapoka anobudirira anokodzera kutariswa nemi.",
"loginToSeeTitle": "Pinda kuti uone mapoka ako",
"loginToSeeBody": "Mapoka awakatanga kana aunotungamira achaonekwa pano.",
"noGroupsTitle": "Hapana mapoka parizvino",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "boka rinokurumbira paNostr",
"tickerFeaturedGroups_other": "mapoka anokurumbira paNostr",
"tickerCountries_one": "nyika iri kuburitsa nhasi",
"tickerCountries_other": "nyika dziri kuburitsa nhasi"
"tickerCountries_other": "nyika dziri kuburitsa nhasi",
"searchPlaceholder": "Tsvaga mapoka…",
"searchAriaLabel": "Tsvaga mapoka",
"noMatch": "Hapana boka rinoenderana ne«{{query}}»",
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga."
},
"create": {
"seoTitleCreate": "Gadzira boka",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "Vandudza boka rako pa{{appName}}.",
"loginGateTitle": "Pinda kuti utange boka",
"loginGateBody": "Mapoka zviitiko zveNostr zvakasaina. Unoda kupinda muNostr kuti uburitse rimwe.",
"backToGroups": "Dzokera kumapoka",
"invalidEditTitle": "Rink yokugadzirisa haina kushanda",
"invalidEditBody": "Rink iyi yokugadzirisa boka hapana nzvimbo yeboka yakakodzera.",
"startNewGroup": "Tanga boka idzva",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "Vandudza campaign yako yekuunganidza mari pa{{appName}}.",
"loginGateTitle": "Pinda kuti utange campaign",
"loginGateBody": "Macampaign zviitiko zveNostr zvakasaina. Unoda kupinda muNostr kuti uburitse rimwe.",
"goHome": "Enda kumba",
"invalidEditTitle": "Rink yokugadzirisa haina kushanda",
"invalidEditBody": "Rink iyi yokugadzirisa campaign hapana nzvimbo yecampaign yakakodzera.",
"startNewCampaign": "Tanga campaign idzva",
@@ -514,7 +545,6 @@
"seoDescription": "Gadzira chiitiko checalendar pa{{appName}}.",
"loginTitle": "Pinda kuti ugadzire chiitiko",
"loginBody": "Zviitiko maNostr events akasainwa. Unoda kupinda muNostr kuti ubudise chimwe.",
"backToEvents": "Dzokera kuzviitiko",
"heading": "Gadzira chiitiko",
"titlePlaceholder": "Kuchenesa nharaunda",
"descriptionPlaceholder": "Udza vanhu zvavanotarisira, zvavanofanira kuuya nazvo, uye vanofanira kuuya ndivanaani...",
@@ -609,12 +639,23 @@
"yourCampaigns": "Mishandirapamwe yako",
"yourCampaignsDesc": "Mishandirapamwe yako iri panetwork yeNostr uye mipiro inoshanda kuburikidza nemurawu wemushandirapamwe. Inoonekwa papeji rekutanga kana muoni weTeam Soapbox aitenderera.",
"empty": "Hapana mishandirapamwe parizvino",
"emptyHint": "Iva wekutanga kutanga kuunganidza mari pa{{appName}}. Taura nyaya yako, sarudza vanobatsirwa, uchipa rwumwe rwekugovera."
"emptyHint": "Iva wekutanga kutanga kuunganidza mari pa{{appName}}. Taura nyaya yako, sarudza vanobatsirwa, uchipa rwumwe rwekugovera.",
"searchPlaceholder": "Tsvaga mishandirapamwe…",
"searchAriaLabel": "Tsvaga mishandirapamwe",
"noMatch": "Hapana mushandirapamwe unoenderana ne«{{query}}»",
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga."
},
"all": {
"title": "Mishandirapamwe Yose",
"seoTitle": "Mishandirapamwe yose",
"description": "Tarisa mishandirapamwe yose yakaiswa paAgora.",
"sectionTagline": "Tarisa chinangwa chimwe nechimwe panetwork.",
"heroKicker": "Mishandirapamwe",
"heroHeading": "Chinangwa chimwe nechimwe,",
"heroHeadingLine2": "panzvimbo imwe chete.",
"heroBody": "Mishandirapamwe yose yakaburitswa paNostr, yakaunganidzwa panzvimbo imwe chete. Tarisa network yose, wana chinangwa chinokukosha, uye uchitsigire wakananga neBitcoin.",
"campaignsCount_one": "mushandirapamwe panetwork",
"campaignsCount_other": "mishandirapamwe panetwork",
"searchAriaLabel": "Tsvaga mishandirapamwe",
"searchPlaceholder": "Tsvaga mishandirapamwe…",
"clearSearch": "Bvisa kutsvaga",
@@ -631,6 +672,31 @@
"emptyHint": "Hapana mushandirapamwe wakatumirwa parizvino. Iva wokutanga."
}
},
"moderation": {
"hiddenBadge": "Zvakavanzwa",
"menu": {
"label": "Zviito zvomutariri",
"ariaCampaign": "Tarisa mushandirapamwe",
"ariaPledge": "Tarisa chitsidziro",
"ariaGroup": "Tarisa boka",
"failedAction": "Hazvina kubudirira ku{{action}}",
"approve": "Tendera",
"unapprove": "Bvisa kutenderwa",
"approvedState": "Zvakatenderwa",
"hide": "Vanza",
"unhide": "Bvisa kuvanzwa",
"hiddenState": "Zvakavanzwa",
"feature": "Sarudza",
"unfeature": "Bvisa kusarudzwa",
"featuredState": "Zvakasarudzwa",
"toastApproved": "Zvatenderwa kupeji rekutanga",
"toastUnapproved": "Zvabviswa papeji rekutanga",
"toastHidden": "Zvavanzwa",
"toastUnhidden": "Zvabviswa pakuvanzwa",
"toastFeatured": "Zvasarudzwa",
"toastUnfeatured": "Zvabviswa pakusarudzwa"
}
},
"settings": {
"title": "Marongero",
"description": "Tarisira marongero ako e{{appName}}",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Maakaundi"
"accounts": "Vashandisi"
},
"filters": {
"title": "Mafilter",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "Hapana mhinduro dzakawanikwa dzinoenderana nokutsvaga kwako.",
"postsPrompt": "Isa mubvunzo wokutsvaga kuti uwane zviri muNostr.",
"accounts": "Hapana maakaundi akawanikwa anoenderana nokutsvaga kwako.",
"accounts": "Hapana vashandisi vakawanikwa vanoenderana nokutsvaga kwako.",
"followsPrompt": "Tsvaga vanhu nezita kana kero yeNIP-05.",
"agora": "Hapana macampaign, pledges, kana mapoka eAgora akawanikwa anoenderana nokutsvaga kwako.",
"agoraPrompt": "Tsvaga macampaign, pledges, nemapoka eAgora, kana kutarisa zvitsva.",
@@ -2034,6 +2100,14 @@
"Apo kambeni inogamuchira zvese, walleti yemupi inosarudza nzira yekushandisa — maWalleti anokwanisa mibhadharo yakanyarara anobhadhara muchivande, mamwe anobhadhara kero yepachena. Verenga **Bhuku reMupi** ne**Bhuku reMushanduki** kuti uone mufananidzo wakazara."
]
},
"why-donations-pending": {
"question": "Sei zvimwe zvipo zvichiti \"pending\"?",
"answer": [
"Chipo che**pending** iBitcoin chaiyo yatotumirwa — chiri kungomirira kusimbiswa nenetiweki. Walleti yemupi yakaparadzira chibvumirano, asi hachisati chabatanidzwa mubhirochaina.",
"Bitcoin inoburitsa bhirochaina itsva pamwe chete maminitsi gumi oga oga, uye vacheri vanosarudza zvibvumirano zvichienderana nemubhadharo wakabhadharwa nemupi. Zvipo zvakawanda zvinosimbiswa muawa imwe; zvine fee diki zvinogona kutora nguva refu apo netiweki yakabatikana. {{appName}} inocheka zvakare mamiriro ekusimbiswa ichizvichaira, saka chiratidzo che**pending** chinonyangarika choga kana bhirochaina yauya.",
"Mari iri munzira — mushanduki achaona chipo chichiverengwa muhuwandu hwekambeni kanongochisimbiswa. Hapana chimwe chinogona kuitwa nemushanduki kana {{appName}} kuti vamhanyise zvinhu; walleti yemupi chete ndiyo inogona kuwedzera fee."
]
},
"censorship-resistance": {
"question": "\"Kushinga kucensorship\" pano kunorevei?",
"answer": [
+86 -14
View File
@@ -20,7 +20,24 @@
"goBack": "Rudi nyuma",
"tryAgain": "Tafadhali jaribu tena.",
"showLess": "Onyesha kidogo",
"readMore": "Soma zaidi"
"readMore": "Soma zaidi",
"byAuthor": "na <0>{{name}}</0>",
"donors_one": "mfadhili {{count}}",
"donors_other": "wafadhili {{count}}",
"clearSearch": "Futa utafutaji",
"searching": "Inatafuta…",
"searchResultsCount_one": "tokeo {{count}}",
"searchResultsCount_other": "matokeo {{count}}",
"sortAriaLabel": "Mpangilio wa kupanga",
"sortDefault": "Chaguomsingi",
"sortTop": "Juu",
"sortNew": "Mpya",
"showHidden": "Onyesha zilizofichwa",
"filtersAriaLabel": "Vichujio vya utafutaji",
"countryFilterAriaLabel": "Chuja kwa nchi",
"countrySearchPlaceholder": "Tafuta nchi…",
"countryNoResults": "Hakuna nchi zilizopatikana.",
"countryGlobal": "Kimataifa"
},
"translate": {
"translate": "Tafsiri",
@@ -597,12 +614,23 @@
"showMore": "Onyesha zaidi ({{count}} zaidi)",
"emptyTitle": "Hakuna ahadi bado",
"emptyHint": "Kuwa wa kwanza kuunda ahadi.",
"emptyHintCountry": "Kuwa wa kwanza kuunda ahadi kwa {{country}}."
"emptyHintCountry": "Kuwa wa kwanza kuunda ahadi kwa {{country}}.",
"searchPlaceholder": "Tafuta ahadi…",
"searchAriaLabel": "Tafuta ahadi",
"noMatch": "Hakuna ahadi zinazolingana na “{{query}}”",
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji.",
"needsReview": "Inahitaji ukaguzi",
"needsReviewDesc": "Ahadi za {{appName}} ambazo bado hazijaangaziwa au kufichwa. Nyanyua moja kwenye rafu ya Maarufu au lifiche kwa Ficha.",
"needsReviewEmpty": "Hakuna kinachosubiri ukaguzi.",
"hidden": "Vilivyofichwa",
"hiddenDesc": "Ahadi zilizofichwa kutoka kwa ugunduzi wa umma. Tumia menyu ya nukta tatu kwenye kadi ili kuonyesha.",
"hiddenEmpty": "Hakuna ahadi zilizofichwa kwa sasa."
},
"card": {
"ended": "Imeisha",
"pledged": "Imeahidiwa",
"byAuthor": "na <0>{{name}}</0>",
"actionsAriaLabel": "Vitendo vya ahadi",
"deletePledge": "Futa ahadi",
"copyLink": "Nakili kiungo",
"linkCopied": "Kiungo kimenakiliwa",
@@ -617,7 +645,6 @@
"seoDescription": "Unda ahadi ya mfadhili ili kuhamasisha hatua halisi kwenye {{appName}}.",
"loginGateTitle": "Ingia ili kuunda ahadi",
"loginGateBody": "Ahadi ni matukio ya Nostr yaliyotiwa saini. Unahitaji kuingia kwa Nostr ili kuchapisha moja.",
"backToPledges": "Rudi kwenye ahadi",
"heading": "Unda ahadi",
"title": "Kichwa",
"titlePlaceholder": "Hifadhi kumbukumbu ya kusafisha pwani",
@@ -696,7 +723,9 @@
"createGroupLoginTitle": "Ingia ili kuunda kikundi",
"createGroupLoginBody": "Kuunda kikundi huchapisha tukio la Nostr kutoka kwa akaunti yako.",
"myGroups": "Vikundi vyangu",
"myGroupsTagline": "Vikundi ulivyounda, unavyosimamia, au unavyofuata.",
"featuredGroups": "Vikundi maarufu",
"featuredGroupsTagline": "Vikundi vinavyojitokeza vinavyostahili usikivu wako.",
"loginToSeeTitle": "Ingia ili kuona vikundi vyako",
"loginToSeeBody": "Vikundi ulivyounda au unavyosimamia vitaonekana hapa.",
"noGroupsTitle": "Hakuna vikundi bado",
@@ -717,7 +746,11 @@
"tickerFeaturedGroups_one": "kikundi maarufu kwenye Nostr",
"tickerFeaturedGroups_other": "vikundi maarufu kwenye Nostr",
"tickerCountries_one": "nchi inayochapisha leo",
"tickerCountries_other": "nchi zinazochapisha leo"
"tickerCountries_other": "nchi zinazochapisha leo",
"searchPlaceholder": "Tafuta vikundi…",
"searchAriaLabel": "Tafuta vikundi",
"noMatch": "Hakuna vikundi vinavyolingana na “{{query}}”",
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
},
"create": {
"seoTitleCreate": "Unda kikundi",
@@ -726,7 +759,6 @@
"seoDescriptionEdit": "Sasisha kikundi chako kwenye {{appName}}.",
"loginGateTitle": "Ingia ili kuanza kikundi",
"loginGateBody": "Vikundi ni matukio ya Nostr yaliyotiwa saini. Unahitaji kuingia kwa Nostr ili kuchapisha kimoja.",
"backToGroups": "Rudi kwenye vikundi",
"invalidEditTitle": "Kiungo cha kuhariri si halali",
"invalidEditBody": "Kiungo hiki cha kuhariri kikundi hakina anwani halali ya kikundi.",
"startNewGroup": "Anza kikundi kipya",
@@ -815,7 +847,6 @@
"seoDescriptionEdit": "Sasisha kampeni yako ya kukusanya fedha kwenye {{appName}}.",
"loginGateTitle": "Ingia ili kuanza kampeni",
"loginGateBody": "Kampeni ni matukio ya Nostr yaliyotiwa saini. Unahitaji kuingia kwa Nostr ili kuchapisha moja.",
"goHome": "Nenda nyumbani",
"invalidEditTitle": "Kiungo cha kuhariri si halali",
"invalidEditBody": "Kiungo hiki cha kuhariri kampeni hakina anwani halali ya kampeni.",
"startNewCampaign": "Anza kampeni mpya",
@@ -945,7 +976,6 @@
"seoDescription": "Unda tukio la kalenda kwenye {{appName}}.",
"loginTitle": "Ingia ili kuunda tukio",
"loginBody": "Matukio ni matukio ya Nostr yaliyotiwa saini. Unahitaji kuingia kwa Nostr ili kuchapisha moja.",
"backToEvents": "Rudi kwenye matukio",
"heading": "Unda tukio",
"titlePlaceholder": "Usafi wa mtaa",
"descriptionPlaceholder": "Waambie watu wa kutarajia nini, wachukue nini, na nani anayepaswa kuhudhuria...",
@@ -1040,12 +1070,17 @@
"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.",
"empty": "Hakuna kampeni bado",
"emptyHint": "Kuwa wa kwanza kuanza kampeni ya kukusanya fedha kwenye {{appName}}. Eleza hadithi yako, chagua walengwa wako, na shiriki kiungo."
"emptyHint": "Kuwa wa kwanza kuanza kampeni ya kukusanya fedha kwenye {{appName}}. Eleza hadithi yako, chagua walengwa wako, na shiriki kiungo.",
"searchPlaceholder": "Tafuta kampeni…",
"searchAriaLabel": "Tafuta kampeni",
"noMatch": "Hakuna kampeni zinazolingana na “{{query}}”",
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
},
"all": {
"title": "Kampeni Zote",
"seoTitle": "Kampeni zote",
"description": "Vinjari kila kampeni iliyochapishwa kwenye Agora.",
"sectionTagline": "Vinjari kila lengo kwenye mtandao.",
"searchAriaLabel": "Tafuta kampeni",
"searchPlaceholder": "Tafuta kampeni…",
"clearSearch": "Futa utafutaji",
@@ -1059,7 +1094,38 @@
"allHidden": "Hakuna kampeni za kuonyesha",
"allHiddenHint": "Kila kampeni kwenye mtandao imefichwa na wasimamizi. Geuza “Onyesha zilizofichwa” ili kuziona.",
"empty": "Hakuna kampeni bado",
"emptyHint": "Hakuna kampeni zilizochapishwa bado. Kuwa wa kwanza."
"emptyHint": "Hakuna kampeni zilizochapishwa bado. Kuwa wa kwanza.",
"heroKicker": "Kampeni",
"heroHeading": "Kila lengo,",
"heroHeadingLine2": "mahali pamoja.",
"heroBody": "Kila kampeni iliyochapishwa kwenye Nostr, imekusanywa mahali pamoja. Vinjari mtandao mzima, pata lengo linalokuhusu, na liunge mkono moja kwa moja kwa Bitcoin.",
"campaignsCount_one": "kampeni kwenye mtandao",
"campaignsCount_other": "kampeni kwenye mtandao"
}
},
"moderation": {
"hiddenBadge": "Vilivyofichwa",
"menu": {
"label": "Vitendo vya msimamizi",
"ariaCampaign": "Simamia kampeni",
"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",
"toastUnfeatured": "Imeondolewa kwenye maarufu"
}
},
"settings": {
@@ -1359,8 +1425,6 @@
"title": "Changanua malipo ya kimya",
"description": "Inatembea kifaa cha kuorodhesha cha BIP-352 kilichosanidiwa block-kwa-block kugundua malipo ya kimya yanayoingia.",
"fromBlock": "Kutoka block",
"toBlock": "Hadi block",
"tipPlaceholder": "kilele",
"indexerTip": "Kilele cha kifaa cha kuorodhesha",
"lastFullyScanned": "Iliyochanganuliwa kikamilifu mwisho",
"never": "kamwe",
@@ -1548,7 +1612,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Akaunti"
"accounts": "Watumiaji"
},
"filters": {
"title": "Vichujio",
@@ -1621,7 +1685,7 @@
"empty": {
"posts": "Hakuna matokeo yaliyolingana na utafutaji wako.",
"postsPrompt": "Weka swali la utafutaji ili kupata maudhui ya Nostr.",
"accounts": "Hakuna akaunti zilizolingana na utafutaji wako.",
"accounts": "Hakuna watumiaji waliolingana na utafutaji wako.",
"followsPrompt": "Tafuta watu kwa jina au anwani ya NIP-05.",
"agora": "Hakuna kampeni, ahadi, au vikundi vya Agora vilivyolingana na utafutaji wako.",
"agoraPrompt": "Tafuta kampeni, ahadi, na vikundi vya Agora, au vinjari vya hivi karibuni.",
@@ -1939,6 +2003,14 @@
"Kampeni inapokubali zote mbili, pochi ya mfadhili huamua njia ya kutumia — pochi zenye uwezo wa malipo ya kimya hulipa kwa faragha, nyingine hulipa anwani ya umma. Soma **Mwongozo wa Mfadhili** na **Mwongozo wa Mwanaharakati** kwa picha kamili."
]
},
"why-donations-pending": {
"question": "Kwa nini baadhi ya michango inasema \"inasubiri\"?",
"answer": [
"Mchango **unaosubiri** ni Bitcoin halisi ambayo tayari imetumwa — inangoja tu kuthibitishwa na mtandao. Pochi ya mfadhili imeshatangaza muamala, lakini bado haujajumuishwa katika kizuizi.",
"Bitcoin huzalisha kizuizi kipya takriban kila dakika 10, na wachimbaji huchagua miamala kulingana na ada aliyolipa mfadhili. Michango mingi huthibitishwa ndani ya saa moja; ile yenye ada ya chini inaweza kuchukua muda mrefu wakati mtandao una shughuli nyingi. {{appName}} hukagua tena hali ya uthibitisho kiotomatiki, hivyo lebo ya **inasubiri** hutoweka yenyewe mara kizuizi kinapotua.",
"Fedha ziko njiani — mwanaharakati ataona mchango ukihesabiwa katika jumla ya kampeni mara tu unapothibitishwa. Hakuna jambo mwanaharakati au {{appName}} wanaloweza kufanya kuharakisha; pochi ya mfadhili pekee ndiyo inayoweza kuongeza ada."
]
},
"censorship-resistance": {
"question": "\"Inastahimili udhibiti\" inamaanisha nini hapa?",
"answer": [
@@ -2283,4 +2355,4 @@
}
}
}
}
}
+84 -11
View File
@@ -20,7 +20,23 @@
"goBack": "Geri dön",
"tryAgain": "Lütfen tekrar deneyin.",
"showLess": "Daha az göster",
"readMore": "Daha fazla oku"
"readMore": "Daha fazla oku",
"byAuthor": "<0>{{name}}</0> tarafından",
"donors_one": "{{count}} bağışçı",
"donors_other": "{{count}} bağışçı",
"clearSearch": "Aramayı temizle",
"searching": "Aranıyor\u2026",
"searchResultsCount_one": "{{count}} sonuç",
"searchResultsCount_other": "{{count}} sonuç",
"sortAriaLabel": "Sıralama düzeni",
"sortTop": "Üst",
"sortNew": "Yeni",
"showHidden": "Gizlileri göster",
"filtersAriaLabel": "Arama filtreleri",
"countryFilterAriaLabel": "Ülkeye göre filtrele",
"countrySearchPlaceholder": "Ülke ara…",
"countryNoResults": "Ülke bulunamadı.",
"countryGlobal": "Küresel"
},
"translate": {
"translate": "Çevir",
@@ -598,12 +614,23 @@
"showMore": "Daha fazla göster ({{count}} daha)",
"emptyTitle": "Henüz taahhüt yok",
"emptyHint": "Taahhüt oluşturan ilk kişi olun.",
"emptyHintCountry": "{{country}} için taahhüt oluşturan ilk kişi olun."
"emptyHintCountry": "{{country}} için taahhüt oluşturan ilk kişi olun.",
"searchPlaceholder": "Taahhüt ara\u2026",
"searchAriaLabel": "Taahhütleri ara",
"noMatch": "\u201c{{query}}\u201d ile eşleşen taahhüt yok",
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin.",
"needsReview": "İncelemeyi bekliyor",
"needsReviewDesc": "Henüz öne çıkarılmamış veya gizlenmemiş {{appName}} taahhütleri. Birini Öne Çıkan rafına yükseltin ya da Gizle ile gizleyin.",
"needsReviewEmpty": "İncelemeyi bekleyen bir şey yok.",
"hidden": "Gizli",
"hiddenDesc": "Herkese açık keşiften gizlenmiş taahhütler. Gizliliği kaldırmak için karttaki kebap menüsünü kullanın.",
"hiddenEmpty": "Şu anda gizlenmiş taahhüt yok."
},
"card": {
"ended": "Bitti",
"pledged": "Taahhüt edildi",
"byAuthor": "<0>{{name}}</0> tarafından",
"actionsAriaLabel": "Taahhüt işlemleri",
"deletePledge": "Taahhüdü sil",
"copyLink": "Bağlantıyı kopyala",
"linkCopied": "Bağlantı kopyalandı",
@@ -618,7 +645,6 @@
"seoDescription": "{{appName}}'da somut eylemlere ilham veren bir bağışçı taahhüdü oluşturun.",
"loginGateTitle": "Taahhüt oluşturmak için giriş yapın",
"loginGateBody": "Taahhütler imzalı Nostr event'leridir. Yayımlamak için bir Nostr girişi gerekir.",
"backToPledges": "Taahhütlere dön",
"heading": "Taahhüt oluştur",
"title": "Başlık",
"titlePlaceholder": "Bir sahil temizliğini belgele",
@@ -697,7 +723,9 @@
"createGroupLoginTitle": "Grup oluşturmak için giriş yapın",
"createGroupLoginBody": "Grup oluşturmak hesabınızdan bir Nostr event'i yayımlar.",
"myGroups": "Gruplarım",
"myGroupsTagline": "Kurduğunuz, yönettiğiniz veya takip ettiğiniz gruplar.",
"featuredGroups": "Öne çıkan gruplar",
"featuredGroupsTagline": "Dikkatinize değer öne çıkan gruplar.",
"loginToSeeTitle": "Gruplarınızı görmek için giriş yapın",
"loginToSeeBody": "Kurduğunuz veya yönettiğiniz gruplar burada görünecek.",
"noGroupsTitle": "Henüz grup yok",
@@ -718,7 +746,11 @@
"tickerFeaturedGroups_one": "Nostr'da öne çıkan grup",
"tickerFeaturedGroups_other": "Nostr'da öne çıkan grup",
"tickerCountries_one": "ülke bugün paylaşım yapıyor",
"tickerCountries_other": "ülke bugün paylaşım yapıyor"
"tickerCountries_other": "ülke bugün paylaşım yapıyor",
"searchPlaceholder": "Grup ara\u2026",
"searchAriaLabel": "Grupları ara",
"noMatch": "\u201c{{query}}\u201d ile eşleşen grup yok",
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
},
"create": {
"seoTitleCreate": "Grup oluştur",
@@ -727,7 +759,6 @@
"seoDescriptionEdit": "{{appName}}'daki grubunuzu güncelleyin.",
"loginGateTitle": "Grup başlatmak için giriş yapın",
"loginGateBody": "Gruplar imzalı Nostr event'leridir. Yayımlamak için bir Nostr girişi gerekir.",
"backToGroups": "Gruplara dön",
"invalidEditTitle": "Geçersiz düzenleme bağlantısı",
"invalidEditBody": "Bu grup düzenleme bağlantısında geçerli bir grup adresi yok.",
"startNewGroup": "Yeni bir grup başlat",
@@ -816,7 +847,6 @@
"seoDescriptionEdit": "{{appName}}'daki fon toplama kampanyanızı güncelleyin.",
"loginGateTitle": "Kampanya başlatmak için giriş yapın",
"loginGateBody": "Kampanyalar imzalı Nostr event'leridir. Yayımlamak için bir Nostr girişi gerekir.",
"goHome": "Ana sayfaya dön",
"invalidEditTitle": "Geçersiz düzenleme bağlantısı",
"invalidEditBody": "Bu kampanya düzenleme bağlantısında geçerli bir kampanya adresi yok.",
"startNewCampaign": "Yeni bir kampanya başlat",
@@ -946,7 +976,6 @@
"seoDescription": "{{appName}}'da bir takvim etkinliği oluşturun.",
"loginTitle": "Etkinlik oluşturmak için giriş yapın",
"loginBody": "Etkinlikler imzalı Nostr event'leridir. Yayımlamak için bir Nostr girişi gerekir.",
"backToEvents": "Etkinliklere dön",
"heading": "Etkinlik oluştur",
"titlePlaceholder": "Mahalle temizliği",
"descriptionPlaceholder": "İnsanlara ne beklemeleri, neyi getirmeleri ve kimlerin katılması gerektiğini anlatın...",
@@ -1041,12 +1070,17 @@
"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.",
"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."
"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\u2026",
"searchAriaLabel": "Kampanyaları ara",
"noMatch": "\u201c{{query}}\u201d ile eşleşen kampanya yok",
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
},
"all": {
"title": "Tüm Kampanyalar",
"seoTitle": "Tüm kampanyalar",
"description": "Agora'da yayımlanmış her kampanyaya göz atın.",
"sectionTagline": "Ağdaki her davaya göz atın.",
"searchAriaLabel": "Kampanyaları ara",
"searchPlaceholder": "Kampanya ara…",
"clearSearch": "Aramayı temizle",
@@ -1060,7 +1094,38 @@
"allHidden": "Gösterilecek kampanya yok",
"allHiddenHint": "Ağdaki her kampanya moderatörler tarafından gizlenmiş. Görmek için “Gizlileri göster” seçeneğini açın.",
"empty": "Henüz kampanya yok",
"emptyHint": "Henüz hiçbir kampanya yayımlanmamış. İlk olun."
"emptyHint": "Henüz hiçbir kampanya yayımlanmamış. İlk olun.",
"heroKicker": "Kampanyalar",
"heroHeading": "Her dava,",
"heroHeadingLine2": "tek bir yerde.",
"heroBody": "Nostr'da yayımlanan her bağış kampanyası tek bir yerde toplandı. Ağın tamamına göz atın, sizin için önemli bir dava bulun ve doğrudan Bitcoin ile destekleyin.",
"campaignsCount_one": "ağdaki kampanya",
"campaignsCount_other": "ağdaki kampanya"
}
},
"moderation": {
"hiddenBadge": "Gizli",
"menu": {
"label": "Moderatör işlemleri",
"ariaCampaign": "Kampanyayı modere et",
"ariaPledge": "Taahhüdü modere et",
"ariaGroup": "Grubu modere et",
"failedAction": "{{action}} başarısız oldu",
"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ı",
"toastUnfeatured": "Öne çıkanlardan kaldırıldı"
}
},
"settings": {
@@ -1587,7 +1652,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "Hesaplar"
"accounts": "Kullanıcılar"
},
"filters": {
"title": "Filtreler",
@@ -1660,7 +1725,7 @@
"empty": {
"posts": "Aramanızla eşleşen sonuç bulunamadı.",
"postsPrompt": "Nostr içeriği bulmak için bir arama sorgusu girin.",
"accounts": "Aramanızla eşleşen hesap bulunamadı.",
"accounts": "Aramanızla eşleşen kullanıcı bulunamadı.",
"followsPrompt": "İnsanları ada veya NIP-05 adresine göre arayın.",
"agora": "Aramanızla eşleşen Agora kampanyası, taahhüdü veya grubu bulunamadı.",
"agoraPrompt": "Agora kampanyalarını, taahhütlerini ve gruplarını arayın ya da en yenilere göz atın.",
@@ -1978,6 +2043,14 @@
"Bir kampanya her ikisini de kabul ettiğinde, hangi yolun kullanılacağına bağışçının cüzdanı karar verir — sessiz ödemeleri destekleyen cüzdanlar gizli olarak öder, diğerleri açık adrese öder. Tam görüntü için **Bağışçı Rehberi**'ni ve **Aktivist Rehberi**'ni okuyun."
]
},
"why-donations-pending": {
"question": "Bazı bağışlar neden \"beklemede\" görünüyor?",
"answer": [
"**Beklemede** olan bir bağış, zaten gönderilmiş gerçek Bitcoin'dir — yalnızca ağ tarafından onaylanmayı beklemektedir. Bağışçının cüzdanı işlemi yayımlamıştır, ancak işlem henüz bir bloğa dahil edilmemiştir.",
"Bitcoin yaklaşık her 10 dakikada bir yeni bir blok üretir ve madenciler işlemleri bağışçının ödediği ücrete göre seçer. Bağışların çoğu bir saat içinde onaylanır; düşük ücretli olanlar ağ yoğun olduğunda daha uzun sürebilir. {{appName}} onay durumunu otomatik olarak yeniden kontrol eder, bu yüzden bir blok geldiğinde **beklemede** etiketi kendiliğinden kaybolur.",
"Fonlar yoldadır — bağış onaylandığı anda aktivist, bağışın kampanya toplamına eklendiğini görecektir. Aktivistin veya {{appName}}'in işlemi hızlandırmak için yapabileceği hiçbir şey yoktur; yalnızca bağışçının cüzdanı ücreti artırabilir."
]
},
"censorship-resistance": {
"question": "Buradaki \"sansüre dayanıklı\" ne anlama geliyor?",
"answer": [
+84 -10
View File
@@ -20,7 +20,24 @@
"goBack": "返回",
"tryAgain": "請重試。",
"showLess": "收起",
"readMore": "閱讀更多"
"readMore": "閱讀更多",
"byAuthor": "由 <0>{{name}}</0>",
"donors_one": "{{count}} 位捐贈者",
"donors_other": "{{count}} 位捐贈者",
"clearSearch": "清除搜尋",
"searching": "搜尋中…",
"searchResultsCount_one": "{{count}} 個結果",
"searchResultsCount_other": "{{count}} 個結果",
"sortAriaLabel": "排序方式",
"sortDefault": "預設",
"sortTop": "熱門",
"sortNew": "最新",
"showHidden": "顯示已隱藏",
"filtersAriaLabel": "搜尋篩選",
"countryFilterAriaLabel": "依國家篩選",
"countrySearchPlaceholder": "搜尋國家…",
"countryNoResults": "找不到國家。",
"countryGlobal": "全球"
},
"translate": {
"translate": "翻譯",
@@ -166,12 +183,23 @@
"showMore": "展開更多(還有 {{count}} 個)",
"emptyTitle": "還沒有懸賞",
"emptyHint": "成為第一個建立懸賞的人。",
"emptyHintCountry": "成為第一個為 {{country}} 建立懸賞的人。"
"emptyHintCountry": "成為第一個為 {{country}} 建立懸賞的人。",
"searchPlaceholder": "搜尋懸賞…",
"searchAriaLabel": "搜尋懸賞",
"noMatch": "沒有懸賞符合「{{query}}」",
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。",
"needsReview": "待稽核",
"needsReviewDesc": "尚未被推薦或隱藏的 {{appName}} 懸賞。把它們提升到精選欄,或用「隱藏」抑制。",
"needsReviewEmpty": "沒有待稽核的內容。",
"hidden": "已隱藏",
"hiddenDesc": "從公開發現中抑制的懸賞。使用卡片上的選單取消隱藏。",
"hiddenEmpty": "目前沒有隱藏的懸賞。"
},
"card": {
"ended": "已結束",
"pledged": "已懸賞",
"byAuthor": "由 <0>{{name}}</0>",
"actionsAriaLabel": "懸賞操作",
"deletePledge": "刪除懸賞",
"copyLink": "複製連結",
"linkCopied": "已複製連結",
@@ -186,7 +214,6 @@
"seoDescription": "建立一個捐贈者懸賞,在 {{appName}} 上激發具體的行動。",
"loginGateTitle": "登入以建立懸賞",
"loginGateBody": "懸賞是簽名的 Nostr 事件。你需要 Nostr 登入才能釋出。",
"backToPledges": "返回懸賞",
"heading": "建立懸賞",
"title": "標題",
"titlePlaceholder": "記錄一次海灘清理",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "登入以建立群組",
"createGroupLoginBody": "建立群組會從你的賬號釋出一個 Nostr 事件。",
"myGroups": "我的群組",
"myGroupsTagline": "你建立、管理或追蹤的群組。",
"featuredGroups": "精選群組",
"featuredGroupsTagline": "值得你關注的出色群組。",
"loginToSeeTitle": "登入以檢視你的群組",
"loginToSeeBody": "你建立或管理的群組會顯示在這裡。",
"noGroupsTitle": "還沒有群組",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "個 Nostr 精選群組",
"tickerFeaturedGroups_other": "個 Nostr 精選群組",
"tickerCountries_one": "個國家今天發帖",
"tickerCountries_other": "個國家今天發帖"
"tickerCountries_other": "個國家今天發帖",
"searchPlaceholder": "搜尋群組…",
"searchAriaLabel": "搜尋群組",
"noMatch": "沒有群組符合「{{query}}」",
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
},
"create": {
"seoTitleCreate": "建立群組",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "在 {{appName}} 上更新你的群組。",
"loginGateTitle": "登入以發起群組",
"loginGateBody": "群組是簽名的 Nostr 事件。你需要 Nostr 登入才能釋出。",
"backToGroups": "返回群組",
"invalidEditTitle": "編輯連結無效",
"invalidEditBody": "此群組編輯連結缺少有效的群組地址。",
"startNewGroup": "發起一個新群組",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "在 {{appName}} 上更新你的募款活動。",
"loginGateTitle": "登入以發起活動",
"loginGateBody": "活動是簽名的 Nostr 事件。你需要 Nostr 登入才能釋出。",
"goHome": "返回首頁",
"invalidEditTitle": "編輯連結無效",
"invalidEditBody": "此活動編輯連結缺少有效的活動地址。",
"startNewCampaign": "發起一個新活動",
@@ -514,7 +545,6 @@
"seoDescription": "在 {{appName}} 上建立日曆事件。",
"loginTitle": "登入以建立事件",
"loginBody": "事件是已簽名的 Nostr 事件。你需要登入 Nostr 才能釋出。",
"backToEvents": "返回事件",
"heading": "建立事件",
"titlePlaceholder": "社群清理",
"descriptionPlaceholder": "告訴大家會發生什麼、需要帶什麼,以及誰適合參加...",
@@ -609,12 +639,23 @@
"yourCampaigns": "你的活動",
"yourCampaignsDesc": "你的活動已在 Nostr 上線,通過活動連結可以接收捐款。一旦 Soapbox 團隊版主批准,它們將出現在首頁。",
"empty": "暫無活動",
"emptyHint": "成為在 {{appName}} 發起眾籌的第一人。講述你的故事、選擇受益人、並分享連結。"
"emptyHint": "成為在 {{appName}} 發起眾籌的第一人。講述你的故事、選擇受益人、並分享連結。",
"searchPlaceholder": "搜尋活動…",
"searchAriaLabel": "搜尋活動",
"noMatch": "沒有活動符合「{{query}}」",
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
},
"all": {
"title": "所有活動",
"seoTitle": "所有活動",
"description": "瀏覽 Agora 上釋出的所有活動。",
"sectionTagline": "瀏覽網路上的每一項事業。",
"heroKicker": "活動",
"heroHeading": "每一份心意,",
"heroHeadingLine2": "匯聚於此。",
"heroBody": "Nostr 上釋出的每一個募款活動,全部匯聚於此。瀏覽整個網路、找到你關心的議題,並直接以比特幣支持它。",
"campaignsCount_one": "個活動正在網路上進行",
"campaignsCount_other": "個活動正在網路上進行",
"searchAriaLabel": "搜尋活動",
"searchPlaceholder": "搜尋活動…",
"clearSearch": "清除搜尋",
@@ -631,6 +672,31 @@
"emptyHint": "尚未釋出任何活動。來當第一個吧。"
}
},
"moderation": {
"hiddenBadge": "已隱藏",
"menu": {
"label": "版主動作",
"ariaCampaign": "管理活動",
"ariaPledge": "管理懸賞",
"ariaGroup": "管理群組",
"failedAction": "無法{{action}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"hide": "隱藏",
"unhide": "取消隱藏",
"hiddenState": "已隱藏",
"feature": "推薦精選",
"unfeature": "取消精選",
"featuredState": "已精選",
"toastApproved": "已批准顯示於首頁",
"toastUnapproved": "已自首頁移除",
"toastHidden": "已隱藏",
"toastUnhidden": "已取消隱藏",
"toastFeatured": "已加入精選",
"toastUnfeatured": "已自精選移除"
}
},
"settings": {
"title": "設定",
"description": "管理你的 {{appName}} 設定",
@@ -1155,7 +1221,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "賬號"
"accounts": "使用者"
},
"filters": {
"title": "過濾器",
@@ -1228,7 +1294,7 @@
"empty": {
"posts": "沒有找到匹配你搜索的結果。",
"postsPrompt": "輸入搜尋關鍵詞以查詢 Nostr 內容。",
"accounts": "沒有找到匹配你搜索的賬號。",
"accounts": "沒有找到匹配你搜尋的使用者。",
"followsPrompt": "通過名字或 NIP-05 地址搜尋使用者。",
"agora": "沒有找到匹配你搜索的 Agora 競選、pledge 或群組。",
"agoraPrompt": "搜尋 Agora 的競選、pledge 和群組,或瀏覽最新內容。",
@@ -1978,6 +2044,14 @@
"當一項活動同時接受兩者時,由捐款人的錢包決定使用哪條路徑——支援靜默支付的錢包私密支付,其他錢包則支付公開地址。完整圖景請閱讀**捐款人指南**和**行動者指南**。"
]
},
"why-donations-pending": {
"question": "為什麼有些捐款顯示為「**待確認**」?",
"answer": [
"**待確認**的捐款是真實的比特幣,已經被送出——只是還在等待網路確認。捐款人的錢包已經廣播了交易,但它尚未被納入區塊。",
"比特幣大約每 10 分鐘產生一個新區塊,礦工會根據捐款人支付的手續費來挑選交易。大多數捐款會在一小時內確認;當網路繁忙時,手續費較低的交易可能需要更久。{{appName}} 會自動重新檢查確認狀態,因此一旦交易進入區塊,**待確認**標籤就會自行消失。",
"款項已經在路上——交易一旦確認,行動者就會看到該捐款計入活動總額。行動者與 {{appName}} 都無法加速這個過程;只有捐款人的錢包能夠提高手續費。"
]
},
"censorship-resistance": {
"question": "這裡所說的“抗審查”是什麼意思?",
"answer": [
+97 -15
View File
@@ -20,7 +20,24 @@
"goBack": "返回",
"tryAgain": "请重试。",
"showLess": "收起",
"readMore": "阅读更多"
"readMore": "阅读更多",
"byAuthor": "由 <0>{{name}}</0>",
"donors_one": "{{count}} 位捐赠者",
"donors_other": "{{count}} 位捐赠者",
"clearSearch": "清除搜索",
"searching": "搜索中…",
"searchResultsCount_one": "{{count}} 个结果",
"searchResultsCount_other": "{{count}} 个结果",
"sortAriaLabel": "排序",
"sortDefault": "默认",
"sortTop": "热门",
"sortNew": "最新",
"showHidden": "显示已隐藏",
"filtersAriaLabel": "搜索筛选",
"countryFilterAriaLabel": "按国家筛选",
"countrySearchPlaceholder": "搜索国家…",
"countryNoResults": "未找到国家。",
"countryGlobal": "全球"
},
"translate": {
"translate": "翻译",
@@ -166,12 +183,23 @@
"showMore": "展开更多(还有 {{count}} 个)",
"emptyTitle": "还没有悬赏",
"emptyHint": "成为第一个创建悬赏的人。",
"emptyHintCountry": "成为第一个为 {{country}} 创建悬赏的人。"
"emptyHintCountry": "成为第一个为 {{country}} 创建悬赏的人。",
"searchPlaceholder": "搜索悬赏…",
"searchAriaLabel": "搜索悬赏",
"noMatch": "没有悬赏匹配「{{query}}」",
"noMatchHint": "尝试其他搜索词,或清除搜索。",
"needsReview": "待审核",
"needsReviewDesc": "尚未被推荐或隐藏的 {{appName}} 悬赏。把它们提升到精选栏,或用「隐藏」抑制。",
"needsReviewEmpty": "没有待审核的内容。",
"hidden": "已隐藏",
"hiddenDesc": "从公开发现中抑制的悬赏。使用卡片上的菜单取消隐藏。",
"hiddenEmpty": "目前没有隐藏的悬赏。"
},
"card": {
"ended": "已结束",
"pledged": "已悬赏",
"byAuthor": "由 <0>{{name}}</0>",
"actionsAriaLabel": "悬赏操作",
"deletePledge": "删除悬赏",
"copyLink": "复制链接",
"linkCopied": "已复制链接",
@@ -186,7 +214,6 @@
"seoDescription": "创建一个捐赠者悬赏,在 {{appName}} 上激发具体的行动。",
"loginGateTitle": "登录以创建悬赏",
"loginGateBody": "悬赏是签名的 Nostr 事件。你需要 Nostr 登录才能发布。",
"backToPledges": "返回悬赏",
"heading": "创建悬赏",
"title": "标题",
"titlePlaceholder": "记录一次海滩清理",
@@ -265,7 +292,9 @@
"createGroupLoginTitle": "登录以创建群组",
"createGroupLoginBody": "创建群组会从你的账号发布一个 Nostr 事件。",
"myGroups": "我的群组",
"myGroupsTagline": "你创建、管理或关注的群组。",
"featuredGroups": "精选群组",
"featuredGroupsTagline": "值得你关注的出色群组。",
"loginToSeeTitle": "登录以查看你的群组",
"loginToSeeBody": "你创建或管理的群组会显示在这里。",
"noGroupsTitle": "还没有群组",
@@ -286,7 +315,11 @@
"tickerFeaturedGroups_one": "个 Nostr 精选群组",
"tickerFeaturedGroups_other": "个 Nostr 精选群组",
"tickerCountries_one": "个国家今天发帖",
"tickerCountries_other": "个国家今天发帖"
"tickerCountries_other": "个国家今天发帖",
"searchPlaceholder": "搜索群组…",
"searchAriaLabel": "搜索群组",
"noMatch": "没有群组匹配「{{query}}」",
"noMatchHint": "尝试其他搜索词,或清除搜索。"
},
"create": {
"seoTitleCreate": "创建群组",
@@ -295,7 +328,6 @@
"seoDescriptionEdit": "在 {{appName}} 上更新你的群组。",
"loginGateTitle": "登录以发起群组",
"loginGateBody": "群组是签名的 Nostr 事件。你需要 Nostr 登录才能发布。",
"backToGroups": "返回群组",
"invalidEditTitle": "编辑链接无效",
"invalidEditBody": "此群组编辑链接缺少有效的群组地址。",
"startNewGroup": "发起一个新群组",
@@ -384,7 +416,6 @@
"seoDescriptionEdit": "在 {{appName}} 上更新你的募款活动。",
"loginGateTitle": "登录以发起活动",
"loginGateBody": "活动是签名的 Nostr 事件。你需要 Nostr 登录才能发布。",
"goHome": "返回首页",
"invalidEditTitle": "编辑链接无效",
"invalidEditBody": "此活动编辑链接缺少有效的活动地址。",
"startNewCampaign": "发起一个新活动",
@@ -514,7 +545,6 @@
"seoDescription": "在 {{appName}} 上创建日历事件。",
"loginTitle": "登录以创建事件",
"loginBody": "事件是已签名的 Nostr 事件。你需要登录 Nostr 才能发布。",
"backToEvents": "返回事件",
"heading": "创建事件",
"titlePlaceholder": "社区清理",
"descriptionPlaceholder": "告诉大家会发生什么、需要带什么,以及谁适合参加...",
@@ -609,12 +639,23 @@
"yourCampaigns": "你的活动",
"yourCampaignsDesc": "你的活动已在 Nostr 上线,通过活动链接可以接收捐款。一旦 Soapbox 团队版主批准,它们将出现在首页。",
"empty": "暂无活动",
"emptyHint": "成为在 {{appName}} 发起众筹的第一人。讲述你的故事、选择受益人、并分享链接。"
"emptyHint": "成为在 {{appName}} 发起众筹的第一人。讲述你的故事、选择受益人、并分享链接。",
"searchPlaceholder": "搜索活动…",
"searchAriaLabel": "搜索活动",
"noMatch": "没有活动匹配「{{query}}」",
"noMatchHint": "尝试其他搜索词,或清除搜索。"
},
"all": {
"title": "所有活动",
"seoTitle": "所有活动",
"description": "浏览 Agora 上发布的所有活动。",
"sectionTagline": "浏览网络上的每一项事业。",
"heroKicker": "活动",
"heroHeading": "每一个理念,",
"heroHeadingLine2": "汇聚于此。",
"heroBody": "Nostr 上发布的每一个筹款活动,汇聚于一处。浏览整个网络,找到你关心的事业,直接用比特币支持它。",
"campaignsCount_one": "个网络上的活动",
"campaignsCount_other": "个网络上的活动",
"searchAriaLabel": "搜索活动",
"searchPlaceholder": "搜索活动…",
"clearSearch": "清除搜索",
@@ -631,6 +672,31 @@
"emptyHint": "尚未发布任何活动。来当第一个吧。"
}
},
"moderation": {
"hiddenBadge": "已隐藏",
"menu": {
"label": "版主操作",
"ariaCampaign": "管理活动",
"ariaPledge": "管理悬赏",
"ariaGroup": "管理群组",
"failedAction": "操作失败:{{action}}",
"approve": "批准",
"unapprove": "取消批准",
"approvedState": "已批准",
"hide": "隐藏",
"unhide": "取消隐藏",
"hiddenState": "已隐藏",
"feature": "精选",
"unfeature": "取消精选",
"featuredState": "已精选",
"toastApproved": "已批准至首页",
"toastUnapproved": "已从首页移除",
"toastHidden": "已隐藏",
"toastUnhidden": "已取消隐藏",
"toastFeatured": "已精选",
"toastUnfeatured": "已从精选移除"
}
},
"settings": {
"title": "设置",
"description": "管理你的 {{appName}} 设置",
@@ -757,7 +823,7 @@
"scanForNew": "扫描新支付",
"scanForPayments": "扫描支付"
},
"tx": {
"tx": {
"pending": "待确认",
"today": "今天",
"yesterday": "昨天",
@@ -1219,7 +1285,7 @@
"tabs": {
"agora": "Agora",
"nostr": "Nostr",
"accounts": "账号"
"accounts": "用户"
},
"filters": {
"title": "过滤器",
@@ -1292,7 +1358,7 @@
"empty": {
"posts": "没有找到匹配你搜索的结果。",
"postsPrompt": "输入搜索关键词以查找 Nostr 内容。",
"accounts": "没有找到匹配你搜索的账号。",
"accounts": "没有找到匹配你搜索的用户。",
"followsPrompt": "通过名字或 NIP-05 地址搜索用户。",
"agora": "没有找到匹配你搜索的 Agora 竞选、pledge 或群组。",
"agoraPrompt": "搜索 Agora 的竞选、pledge 和群组,或浏览最新内容。",
@@ -1972,10 +2038,18 @@
},
"faq": {
"categories": {
"getting-started": { "label": "关于 Agora" },
"payments": { "label": " Agora 上的比特币捐款" },
"about-nostr": { "label": "关于 Nostr" },
"legacy": { "label": "遗留内容" }
"getting-started": {
"label": "关于 Agora"
},
"payments": {
"label": "在 Agora 上的比特币捐款"
},
"about-nostr": {
"label": "关于 Nostr"
},
"legacy": {
"label": "遗留内容"
}
},
"items": {
"what-is-ditto": {
@@ -2034,6 +2108,14 @@
"当一项活动同时接受两者时,由捐款人的钱包决定使用哪条路径——支持静默支付的钱包私密支付,其他钱包则支付公开地址。完整图景请阅读**捐款人指南**和**行动者指南**。"
]
},
"why-donations-pending": {
"question": "为什么有些捐款显示为“**待确认**”?",
"answer": [
"**待确认**的捐款是真实的、已经发送出去的比特币——只是还在等待网络确认。捐款人的钱包已经广播了这笔交易,但它尚未被打包进区块。",
"比特币大约每 10 分钟产生一个新区块,矿工会根据捐款人支付的手续费来挑选交易。大多数捐款会在一小时内确认;当网络拥堵时,手续费较低的交易可能需要更长时间。{{appName}} 会自动重新检查确认状态,所以一旦有区块打包,**待确认**标签就会自行消失。",
"款项已经在路上——一旦确认,行动者就会看到这笔捐款计入活动总额。无论是行动者还是 {{appName}} 都无法加快它的速度;只有捐款人的钱包可以通过追加手续费来提速。"
]
},
"censorship-resistance": {
"question": "这里所说的“抗审查”是什么意思?",
"answer": [
+21 -14
View File
@@ -33,12 +33,14 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { CommentsSection } from '@/components/CommentsSection';
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
import { DetailReplySkeleton, DetailStory } from '@/components/DetailStory';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ModerationMenu } from '@/components/moderation';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useEventTranslation } from '@/hooks/useEventTranslation';
@@ -234,24 +236,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
<PledgeStory storyEvent={storyEvent} hasContent={displayAction.description.trim().length > 0} />
<div id="pledge-activity" className="scroll-mt-20">
<div className="mt-6">
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">{t('pledges.detail.submissions')}</h2>
{topLevel.length > 0 ? (
<span className="text-sm text-muted-foreground tabular-nums">
{t('pledges.detail.submissionCount', { count: topLevel.length })}
</span>
) : null}
</div>
<CommentsSection
title={t('pledges.detail.submissions')}
countLabel={topLevel.length > 0 ? t('pledges.detail.submissionCount', { count: topLevel.length }) : undefined}
>
<DetailCommentComposer
event={action.event}
placeholder={t('pledges.detail.submissionPlaceholder')}
className="mb-3"
/>
{commentsLoading && replyTree.length === 0 ? (
<div className="space-y-3">
<div>
{Array.from({ length: 3 }).map((_, i) => <DetailReplySkeleton key={i} />)}
</div>
) : replyTree.length > 0 ? (
@@ -270,7 +265,7 @@ function PledgeDetailContent({ action }: { action: Action }) {
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
className="block w-full px-6 py-10 text-center hover:bg-foreground/5 transition-colors"
>
<p className="text-base font-medium text-foreground">{t('pledges.detail.noSubmissionsTitle')}</p>
<p className="mt-1 text-sm text-muted-foreground">
@@ -278,7 +273,7 @@ function PledgeDetailContent({ action }: { action: Action }) {
</p>
</button>
)}
</div>
</CommentsSection>
</div>
</div>
@@ -396,6 +391,18 @@ function PledgeHero({
<ChevronLeft className="size-5 rtl:rotate-180" />
<span className="text-sm font-medium hidden sm:inline">{t('pledges.detail.back')}</span>
</button>
{/* Moderator-only kebab. Returns null for non-moderators so
non-mod viewers don't subscribe to the moderation query.
Matches the dark hero styling — translucent black pill so
it reads against the photo at the same weight as the back
button on the left. */}
<ModerationMenu
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
className="size-10 rounded-full bg-black/30 text-white backdrop-blur-md hover:bg-black/45 hover:text-white focus-visible:ring-2 focus-visible:ring-white/80"
/>
</div>
</div>
+373 -243
View File
@@ -1,46 +1,46 @@
import { useEffect, useState, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation, Trans } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { nip19 } from 'nostr-tools';
import { parseAction, useActions, type Action } from '@/hooks/useActions';
import { useAppContext } from '@/hooks/useAppContext';
import { useAuthor } from '@/hooks/useAuthor';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useDebounce } from '@/hooks/useDebounce';
import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { useEventTranslation } from '@/hooks/useEventTranslation';
import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries';
import { getDisplayName } from '@/lib/genUserName';
import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import {
ModerationMenuItems,
ModerationOverlay,
ModeratorCollapsibleSection,
} from '@/components/moderation';
import { PledgeCard } from '@/components/PledgeCard';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
} from '@/components/ui/command';
import {
Popover, PopoverContent, PopoverTrigger,
} from '@/components/ui/popover';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
CalendarClock, Clock, HandHeart, MapPin, Plus, ChevronRight, Loader2,
Link as LinkIcon, Check, MoreHorizontal, Trash2, ListFilter,
Calendar, DollarSign, Globe, Megaphone,
HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2,
Link as LinkIcon, Check, MoreHorizontal, Trash2,
Megaphone, Hourglass, EyeOff,
} from 'lucide-react';
// ─────────────────────────────────────────────────────────────────────────────
@@ -62,9 +62,10 @@ function ActionSkeleton() {
);
}
function ActionShareMenu({ action }: { action: Action }) {
function ActionShareMenu({ action, displayTitle }: { action: Action; displayTitle: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: moderators } = useCampaignModerators();
const { mutateAsync: createEvent } = useNostrPublish();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
@@ -73,6 +74,12 @@ function ActionShareMenu({ action }: { action: Action }) {
const [isDeleting, setIsDeleting] = useState(false);
const isOwner = user?.pubkey === action.pubkey;
// Moderator gate is identical to the one in `ModerationMenuItems`,
// duplicated here so we can decide whether to render the trailing
// separator that introduces the moderator section. `ModerationMenuItems`
// returns `null` for non-mods, so without this check we'd render an
// orphaned separator at the bottom of the dropdown.
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
const naddr = nip19.naddrEncode({
kind: 36639,
@@ -150,7 +157,8 @@ function ActionShareMenu({ action }: { action: Action }) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
aria-label={t('pledges.card.actionsAriaLabel')}
className="h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -177,120 +185,27 @@ function ActionShareMenu({ action }: { action: Action }) {
)}
{t('pledges.card.copyLink')}
</DropdownMenuItem>
{/* Moderator actions appear under a separator when the viewer
is a Team Soapbox moderator. `ModerationMenuItems` returns
null for non-mods, so we gate the trailing separator on the
same `isMod` check to avoid an orphan separator at the
bottom of non-mod dropdowns. */}
{isMod && <DropdownMenuSeparator />}
<ModerationMenuItems
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={displayTitle}
surface="pledge"
axes={['hide', 'featured']}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired?: boolean; btcPrice: number | undefined }) {
const { t } = useTranslation();
const { translatedEvent, translateAction } = useEventTranslation(action.event, {
iconOnly: true,
buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10',
});
const displayAction = parseAction(translatedEvent) ?? action;
const author = useAuthor(action.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, action.pubkey);
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
// Always show a cover — fall back to the default if the author didn't set
// one, or the URL failed to validate / load.
const coverImage = (displayAction.image && !imageLoadFailed)
? displayAction.image
: DEFAULT_COVER_IMAGE;
const deadline = displayAction.deadline ? formatCompactPledgeDeadline(displayAction.deadline) : null;
const countryLabel = displayAction.countryCode ? getGeoDisplayName(displayAction.countryCode) : undefined;
return (
<RouterLink
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
<div className="absolute top-3 right-3 flex items-center gap-2" onClick={(e) => e.preventDefault()}>
{isExpired && (
<Badge variant="secondary" className="backdrop-blur bg-background/85 border-border/40 text-muted-foreground">
{t('pledges.card.ended')}
</Badge>
)}
<ActionShareMenu action={action} />
</div>
</div>
<div className="flex flex-col gap-3 p-5 flex-1">
<div className="space-y-2">
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
{displayAction.title}
</h3>
{displayAction.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">
{displayAction.description}
</p>
)}
</div>
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(action.bounty, btcPrice)}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn('inline-flex items-center gap-1.5', deadline.isPast && 'text-destructive')}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div className="truncate">
<Trans
i18nKey="pledges.card.byAuthor"
values={{ name: displayName }}
components={{ 0: <span className="font-medium text-foreground" /> }}
/>
</div>
{translateAction}
</div>
</div>
</Card>
</RouterLink>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Page
// ─────────────────────────────────────────────────────────────────────────────
type SortOption = 'recent' | 'bounty' | 'deadline';
export default function ActionsPage() {
const { t } = useTranslation();
const { config } = useAppContext();
@@ -299,14 +214,117 @@ export default function ActionsPage() {
const navigate = useNavigate();
const [selectedCountry, setSelectedCountry] = useState<string | undefined>(undefined);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [headerCountryPickerOpen, setHeaderCountryPickerOpen] = useState(false);
// On-page NIP-50 search + sort + show-hidden toolbar state.
//
// Default sort, empty query → curated active / upcoming / past
// sections below.
// Default sort, with query → relay search for kind 36639, results
// post-filtered against title/content client-side.
// Top / New → always active. Top sends `sort:top`;
// New sends a raw chronological feed of the kind.
//
// The country filter is threaded through to the search as a NIP-73
// `#i` tag filter (`iso3166:XX` + legacy `geo:XX`). Picking a country
// with an empty query still activates the search view — narrowing a
// kind by external identifier produces a useful filtered grid even
// without a typed term.
const [searchInput, setSearchInput] = useState('');
const [sortMode, setSortMode] = useState<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const iTags = useMemo<string[] | undefined>(() => {
if (!selectedCountry) return undefined;
const code = selectedCountry.toUpperCase();
return [`iso3166:${code}`, `geo:${code}`];
}, [selectedCountry]);
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<Action>({
kind: 36639,
query: debouncedSearch,
sort: sortMode,
parse: parseAction,
iTags,
// Pledge titles live in a `title` tag, not `content`. Most NIP-50
// implementations only match content; widen the net client-side.
getKeywordHaystack: (event) => {
const title = event.tags.find(([n]) => n === 'title')?.[1] ?? '';
return [title, event.content];
},
});
// Pledges now ride the same `agora.moderation` namespace as campaigns
// and organizations (two-axis: hide + featured). Filter hidden pledges
// out of search results unless the moderator opts in via the
// Show-hidden switch. Computed here so the search hook stays
// kind-agnostic.
const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration();
const { searchHits, searchHiddenCount } = useMemo(() => {
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
let hidden = 0;
const visible: Action[] = [];
for (const a of searchHitsRaw) {
const coord = `36639:${a.pubkey}:${a.id}`;
if (hiddenCoords.has(coord)) {
hidden += 1;
if (showHidden) visible.push(a);
} else {
visible.push(a);
}
}
return { searchHits: visible, searchHiddenCount: hidden };
}, [searchHitsRaw, pledgeModeration, showHidden]);
const { data: actions, isLoading: actionsLoading } = useActions({
countryCode: selectedCountry,
limit: 300,
});
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
// the pledge moderation namespace rides the same signer set as the
// campaign and group surfaces. Drives the Pending and Hidden review
// sections at the bottom of the page.
const { data: moderators } = useCampaignModerators();
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
// For moderators we pull the full (unfiltered-by-country) pledge
// stream so the review queue isn't blinkered to whatever country the
// viewer happens to have selected. Same `agora-action` t-tag filter
// as the public list, just wider. Skipped entirely for non-mods so we
// don't pay the round-trip cost for everyone.
const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({
limit: 300,
enabled: isMod,
});
// Pending (mod-only): pledges that haven't been featured or hidden.
// Mirrors the campaign/group "needs review" semantics — pledges have
// a two-axis model, so "pending" means "no curation decision yet".
// Gated on `pledgeModerationReady` so a cold load doesn't briefly
// dump every pledge into "Pending" before label folding completes.
const pendingPledges = useMemo<Action[]>(() => {
if (!isMod || !pledgeModerationReady) return [];
return (allPledgesForMods ?? []).filter((p) => {
const coord = `36639:${p.pubkey}:${p.id}`;
return !pledgeModeration.featuredCoords.has(coord)
&& !pledgeModeration.hiddenCoords.has(coord);
});
}, [isMod, pledgeModerationReady, pledgeModeration, allPledgesForMods]);
// Hidden (mod-only): pledges where the latest hide-axis label is `hidden`.
const hiddenPledges = useMemo<Action[]>(() => {
if (!isMod || !pledgeModerationReady) return [];
return (allPledgesForMods ?? []).filter((p) => {
const coord = `36639:${p.pubkey}:${p.id}`;
return pledgeModeration.hiddenCoords.has(coord);
});
}, [isMod, pledgeModerationReady, pledgeModeration, allPledgesForMods]);
// Route entry points for "Create pledge" all pass the currently-selected
// country via ?country= so the dedicated page can pre-fill it, matching
// the old modal's `countryCode` prop.
@@ -314,22 +332,6 @@ export default function ActionsPage() {
? `/pledges/new?country=${encodeURIComponent(selectedCountry)}`
: '/pledges/new';
const allCountries = useMemo(() => getAllCountries(), []);
const countryOptions = useMemo(() => {
const options: Array<{ value: string; label: string; flag: string }> = [
{ value: 'global', label: t('pledges.list.global'), flag: '🌍' },
];
allCountries.forEach((country) => {
options.push({
value: country.code,
label: country.name,
flag: countryCodeToFlag(country.code),
});
});
return options;
}, [allCountries, t]);
const selectedCountryName = selectedCountry
? getGeoDisplayName(selectedCountry)
: t('pledges.list.global');
@@ -356,23 +358,13 @@ export default function ActionsPage() {
}) ?? [];
const pastUnsorted = actions?.filter((c) => c.deadline && c.deadline <= now) ?? [];
const sortActions = (cs: Action[]) => {
const sorted = [...cs];
const isPastOnlyList = sorted.length > 0 && sorted.every((c) => !!c.deadline && c.deadline <= now);
switch (sortBy) {
case 'recent':
return sorted.sort((a, b) => b.createdAt - a.createdAt);
case 'bounty':
return sorted.sort((a, b) => b.bounty - a.bounty);
case 'deadline':
return sorted.sort((a, b) => {
if (!a.deadline) return 1;
if (!b.deadline) return -1;
// Upcoming/current: soonest deadline first. Past: most recently ended first.
return isPastOnlyList ? b.deadline - a.deadline : a.deadline - b.deadline;
});
}
};
// Within each lifecycle section we sort newest-first by `createdAt`.
// The page used to expose Recent / Bounty / Deadline as a dropdown,
// but those modes were superseded by the shared DiscoverySearchToolbar
// (Default / Top / New) which drives a relay-ranked search view —
// keeping a parallel client-side sort on top of the curated layout
// duplicated the affordance for no real gain.
const sortActions = (cs: Action[]) => [...cs].sort((a, b) => b.createdAt - a.createdAt);
const currentActions = sortActions(currentUnsorted);
const upcomingActions = sortActions(upcomingUnsorted);
@@ -388,7 +380,6 @@ export default function ActionsPage() {
const visiblePast = showAllPast ? pastActions : pastActions.slice(0, DEFAULT_VISIBLE);
const hasCurrent = currentActions.length > 0;
const hasUpcoming = upcomingActions.length > 0;
const isOnlyPastView = !hasCurrent && !hasUpcoming && pastActions.length > 0;
const primarySectionTitle = hasCurrent
? t('pledges.list.sectionActive')
: hasUpcoming
@@ -396,77 +387,23 @@ export default function ActionsPage() {
: pastActions.length > 0
? t('pledges.list.sectionPast')
: t('pledges.list.sectionDefault');
const deadlineSortLabel = isOnlyPastView
? t('pledges.list.sortDeadlinePast')
: t('pledges.list.sortDeadline');
const headerControls = (
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-2 hover:bg-muted/50 rounded-lg" aria-label={t('pledges.list.sortAriaLabel')}>
<ListFilter className="h-5 w-5 text-primary" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">{t('pledges.list.sortBy')}</div>
<DropdownMenuItem onClick={() => setSortBy('recent')} className={sortBy === 'recent' ? 'bg-primary/10' : ''}>
<Clock className="mr-2 h-4 w-4" /><span>{t('pledges.list.sortRecent')}</span>
{sortBy === 'recent' && <Check className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('bounty')} className={sortBy === 'bounty' ? 'bg-primary/10' : ''}>
<DollarSign className="mr-2 h-4 w-4" /><span>{t('pledges.list.sortBounty')}</span>
{sortBy === 'bounty' && <Check className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy('deadline')} className={sortBy === 'deadline' ? 'bg-primary/10' : ''}>
<Calendar className="mr-2 h-4 w-4" /><span>{deadlineSortLabel}</span>
{sortBy === 'deadline' && <Check className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Popover open={headerCountryPickerOpen} onOpenChange={setHeaderCountryPickerOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-2 hover:bg-muted/50 rounded-lg" aria-label={t('pledges.list.filterAriaLabel')}>
{selectedCountry ? (
<span className="text-2xl">{countryCodeToFlag(selectedCountry)}</span>
) : (
<Globe className="h-5 w-5 text-primary" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="end">
<Command>
<CommandInput placeholder={t('pledges.list.countrySearchPlaceholder')} />
<CommandList>
<CommandEmpty>{t('pledges.list.noResults')}</CommandEmpty>
<CommandGroup>
{countryOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
setSelectedCountry(option.value === 'global' ? undefined : option.value);
setHeaderCountryPickerOpen(false);
}}
className="gap-2"
>
<span>{option.flag}</span>
<span className="flex-1">{option.label}</span>
<Check
className={cn(
'h-4 w-4',
(selectedCountry || 'global') === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
searchPlaceholderKey="pledges.list.searchPlaceholder"
searchAriaLabelKey="pledges.list.searchAriaLabel"
showHidden={{
value: showHidden,
onChange: setShowHidden,
count: searchHiddenCount,
}}
country={selectedCountry}
onCountryChange={setSelectedCountry}
/>
);
return (
@@ -477,6 +414,83 @@ export default function ActionsPage() {
onCreateAction={() => navigate(createActionHref)}
/>
{isSearching ? (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14">
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: sortMode === 'top'
? t('common.sortTop')
: t('common.sortNew')}
</h2>
{searchHits && (
<p className="text-sm text-muted-foreground mt-1">
{t('common.searchResultsCount', { count: searchHits.length })}
</p>
)}
</div>
{headerControls}
</div>
{isSearchFetching && !searchHits ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 8 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
) : searchHits && searchHits.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{searchHits.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
isExpired={
action.deadline ? action.deadline <= Date.now() / 1000 : false
}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
) : (
<Card className="border-dashed">
<div className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('pledges.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('pledges.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('pledges.list.emptyTitle')}
</p>
)}
</div>
</Card>
)}
</section>
</div>
) : (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12">
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
@@ -484,7 +498,7 @@ export default function ActionsPage() {
</div>
) : (actions && actions.length > 0) ? (
<div className="space-y-8">
<div className="flex items-end justify-between gap-4">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{primarySectionTitle}</h2>
<p className="text-sm text-muted-foreground mt-1">
@@ -556,7 +570,7 @@ export default function ActionsPage() {
</div>
) : (
<>
<div className="flex items-end justify-between gap-4 mb-6">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('pledges.list.sectionActive')}</h2>
<p className="text-sm text-muted-foreground mt-1">
@@ -579,7 +593,7 @@ export default function ActionsPage() {
</div>
{user && (
<Button onClick={() => navigate(createActionHref)}>
<Plus className="size-4 mr-2" />
<PlusCircle className="size-4 mr-2" />
{t('pledges.list.createPledge')}
</Button>
)}
@@ -588,6 +602,95 @@ export default function ActionsPage() {
</>
)}
</div>
)}
{/* Moderator-only review queues at the bottom of the page
consistent with the campaigns and groups index pages so
reviewers see the same "Pending / Hidden" affordance
everywhere. Pulls the unfiltered-by-country pledge stream so
the queue isn't blinkered by the viewer's country filter. */}
{isMod && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 pb-10 lg:pb-14 space-y-12">
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('pledges.list.needsReview')}
description={t('pledges.list.needsReviewDesc', { appName: config.appName })}
count={pendingPledges.length}
isLoading={allPledgesLoading || !pledgeModerationReady}
emptyText={t('pledges.list.needsReviewEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{pendingPledges.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
isExpired={action.deadline ? action.deadline <= Date.now() / 1000 : false}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
</ModeratorCollapsibleSection>
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('pledges.list.hidden')}
description={t('pledges.list.hiddenDesc')}
count={hiddenPledges.length}
isLoading={allPledgesLoading || !pledgeModerationReady}
emptyText={t('pledges.list.hiddenEmpty')}
skeleton={
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => <ActionSkeleton key={i} />)}
</div>
}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{hiddenPledges.map((action) => (
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
isExpired={action.deadline ? action.deadline <= Date.now() / 1000 : false}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
</ModeratorCollapsibleSection>
</div>
)}
</main>
);
}
@@ -685,15 +788,15 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
only carries a single fact the current pledge count
so it stays calm and the headline does the heavy lifting. */}
<div
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
className="relative w-full max-w-md mx-auto rounded-full bg-black/30 backdrop-blur-xl backdrop-saturate-150 border border-white/20 px-5 py-3 shadow-lg shadow-amber-500/10"
aria-live="polite"
>
<div className="flex items-center justify-center gap-3">
<Megaphone className="size-5 text-primary shrink-0" aria-hidden />
<span className="text-sm sm:text-base font-semibold tracking-tight">
<Megaphone className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{actionCount.toLocaleString()}
</span>
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
<span className="text-xs sm:text-sm text-white/85 line-clamp-1 drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{t('pledges.list.openCount', { count: actionCount })}
</span>
</div>
@@ -717,7 +820,7 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
)}
aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')}
>
<Plus className="mr-2" />
<PlusCircle className="mr-2" />
{t('pledges.list.createPledge')}
</Button>
</div>
@@ -736,21 +839,48 @@ function ActionSection({
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{items.map((action) => (
<ActionCard
<PledgeCard
key={`${action.pubkey}:${action.id}`}
action={action}
isExpired={isExpired}
btcPrice={btcPrice}
showAuthor
showTranslate
topRight={
<>
<ModerationOverlay
coord={`36639:${action.pubkey}:${action.id}`}
entityTitle={action.title}
surface="pledge"
axes={['hide', 'featured']}
showMenu={false}
className="flex items-center"
/>
<ActionShareMenu action={action} displayTitle={action.title} />
</>
}
/>
))}
</div>
{total > visible && (
<div className="flex justify-center pt-2">
<Button variant="outline" onClick={onToggle} className="gap-2">
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={onToggle}
className="rounded-full text-sm"
aria-expanded={showAll}
>
{showAll ? (
<>{t('pledges.list.showLess')} <ChevronRight className="h-4 w-4 rotate-90" /></>
<>
<ChevronUp className="size-4 mr-1.5" />
{t('pledges.list.showLess')}
</>
) : (
<>{t('pledges.list.showMore', { count: total - visible })} <ChevronRight className="h-4 w-4 -rotate-90" /></>
<>
<ChevronDown className="size-4 mr-1.5" />
{t('pledges.list.showMore', { count: total - visible })}
</>
)}
</Button>
</div>
+185 -109
View File
@@ -2,31 +2,41 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useTranslation } from 'react-i18next';
import { Clock, EyeOff, HandHeart, PlusCircle, Search, TrendingUp, X } from 'lucide-react';
import { HandHeart, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useDebounce } from '@/hooks/useDebounce';
import { useAppContext } from '@/hooks/useAppContext';
import { useDebounce } from '@/hooks/useDebounce';
import { HOPE_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import type { Nip50Sort } from '@/hooks/useNip50Search';
import type { ParsedCampaign } from '@/lib/campaign';
const SORT_OPTIONS: { value: CampaignSort; labelKey: string; icon: typeof TrendingUp }[] = [
{ value: 'top', labelKey: 'campaigns.all.sortTop', icon: TrendingUp },
{ value: 'none', labelKey: 'campaigns.all.sortNew', icon: Clock },
];
/** Type-guard for the `?sort=` URL param. Default is `top` (most-zapped). */
function parseSort(value: string | null): CampaignSort {
return value === 'none' ? 'none' : 'top';
}
/**
* Map between the shared toolbar's sort vocabulary (`default` / `top` /
* `new`) and the `useAllCampaigns` hook's vocabulary (`top` / `none`).
*
* AllCampaignsPage doesn't have a curated/default layout — it's the
* "show me everything" page so the toolbar's 'default' option falls
* through to 'top' here, the page's canonical ranked view. The legacy
* `none` value is preserved on the URL so existing share links keep
* working.
*/
const toToolbarSort = (s: CampaignSort): Nip50Sort => (s === 'none' ? 'new' : 'top');
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'new' ? 'none' : 'top');
/**
* Lists every campaign found on relays. Two sort modes:
*
@@ -37,25 +47,21 @@ function parseSort(value: string | null): CampaignSort {
* summary, story, location, and category tags client-side.
*
* Hidden campaigns are excluded by default flip the "Show hidden"
* toggle to include them. The toggle filters client-side after the
* campaign list resolves.
* toggle (inside the toolbar's filter popover) to include them.
*
* URL state: `?sort=none&q=<search>`. Default values are stripped so the
* canonical URL stays clean. Useful for sharing search results.
*/
export function AllCampaignsPage() {
const { t } = useTranslation();
// `noMaxWidth: true` drops MainLayout's default `sidebar:max-w-[600px]`
// cap so the campaign grid can spread to 3-4 columns on desktop, the
// same width budget the Pledge index gets. The inner `max-w-7xl`
// wrapper still keeps the content from sprawling on ultrawide
// monitors.
const { config } = useAppContext();
// URL state — sort and query live in the URL so results are shareable.
// URL state — sort, query, and country live in the URL so results are
// shareable.
const [searchParams, setSearchParams] = useSearchParams();
const sort = parseSort(searchParams.get('sort'));
const urlQuery = searchParams.get('q') ?? '';
const urlCountry = searchParams.get('country') ?? undefined;
// Search input is local-state so typing is responsive; we debounce to
// the URL + the query.
@@ -86,16 +92,27 @@ export function AllCampaignsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlQuery]);
const setSort = (value: CampaignSort) => {
const setSortFromToolbar = (value: Nip50Sort) => {
const next = new URLSearchParams(searchParams);
if (value === 'none') next.set('sort', 'none');
const queryValue = toQuerySort(value);
if (queryValue === 'none') next.set('sort', 'none');
else next.delete('sort');
setSearchParams(next, { replace: true });
};
// The country picker also rides the URL so country-scoped views are
// shareable / linkable.
const setCountry = (next: string | undefined) => {
const params = new URLSearchParams(searchParams);
if (next) params.set('country', next);
else params.delete('country');
setSearchParams(params, { replace: true });
};
const { data: campaigns, isLoading } = useAllCampaigns({
sort,
search: debouncedSearch.trim(),
countryCode: urlCountry,
limit: 200,
});
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
@@ -125,100 +142,50 @@ export function AllCampaignsPage() {
const showSkeleton = isLoading || !moderationReady;
const activeQuery = debouncedSearch.trim();
const totalCampaigns = campaigns?.length ?? 0;
return (
<main className="min-h-screen pb-16">
<AllCampaignsHero campaignCount={totalCampaigns} />
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-8">
<header className="space-y-3">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight">{t('campaigns.all.title')}</h1>
</header>
{/* Toolbar */}
<div className="space-y-4 rounded-lg border border-border/70 bg-card px-4 py-4">
{/* Search input */}
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
inputMode="search"
autoComplete="off"
aria-label={t('campaigns.all.searchAriaLabel')}
placeholder={t('campaigns.all.searchPlaceholder')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9 pr-9"
/>
{searchInput && (
<button
type="button"
aria-label={t('campaigns.all.clearSearch')}
onClick={() => setSearchInput('')}
className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<X className="size-4" />
</button>
)}
</div>
{/* Controls row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
{/* Sort pills */}
<div
className="flex gap-1 p-1 rounded-lg bg-secondary/40"
role="radiogroup"
aria-label={t('campaigns.all.sortAriaLabel')}
>
{SORT_OPTIONS.map(({ value, labelKey, icon: Icon }) => (
<button
key={value}
type="button"
role="radio"
aria-checked={sort === value}
onClick={() => setSort(value)}
className={cn(
'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md motion-safe:transition-colors',
sort === value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-3" />
{t(labelKey)}
</button>
))}
</div>
{/* Show-hidden switch */}
<div className="flex items-center gap-2">
<Switch
id="show-hidden"
checked={showHidden}
onCheckedChange={setShowHidden}
/>
<Label
htmlFor="show-hidden"
className="text-sm font-medium cursor-pointer inline-flex items-center gap-1.5"
>
<EyeOff className="size-4 text-muted-foreground" aria-hidden />
{t('campaigns.all.showHidden')}
{hiddenCount > 0 && (
<span className="text-muted-foreground font-normal">({hiddenCount})</span>
)}
</Label>
</div>
</div>
<Button asChild variant="outline" size="sm">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</Button>
{/* Section heading matches the `/pledges` and `/groups` pages
so the discovery surfaces all share the same large-bold
section header pattern. Title switches between Search / Top /
New based on toolbar state; tagline stays constant.
Search input + filter button cluster on the right, paired
with the heading on the left in a single row. */}
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{activeQuery
? t('common.search')
: sort === 'top'
? t('common.sortTop')
: t('common.sortNew')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{activeQuery
? t('common.searchResultsCount', { count: visible.length })
: t('campaigns.all.sectionTagline')}
</p>
</div>
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={toToolbarSort(sort)}
onSortChange={setSortFromToolbar}
sortOptions={['top', 'new']}
searchPlaceholderKey="campaigns.all.searchPlaceholder"
searchAriaLabelKey="campaigns.all.searchAriaLabel"
showHidden={{
value: showHidden,
onChange: setShowHidden,
count: hiddenCount,
}}
country={urlCountry}
onCountryChange={setCountry}
/>
</div>
{/* Grid widens to 3 columns at lg and 4 at xl so desktop users
@@ -282,3 +249,112 @@ export function AllCampaignsPage() {
}
export default AllCampaignsPage;
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
interface AllCampaignsHeroProps {
/** Total campaigns currently loaded — fuels the live stat pill. */
campaignCount: number;
}
/**
* Photo-led hero for the All-Campaigns page. Mirrors the Pledges /
* Communities hero recipe (rotating banner + atmospheric tint + scrims
* + overlay copy + glassy CTA) so the three discovery pages share the
* same visual shape. The campaign home (`/campaigns`) keeps its bespoke
* lightning-map hero as the brand-leading entry point; this surface
* gets the photo-led treatment because it's the actual browseable index.
*/
function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
const { t } = useTranslation();
// Cycle through warm hues on the same cadence as the banner so the
// whole hero feels like one coordinated moment instead of two
// unrelated rotations.
const [hueIndex, setHueIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
}, 9_000);
return () => window.clearInterval(id);
}, []);
const activeHue = HOPE_PALETTE[hueIndex];
return (
<section className="relative overflow-hidden border-b border-border bg-secondary/30">
{/* Rotating photo banner uses the default WLC photo set so this
page matches the Communities hero's photographic vocabulary. */}
<HeroBanner />
{/* Warm atmosphere — campaigns-side hue, same as the Pledges hero. */}
<HeroAtmosphere hue={activeHue} />
{/* Top scrim so the headline stays legible across every photo. */}
<div
className="absolute inset-x-0 top-0 h-64 sm:h-80 pointer-events-none bg-gradient-to-b from-black/70 via-black/40 to-transparent"
aria-hidden="true"
/>
{/* Bottom scrim so the stat pill + CTA stay legible. */}
<div
className="absolute inset-x-0 bottom-0 h-56 sm:h-72 pointer-events-none bg-gradient-to-t from-black/70 via-black/35 to-transparent"
aria-hidden="true"
/>
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center">
<div className="relative space-y-3 max-w-3xl">
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow">
{t('campaigns.all.heroKicker')}
</p>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
{t('campaigns.all.heroHeading')}
<br className="sm:hidden" /> {t('campaigns.all.heroHeadingLine2')}
</h1>
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
{t('campaigns.all.heroBody')}
</p>
</div>
<div className="flex-1 min-h-[100px] sm:min-h-[120px]" aria-hidden="true" />
{/* Live stat pill — campaigns-on-network count. */}
<div
className="relative w-full max-w-md mx-auto rounded-full bg-black/30 backdrop-blur-xl backdrop-saturate-150 border border-white/20 px-5 py-3 shadow-lg shadow-amber-500/10"
aria-live="polite"
>
<div className="flex items-center justify-center gap-3">
<HandHeart className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{campaignCount.toLocaleString()}
</span>
<span className="text-xs sm:text-sm text-white/85 line-clamp-1 drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{t('campaigns.all.campaignsCount', { count: campaignCount })}
</span>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
<Button
asChild
size="lg"
className={cn(
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
'backdrop-blur-xl backdrop-saturate-150',
'border border-white/25 hover:border-white/35',
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
'motion-safe:transition-colors motion-safe:duration-200',
)}
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
{t('campaigns.all.startCampaign')}
</Link>
</Button>
</div>
</div>
</section>
);
}
+97 -109
View File
@@ -17,7 +17,8 @@ import {
Trash2,
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { AuthorByline } from '@/components/AuthorByline';
import { CommentsSection } from '@/components/CommentsSection';
import {
CampaignWalletDonatePanel,
} from '@/components/CampaignWalletDonatePanel';
@@ -39,11 +40,11 @@ import { DetailReplySkeleton, DetailStory } from '@/components/DetailStory';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { PostActionBar } from '@/components/PostActionBar';
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
import { PendingBadge } from '@/components/PendingBadge';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { Progress } from '@/components/ui/progress';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaign } from '@/hooks/useCampaign';
@@ -53,7 +54,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
import { useEventStats } from '@/hooks/useTrending';
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { useEventTranslation } from '@/hooks/useEventTranslation';
@@ -66,7 +66,6 @@ import {
import { satsToUSDWhole } from '@/lib/bitcoin';
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
import { formatNumber } from '@/lib/formatNumber';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { timeAgo } from '@/lib/timeAgo';
import NotFound from './NotFound';
@@ -129,7 +128,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { data: btcPrice } = useBtcPrice();
const author = useAuthor(campaign.pubkey);
const { data: stats, isLoading: statsLoading } = useCampaignDonations(campaign);
const navigate = useNavigate();
const { toast } = useToast();
@@ -246,14 +244,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
}, [feedEventsById, pinnedEvents, pinnedIds]);
const cover = sanitizeUrl(campaign.banner);
const creatorMetadata = author.data?.metadata;
const creatorName =
creatorMetadata?.display_name || creatorMetadata?.name || genUserName(campaign.pubkey);
const creatorProfileUrl = useProfileUrl(campaign.pubkey, creatorMetadata);
const deadline = campaign.deadline ? formatDeadline(campaign.deadline, t) : null;
const countryLabel = getCampaignCountryLabel(campaign);
const raisedSats = stats?.totalSats ?? 0;
const pendingSats = stats?.pendingSats ?? 0;
const confirmedByTxid = stats?.confirmedByTxid;
const isCreator = user?.pubkey === campaign.pubkey;
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
@@ -347,9 +343,11 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<DonateColumn
campaign={campaign}
raisedSats={raisedSats}
pendingSats={pendingSats}
statsLoading={statsLoading}
btcPrice={btcPrice}
donations={donationReceipts}
confirmedByTxid={confirmedByTxid}
deadline={deadline}
onShare={handleShare}
onSeeAll={scrollToActivity}
@@ -364,9 +362,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
<CampaignHero
campaign={displayCampaign}
cover={cover}
creatorName={creatorName}
creatorProfileUrl={creatorProfileUrl}
creatorPicture={sanitizeUrl(creatorMetadata?.picture)}
creatorPubkey={campaign.pubkey}
deadline={deadline}
countryLabel={countryLabel}
isCreator={isCreator}
@@ -473,75 +469,52 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
</div>
)}
<div className="mt-4">
<div className="mb-3 px-1">
<h2 className="text-lg font-semibold tracking-tight">
{t('campaignsDetail.commentsAndDonations')}
</h2>
</div>
<CommentsSection title={t('campaignsDetail.commentsAndDonations')}>
<DetailCommentComposer
event={campaign.event}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })}
/>
{/* Muted surface wraps the composer and comment list.
The composer inside uses `bg-card` (one shade
lighter) so the input area reads as the focused
surface against this muted backdrop. `bg-muted/60`
softens the wrap so contained cards feel less
boxed-in. All internal dividers (top separator
plus the per-note `border-b`) are retinted to
`border-primary/20` so they read as a single
consistent edge color matching the composer's
bottom border. */}
{/* Muted surface wraps the composer and comment list.
The wrap carries the outer L/R/B border so the
rounded corners curve naturally without any 1px
gaps at the join. Per-article `border-b` divides
items. The composer's own border closes the top. */}
<div className="rounded-2xl bg-muted/60 overflow-hidden border-l border-r border-primary/20 [&_article]:border-b-primary/20 [&_article]:bg-background/40">
<DetailCommentComposer
event={campaign.event}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })}
/>
{commentsLoading && statsLoading && replyTree.length === 0 ? (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<DetailReplySkeleton key={i} />
))}
</div>
) : replyTree.length > 0 ? (
<div>
<ThreadedReplyList
roots={replyTree}
hideCommentContext
leafCardClassName="py-4"
renderAuthorBadge={(event) =>
event.pubkey === campaign.pubkey ? <CampaignerBadge /> : null
}
renderItemHeader={(event) => (
<CampaignPinHeader
canManagePins={canManagePins}
isPinned={isPinned(event.id)}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full px-6 py-10 text-center hover:bg-foreground/5 transition-colors"
>
<p className="text-base font-medium text-foreground">
{t('campaignsDetail.noCommentsTitle')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('campaignsDetail.noCommentsHint')}
</p>
</button>
)}
</div>
</div>
{commentsLoading && statsLoading && replyTree.length === 0 ? (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<DetailReplySkeleton key={i} />
))}
</div>
) : replyTree.length > 0 ? (
<div>
<ThreadedReplyList
roots={replyTree}
hideCommentContext
leafCardClassName="py-4"
renderAuthorBadge={(event) =>
event.pubkey === campaign.pubkey ? <CampaignerBadge /> : null
}
renderItemHeader={(event) => (
<CampaignPinHeader
canManagePins={canManagePins}
isPinned={isPinned(event.id)}
pinPending={togglePin.isPending}
onTogglePin={() => handleTogglePin(event)}
/>
)}
/>
</div>
) : (
<button
type="button"
onClick={() => setReplyOpen(true)}
className="block w-full px-6 py-10 text-center hover:bg-foreground/5 transition-colors"
>
<p className="text-base font-medium text-foreground">
{t('campaignsDetail.noCommentsTitle')}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t('campaignsDetail.noCommentsHint')}
</p>
</button>
)}
</CommentsSection>
</div>
</div>
@@ -666,9 +639,7 @@ function CampaignerBadge() {
interface CampaignHeroProps {
campaign: ParsedCampaign;
cover: string | undefined;
creatorName: string;
creatorProfileUrl: string;
creatorPicture: string | undefined;
creatorPubkey: string;
deadline: { label: string; isPast: boolean } | null;
countryLabel: string | undefined;
isCreator: boolean;
@@ -684,9 +655,7 @@ interface CampaignHeroProps {
function CampaignHero({
campaign,
cover,
creatorName,
creatorProfileUrl,
creatorPicture,
creatorPubkey,
deadline,
countryLabel,
isCreator,
@@ -699,7 +668,6 @@ function CampaignHero({
translateAction,
}: CampaignHeroProps) {
const { t } = useTranslation();
const initials = creatorName.slice(0, 2).toUpperCase();
return (
// True full-bleed: no max-width wrapper, no horizontal padding, no
@@ -796,25 +764,9 @@ function CampaignHero({
</p>
)}
<Link
to={creatorProfileUrl}
onClick={(e) => e.stopPropagation()}
className="mt-5 inline-flex items-center gap-2.5 text-sm sm:text-base text-white/90 hover:text-white motion-safe:transition-colors group [text-shadow:none]"
>
<Avatar className="size-8 sm:size-9 ring-2 ring-white/30">
{creatorPicture && <AvatarImage src={creatorPicture} alt="" />}
<AvatarFallback className="text-xs bg-white/15 text-white">
{initials}
</AvatarFallback>
</Avatar>
<span className="[text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">
<Trans
i18nKey="campaignsDetail.byAuthor"
values={{ name: creatorName }}
components={{ 0: <span className="font-semibold underline-offset-4 group-hover:underline" /> }}
/>
</span>
</Link>
<div className="mt-5">
<AuthorByline pubkey={creatorPubkey} variant="hero" />
</div>
{(countryLabel || deadline) && (
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
@@ -885,10 +837,22 @@ function CampaignStory({
interface DonateColumnProps {
campaign: ParsedCampaign;
raisedSats: number;
/**
* Unconfirmed mempool delta in sats. Positive = inbound pending, negative
* = beneficiary spending. Displayed as a pending badge under the raised
* total when non-zero.
*/
pendingSats: number;
statsLoading: boolean;
btcPrice: number | undefined;
/** Aggregated kind 8333 donation events, newest first. */
donations: NostrEvent[];
/**
* `txid → confirmed` lookup from the verified receipts. Undefined while
* verification is still in flight. Donor rows whose txid maps to `false`
* render a pending badge in place of the relative timestamp.
*/
confirmedByTxid: Map<string, boolean> | undefined;
deadline: { label: string; isPast: boolean } | null;
onShare: () => void;
/** Scroll the inline activity list into view (donations + comments). */
@@ -898,9 +862,11 @@ interface DonateColumnProps {
function DonateColumn({
campaign,
raisedSats,
pendingSats,
statsLoading,
btcPrice,
donations,
confirmedByTxid,
deadline,
onShare,
onSeeAll,
@@ -955,6 +921,12 @@ function DonateColumn({
{t('campaignsDetail.donationCount', { count: donations.length })}
</div>
) : null}
{pendingSats !== 0 && (
<PendingBadge
amountLabel={formatSatsFull(Math.abs(pendingSats), btcPrice)}
className="flex"
/>
)}
</div>
{campaign.goalUsd && raisedUsd(raisedSats, btcPrice) !== undefined && (
<Progress
@@ -1003,7 +975,11 @@ function DonateColumn({
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('campaignsDetail.recentDonations')}
</div>
<DonorPreviewList donations={donations} btcPrice={btcPrice} />
<DonorPreviewList
donations={donations}
btcPrice={btcPrice}
confirmedByTxid={confirmedByTxid}
/>
<button
type="button"
onClick={onSeeAll}
@@ -1025,13 +1001,17 @@ function raisedUsd(sats: number, btcPrice: number | undefined): number | undefin
}
/** Compact donor list: monogram, amount, relative time. Shows up to the
* first 5 entries; the parent surfaces the rest via "See all". */
* first 5 entries; the parent surfaces the rest via "See all". Rows whose
* underlying Bitcoin tx is still in the mempool render a pending badge in
* place of the relative timestamp mirrors the wallet's tx list. */
function DonorPreviewList({
donations,
btcPrice,
confirmedByTxid,
}: {
donations: NostrEvent[];
btcPrice: number | undefined;
confirmedByTxid: Map<string, boolean> | undefined;
}) {
const preview = donations.slice(0, 5);
return (
@@ -1039,6 +1019,14 @@ function DonorPreviewList({
{preview.map((ev) => {
const amountTag = ev.tags.find(([n]) => n === 'amount')?.[1];
const sats = amountTag ? Number(amountTag) : 0;
const txid = ev.tags
.find(([n]) => n === 'i')?.[1]
?.replace(/^bitcoin:tx:/, '');
const confirmed = txid ? confirmedByTxid?.get(txid) : undefined;
// `false` means the verifier confirmed the tx is unconfirmed.
// `undefined` means we haven't verified yet (or txid is missing) —
// don't show pending in that case to avoid flashing on load.
const isPending = confirmed === false;
return (
<li key={ev.id} className="flex items-center gap-3 text-sm">
<div className="size-8 shrink-0 rounded-full bg-primary/15 text-primary flex items-center justify-center">
@@ -1049,7 +1037,7 @@ function DonorPreviewList({
{formatSatsFull(sats, btcPrice)}
</div>
<div className="text-xs text-muted-foreground">
{timeAgo(ev.created_at)}
{isPending ? <PendingBadge /> : timeAgo(ev.created_at)}
</div>
</div>
</li>
+22 -86
View File
@@ -1,25 +1,20 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Trans, useTranslation } from 'react-i18next';
import { ArrowRight, ChevronDown, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { cn } from '@/lib/utils';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { type ParsedCampaign } from '@/lib/campaign';
import type { ParsedCampaign } from '@/lib/campaign';
/** Cap on how many featured campaigns we render in the home-page row. */
const MAX_FEATURED = 4;
@@ -319,28 +314,40 @@ export function CampaignsPage() {
{/* Moderator-only: campaigns awaiting an approval decision. */}
{isMod && (
<ModeratorSection
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('campaigns.home.pending')}
description={t('campaigns.home.pendingDesc')}
count={pendingCampaigns.length}
campaigns={pendingCampaigns}
isLoading={allLoading}
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>
)}
{/* Moderator-only: campaigns currently hidden. */}
{isMod && (
<ModeratorSection
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('campaigns.home.hidden')}
description={t('campaigns.home.hiddenDesc')}
count={hiddenCampaigns.length}
campaigns={hiddenCampaigns}
isLoading={allLoading}
emptyText={t('campaigns.home.hiddenEmpty')}
/>
skeleton={<CampaignGridSkeleton />}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{hiddenCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
</ModeratorCollapsibleSection>
)}
{/* Non-mod creator: surface their own not-yet-approved campaigns
@@ -369,77 +376,6 @@ export function CampaignsPage() {
);
}
/**
* Collapsible moderator-only section listing campaigns in a particular
* moderation state (pending / hidden). Defaults to expanded when the list
* is short, collapsed otherwise.
*/
function ModeratorSection({
icon,
title,
description,
count,
campaigns,
isLoading,
emptyText,
}: {
icon: React.ReactNode;
title: string;
description: string;
count: number;
campaigns: ParsedCampaign[];
isLoading: boolean;
emptyText: string;
}) {
const [open, setOpen] = useState(count <= 6);
return (
<Collapsible open={open} onOpenChange={setOpen} asChild>
<section className="space-y-5">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-end justify-between gap-4 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
{title}
<span className="text-base font-medium text-muted-foreground">({count})</span>
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{description}</p>
</div>
<ChevronDown
className={cn(
'size-5 text-muted-foreground motion-safe:transition-transform shrink-0',
open && 'rotate-180',
)}
aria-hidden
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{isLoading && campaigns.length === 0 ? (
<CampaignGridSkeleton />
) : campaigns.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{emptyText}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
)}
</CollapsibleContent>
</section>
</Collapsible>
);
}
/**
* Returns the grid class string for an adaptive featured row.
* Mobile stays 1-column; desktop expands to 2/3/4 columns based on count.
+233 -132
View File
@@ -9,30 +9,28 @@ import { HeroBanner } from '@/components/HeroBanner';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { SectionHeader } from '@/components/discovery/SectionHeader';
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
import { ModeratorCollapsibleSection } from '@/components/moderation';
import { COOL_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDebounce } from '@/hooks/useDebounce';
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search';
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
import { useToast } from '@/hooks/useToast';
import { useUserOrganizations } from '@/hooks/useUserOrganizations';
import { hasAgoraTag } from '@/lib/agoraNoteTags';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
import type { ParsedCommunity } from '@/lib/communityUtils';
import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils';
// ─── Page ──────────────────────────────────────────────────────────────────────
@@ -71,35 +69,190 @@ export function CommunitiesPage() {
navigate('/groups/new');
};
// On-page NIP-50 search + sort + show-hidden toolbar state.
//
// Default sort, empty query → curated "My groups" / "Featured" /
// moderator shelves below.
// Default sort, with query → relay search for kind 34550, results
// post-filtered against name/description/content client-side.
// Top / New → always active. Top sends `sort:top`;
// New sends a raw chronological feed of the kind.
//
// Groups aren't country-scoped on the discovery surface (a community
// is its own scope), so the country picker is intentionally omitted
// from the toolbar here even though Campaigns and Pledges expose it.
const [searchInput, setSearchInput] = useState('');
const [sortMode, setSortMode] = useState<Nip50Sort>('default');
const [showHidden, setShowHidden] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
const trimmedSearch = debouncedSearch.trim();
const {
data: searchHitsRaw,
isFetching: isSearchFetching,
isActive: isSearching,
} = useNip50Search<ParsedCommunity>({
kind: COMMUNITY_DEFINITION_KIND,
query: debouncedSearch,
sort: sortMode,
parse: parseCommunityEvent,
// Group names and descriptions live in tags, not `content`. Relay
// NIP-50 implementations that only match content silently miss
// obvious title hits — widen client-side by also checking these
// tag values.
getKeywordHaystack: (event) => {
const name = event.tags.find(([n]) => n === 'name')?.[1] ?? '';
const description = event.tags.find(([n]) => n === 'description')?.[1] ?? '';
return [name, description, event.content];
},
});
// Lift org moderation to the page so search results can drop hidden
// groups (or include them when the Show-hidden switch is on). The
// ModeratorReviewSections subtree below still calls its own copy of
// the hook — query results are cached, so the second call is free.
const { data: orgModeration } = useOrganizationModeration();
const { searchHits, searchHiddenCount } = useMemo(() => {
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
let hidden = 0;
const visible: ParsedCommunity[] = [];
for (const c of searchHitsRaw) {
if (hiddenCoords.has(c.aTag)) {
hidden += 1;
if (showHidden) visible.push(c);
} else {
visible.push(c);
}
}
return { searchHits: visible, searchHiddenCount: hidden };
}, [searchHitsRaw, orgModeration, showHidden]);
// Search + sort + show-hidden cluster reused on both branches' top
// section row. Factored out so the "My groups" heading (curated
// layout) and the "Search / Top / New" heading (search-active layout)
// both render the same affordance without duplicating the prop list.
const searchToolbar = (
<DiscoverySearchToolbar
query={searchInput}
onQueryChange={setSearchInput}
sort={sortMode}
onSortChange={setSortMode}
searchPlaceholderKey="groups.list.searchPlaceholder"
searchAriaLabelKey="groups.list.searchAriaLabel"
showHidden={{
value: showHidden,
onChange: setShowHidden,
count: searchHiddenCount,
}}
/>
);
return (
<main className="min-h-screen pb-16 sidebar:pb-0">
<CommunitiesHero onCreateCommunity={handleCreateCommunity} />
<div className="max-w-5xl mx-auto space-y-2 sm:space-y-4 pb-8">
<section className="pt-6">
<SectionHeader title={t('groups.list.myGroups')} className="pb-3 sm:px-6" />
<MyCommunitiesShelf
userOrganizations={userOrganizations}
onCreateCommunity={handleCreateCommunity}
/>
</section>
{isSearching ? (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10">
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{trimmedSearch
? t('common.search')
: sortMode === 'top'
? t('common.sortTop')
: t('common.sortNew')}
</h2>
{searchHits && (
<p className="text-sm text-muted-foreground mt-1">
{t('common.searchResultsCount', { count: searchHits.length })}
</p>
)}
</div>
{searchToolbar}
</div>
<section className="pt-4 pb-8">
<SectionHeader
title={t('groups.list.featuredGroups')}
className="pb-3 sm:px-6"
/>
<FeaturedOrganizationsShelf />
</section>
{isSearchFetching && !searchHits ? (
<CommunityGrid>
{Array.from({ length: 8 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : searchHits && searchHits.length > 0 ? (
<CommunityGrid>
{searchHits.map((community) => (
<CommunityMiniCard
key={community.aTag}
community={community}
className="w-full"
/>
))}
</CommunityGrid>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center space-y-2">
{trimmedSearch ? (
<>
<p className="text-base font-medium">
{t('groups.list.noMatch', { query: trimmedSearch })}
</p>
<p className="text-sm text-muted-foreground">
{t('groups.list.noMatchHint')}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('groups.list.noFeaturedBody', { appName: config.appName })}
</p>
)}
</CardContent>
</Card>
)}
</section>
</div>
) : (
<div className="max-w-7xl mx-auto px-4 sm:px-6 space-y-10 sm:space-y-12 pb-8 pt-10 lg:pt-14">
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('groups.list.myGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('groups.list.myGroupsTagline')}
</p>
</div>
{searchToolbar}
</div>
<MyCommunitiesShelf
userOrganizations={userOrganizations}
onCreateCommunity={handleCreateCommunity}
/>
</section>
{/* Moderator-only review sections: "Needs review" and "Hidden".
Organizations have a two-axis moderation model (featured /
hidden no approval gate), so anything not yet labelled
simply lives in the public Featured-or-not space. The Needs
review queue surfaces unlabelled Agora-tagged orgs so
moderators can pick what to feature or hide. */}
{isMod && <ModeratorReviewSections />}
</div>
<section className="space-y-5">
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
{t('groups.list.featuredGroups')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('groups.list.featuredGroupsTagline')}
</p>
</div>
</div>
<FeaturedOrganizationsShelf />
</section>
{/* Moderator-only review sections: "Needs review" and "Hidden".
Organizations have a two-axis moderation model (featured /
hidden no approval gate), so anything not yet labelled
simply lives in the public Featured-or-not space. The Needs
review queue surfaces unlabelled Agora-tagged orgs so
moderators can pick what to feature or hide. */}
{isMod && <ModeratorReviewSections />}
</div>
)}
</main>
);
}
@@ -158,106 +311,56 @@ function ModeratorReviewSections() {
return (
<>
<ModeratorOrgSection
<ModeratorCollapsibleSection
icon={<Hourglass className="size-4" />}
title={t('groups.list.needsReview')}
description={t('groups.list.needsReviewDesc', { appName: config.appName })}
count={needsReviewOrgs.length}
orgs={needsReviewOrgs}
isLoading={sectionsLoading}
emptyText={t('groups.list.needsReviewEmpty')}
/>
<ModeratorOrgSection
size="compact"
triggerPaddingClassName="pb-3"
skeleton={
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
}
>
<CommunityGrid>
{needsReviewOrgs.map((org) => (
<CommunityMiniCard key={org.aTag} community={org} className="w-full" />
))}
</CommunityGrid>
</ModeratorCollapsibleSection>
<ModeratorCollapsibleSection
icon={<EyeOff className="size-4" />}
title={t('groups.list.hidden')}
description={t('groups.list.hiddenDesc')}
count={hiddenOrgs.length}
orgs={hiddenOrgs}
isLoading={sectionsLoading}
emptyText={t('groups.list.hiddenEmpty')}
/>
size="compact"
triggerPaddingClassName="pb-3"
skeleton={
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
}
>
<CommunityGrid>
{hiddenOrgs.map((org) => (
<CommunityMiniCard key={org.aTag} community={org} className="w-full" />
))}
</CommunityGrid>
</ModeratorCollapsibleSection>
</>
);
}
/**
* Collapsible moderator-only section listing organizations in a particular
* moderation state (pending / hidden). Defaults to expanded when the list
* is short ( 6 items), collapsed otherwise same heuristic as the
* campaign version.
*/
function ModeratorOrgSection({
icon,
title,
description,
count,
orgs,
isLoading,
emptyText,
}: {
icon: React.ReactNode;
title: string;
description: string;
count: number;
orgs: ParsedCommunity[];
isLoading: boolean;
emptyText: string;
}) {
const [open, setOpen] = useState(count <= 6);
return (
<Collapsible open={open} onOpenChange={setOpen} asChild>
<section className="pt-4 pb-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-end justify-between gap-4 rounded-lg text-left px-4 sm:px-6 pb-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
<div>
<h2 className="text-xl sm:text-2xl font-bold tracking-tight inline-flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
{title}
<span className="text-sm font-medium text-muted-foreground">({count})</span>
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{description}</p>
</div>
<ChevronDown
className={cn(
'size-5 text-muted-foreground motion-safe:transition-transform shrink-0',
open && 'rotate-180',
)}
aria-hidden
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{isLoading && orgs.length === 0 ? (
<CommunityGrid>
{Array.from({ length: 4 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</CommunityGrid>
) : orgs.length === 0 ? (
<div className="px-4 sm:px-6">
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{emptyText}
</CardContent>
</Card>
</div>
) : (
<CommunityGrid>
{orgs.map((org) => (
<CommunityMiniCard key={org.aTag} community={org} className="w-full" />
))}
</CommunityGrid>
)}
</CollapsibleContent>
</section>
</Collapsible>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Hero
// ═══════════════════════════════════════════════════════════════════════════════
@@ -373,7 +476,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
<div className="flex-1 min-h-[100px] sm:min-h-[120px]" aria-hidden="true" />
<div
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-teal-500/10"
className="relative w-full max-w-md mx-auto rounded-full bg-black/30 backdrop-blur-xl backdrop-saturate-150 border border-white/20 px-5 py-3 shadow-lg shadow-teal-500/10"
aria-live="polite"
>
{currentStat ? (
@@ -381,11 +484,11 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
key={currentStat.id}
className="flex items-center justify-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500"
>
<span className="text-primary shrink-0">{currentStat.icon}</span>
<span className="text-sm sm:text-base font-semibold tracking-tight">
<span className="text-cyan-200 shrink-0 drop-shadow">{currentStat.icon}</span>
<span className="text-sm sm:text-base font-semibold tracking-tight text-white drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{currentStat.value}
</span>
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
<span className="text-xs sm:text-sm text-white/85 line-clamp-1 drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{currentStat.label}
</span>
</div>
@@ -398,7 +501,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
<Skeleton className="h-3 w-32" />
</>
) : (
<span className="text-xs text-muted-foreground">
<span className="text-xs text-white/85 drop-shadow-[0_1px_4px_rgb(0_0_0/0.5)]">
{t('groups.list.connectingRelays')}
</span>
)}
@@ -521,7 +624,7 @@ function MyCommunitiesShelfContent({
))}
</CommunityGrid>
{canExpand && (
<div className="flex justify-center px-4 sm:px-6">
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
@@ -599,17 +702,15 @@ function EmptyShelf({
action: React.ReactNode;
}) {
return (
<div className="px-4 sm:px-6">
<Card className="border-dashed">
<CardContent className="py-10 px-6 text-center space-y-3 flex flex-col items-center">
<div className="p-3 rounded-full bg-primary/10">{icon}</div>
<div className="space-y-1">
<h3 className="text-base font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{body}</p>
</div>
{action}
</CardContent>
</Card>
</div>
<Card className="border-dashed">
<CardContent className="py-10 px-6 text-center space-y-3 flex flex-col items-center">
<div className="p-3 rounded-full bg-primary/10">{icon}</div>
<div className="space-y-1">
<h3 className="text-base font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{body}</p>
</div>
{action}
</CardContent>
</Card>
);
}
+15 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation, Trans } from 'react-i18next';
@@ -18,6 +18,7 @@ import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -214,15 +215,17 @@ export function CreateActionPage() {
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<Megaphone className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('pledges.create.loginGateTitle')}</h2>
<p className="text-muted-foreground">
{t('pledges.create.loginGateBody')}
</p>
<Button asChild>
<Link to="/pledges">{t('pledges.create.backToPledges')}</Link>
</Button>
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Megaphone className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">{t('pledges.create.loginGateTitle')}</h2>
<p className="text-muted-foreground text-sm">
{t('pledges.create.loginGateBody')}
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
</div>
@@ -330,7 +333,7 @@ export function CreateActionPage() {
<Textarea
placeholder={t('pledges.create.descriptionPlaceholder')}
rows={7}
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
@@ -483,7 +486,7 @@ function CountrySelect({
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
+15 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
@@ -19,6 +19,7 @@ import {
import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -543,15 +544,17 @@ export function CreateCampaignPage() {
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<HandHeart className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('campaignsCreate.loginGateTitle')}</h2>
<p className="text-muted-foreground">
{t('campaignsCreate.loginGateBody')}
</p>
<Button asChild>
<Link to="/">{t('campaignsCreate.goHome')}</Link>
</Button>
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<HandHeart className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">{t('campaignsCreate.loginGateTitle')}</h2>
<p className="text-muted-foreground text-sm">
{t('campaignsCreate.loginGateBody')}
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
</div>
@@ -754,7 +757,7 @@ export function CreateCampaignPage() {
onChange={(e) => setStory(e.target.value)}
placeholder={t('campaignsCreate.storyPlaceholder')}
rows={7}
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
</FormSection>
@@ -1094,7 +1097,7 @@ function CountrySelect({
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
+14 -11
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
@@ -18,6 +18,7 @@ import {
import { PersonSearch } from '@/components/PersonSearch';
import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -467,15 +468,17 @@ export function CreateCommunityPage() {
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<Users className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('groups.create.loginGateTitle')}</h2>
<p className="text-muted-foreground">
{t('groups.create.loginGateBody')}
</p>
<Button asChild>
<Link to="/groups">{t('groups.create.backToGroups')}</Link>
</Button>
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">{t('groups.create.loginGateTitle')}</h2>
<p className="text-muted-foreground text-sm">
{t('groups.create.loginGateBody')}
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
</div>
@@ -765,7 +768,7 @@ function CountrySelect({
setOpen(false);
}
}}
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder={t('forms.countrySearchPlaceholder')}
autoComplete="off"
role="combobox"
+14 -11
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -11,6 +11,7 @@ import { CountrySelect } from '@/components/CountrySelect';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { LoginArea } from '@/components/auth/LoginArea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@@ -238,15 +239,17 @@ export function CreateEventPage() {
<main className="min-h-screen pb-16">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<Card>
<CardContent className="py-12 px-8 text-center space-y-4">
<CalendarDays className="size-10 text-muted-foreground/60 mx-auto" />
<h2 className="text-xl font-semibold">{t('calendarEvents.create.loginTitle')}</h2>
<p className="text-muted-foreground">
{t('calendarEvents.create.loginBody')}
</p>
<Button asChild>
<Link to="/events">{t('calendarEvents.create.backToEvents')}</Link>
</Button>
<CardContent className="py-12 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<CalendarDays className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">{t('calendarEvents.create.loginTitle')}</h2>
<p className="text-muted-foreground text-sm">
{t('calendarEvents.create.loginBody')}
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
</div>
@@ -331,7 +334,7 @@ export function CreateEventPage() {
<Textarea
placeholder={t('calendarEvents.create.descriptionPlaceholder')}
rows={7}
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
+2 -23
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Capacitor } from '@capacitor/core';
import { useSeoMeta } from '@unhead/react';
import { useTranslation } from 'react-i18next';
import { Bell, BellOff, AlertTriangle, Heart, Highlighter, Repeat2, Zap, AtSign, MessageSquare, Users, Award, Mail, Radio, MonitorSmartphone } from 'lucide-react';
import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Radio, MonitorSmartphone } from 'lucide-react';
import { Navigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@@ -14,7 +14,7 @@ import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { toast } from '@/hooks/useToast';
type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments' | 'badges' | 'letters' | 'highlights';
type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments';
interface NotificationTypeRow {
key: NotificationPrefKey;
@@ -62,27 +62,6 @@ const NOTIFICATION_TYPES: NotificationTypeRow[] = [
descriptionKey: 'notifSettings.types.comments.description',
icon: <MessageSquare className="size-5" />,
},
{
key: 'badges',
labelKey: 'notifSettings.types.badges.label',
kinds: [8],
descriptionKey: 'notifSettings.types.badges.description',
icon: <Award className="size-5" />,
},
{
key: 'letters',
labelKey: 'notifSettings.types.letters.label',
kinds: [8211],
descriptionKey: 'notifSettings.types.letters.description',
icon: <Mail className="size-5" />,
},
{
key: 'highlights',
labelKey: 'notifSettings.types.highlights.label',
kinds: [9802],
descriptionKey: 'notifSettings.types.highlights.description',
icon: <Highlighter className="size-5" />,
},
];
function KindBadge({ kind }: { kind: number }) {
-3
View File
@@ -155,7 +155,6 @@ function shellTitleForKind(kind?: number): string {
}
import { CommentContext } from "@/components/CommentContext";
import { CommunityContent } from "@/components/CommunityContent";
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
import { EmojiPackContent } from "@/components/EmojiPackContent";
import {
@@ -1869,8 +1868,6 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<FileMetadataContent event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCommunity ? (
<CommunityContent event={displayEvent} />
) : isGitRepo ? (
<div className="mt-3">
<GitRepoCard event={event} />
+1 -2
View File
@@ -384,7 +384,7 @@ export function SearchPage() {
<PageHeader title={t('search.title')} icon={<SearchIcon className="size-5" />} />
<SubHeaderBar>
<TabButton label={t('search.tabs.agora')} active={activeTab === 'agora'} onClick={() => setActiveTab('agora')} />
<TabButton label={t('search.tabs.nostr')} active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label={t('search.filters.langOptions.global')} active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
<TabButton label={t('search.tabs.accounts')} active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
</SubHeaderBar>
@@ -1079,4 +1079,3 @@ function AgoraSearchTab({
return <EmptyState message={t('search.empty.agoraPrompt')} />;
}
+5 -6
View File
@@ -37,6 +37,7 @@ import {
import { HDSendBitcoinDialog } from '@/components/HDSendBitcoinDialog';
import { HDSilentPaymentScanDialog } from '@/components/HDSilentPaymentScanDialog';
import { WalletBackupMnemonicDialog } from '@/components/WalletBackupMnemonic';
import { PendingBadge } from '@/components/PendingBadge';
import { useAppContext } from '@/hooks/useAppContext';
import { useHdWallet } from '@/hooks/useHdWallet';
import { useHdWalletSp } from '@/hooks/useHdWalletSp';
@@ -230,12 +231,10 @@ export function WalletPage() {
</span>
{pendingBalance !== 0 && (
<span className="flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? t('wallet.amountPending', { amount: satsToUSD(Math.abs(pendingBalance), btcPrice) })
: t('wallet.pending')}
</span>
<PendingBadge
amountLabel={btcPrice ? satsToUSD(Math.abs(pendingBalance), btcPrice) : undefined}
className="pt-1 flex"
/>
)}
</button>
)}
+1 -1
View File
@@ -260,7 +260,7 @@ export function WalletRecoveryPage() {
rows={4}
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
className="font-mono text-base md:text-sm"
/>
<Alert variant="default" className="border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/30 dark:bg-amber-950/50 dark:text-amber-100">