Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e433e70cb | |||
| 3525f685bb | |||
| a358a5d95c | |||
| ba2ab78995 | |||
| bc7e3b8547 | |||
| f19562cf64 | |||
| 9244bb2f16 | |||
| 41c3fd62ac | |||
| ca4855718a | |||
| 749c325b91 | |||
| 73df1484b7 | |||
| ad43d540e0 | |||
| ad31e12803 |
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Check,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ModerationMenuItems } from '@/components/moderation';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
/**
|
||||
* Per-card kebab menu for pledges. Surfaces:
|
||||
* • Delete (owner only) — NIP-09 with both `e` and `a` tags so
|
||||
* relays that ignore a-tag-only deletions still drop the event.
|
||||
* • Copy link — naddr1 URL on the current share origin.
|
||||
* • Moderation actions (mods only) — hide / feature, under a
|
||||
* separator that only renders when the viewer is a moderator.
|
||||
*
|
||||
* Lives outside `ActionsPage` so both the page and the reusable
|
||||
* `PledgesDiscoverySection` can pin it to the card's `topRight` slot
|
||||
* without duplicating the logic.
|
||||
*/
|
||||
export 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();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
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,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const actionUrl = `${shareOrigin}/${naddr}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(actionUrl);
|
||||
setCopied(true);
|
||||
toast({ title: t('pledges.card.linkCopied') });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!user || !isOwner) return;
|
||||
|
||||
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
|
||||
// honour a-tag-only deletions for addressable events.
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
content: t('pledges.card.deletedContent'),
|
||||
tags: [
|
||||
['e', action.event.id],
|
||||
['a', getPledgeCoord(action)],
|
||||
],
|
||||
});
|
||||
// Extract any organization `A` tag the pledge was associated with so
|
||||
// the org's activity shelf and community feeds refresh too.
|
||||
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
|
||||
...(orgATag
|
||||
? [
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['organization-activity', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['community-actions', orgATag],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return (
|
||||
root === 'community-activity-feed' &&
|
||||
typeof aTagsKey === 'string' &&
|
||||
aTagsKey.split(',').includes(orgATag)
|
||||
);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
toast({ title: t('pledges.card.deleted') });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pledge:', error);
|
||||
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
{isOwner && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.deletePledge')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{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={getPledgeCoord(action)}
|
||||
entityTitle={displayTitle}
|
||||
surface="pledge"
|
||||
axes={['hide', 'featured']}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -133,7 +133,14 @@ export function DiscoverySearchToolbar({
|
||||
key={value}
|
||||
checked={sort === value}
|
||||
onCheckedChange={(checked) => {
|
||||
// `checked === false` means the user clicked the
|
||||
// currently-active item — return to the curated
|
||||
// `default` view (featured-first) rather than leaving
|
||||
// them stuck on Top/New with no exit affordance now
|
||||
// that `default` is no longer an exposed option in the
|
||||
// dropdown.
|
||||
if (checked) onSortChange(value);
|
||||
else onSortChange('default');
|
||||
}}
|
||||
// The checkbox slot on the left is hidden in favour of an
|
||||
// explicit `Check` on the right (matches the
|
||||
|
||||
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEventTranslation } from '@/hooks/useEventTranslation';
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
@@ -172,3 +173,30 @@ export function PledgeCard({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
|
||||
* 16:9 cover, then title, two lines of body, a progress bar row, and
|
||||
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
|
||||
* grids as the real card so the skeleton row doesn't reflow when data
|
||||
* arrives.
|
||||
*
|
||||
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
|
||||
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
|
||||
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
|
||||
* `ActionsPage` before this consolidation.
|
||||
*/
|
||||
export function PledgeCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="flex-1 p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HandHeart, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
interface CampaignsDiscoverySectionProps {
|
||||
/**
|
||||
* Where this section's filter state lives:
|
||||
*
|
||||
* • `'url'` — flat URL params (`?q=&sort=&country=`). Used by the
|
||||
* dedicated `/campaigns/all` page so search results are
|
||||
* shareable and survive refresh.
|
||||
* • `'local'` — local-only state. Used by `/` where three
|
||||
* discovery sections coexist and can't all own `?q=`.
|
||||
*/
|
||||
filterPersistence: 'url' | 'local';
|
||||
/**
|
||||
* Visible-row cap for the **idle** featured-first view. The active
|
||||
* (search / sort / country) view always shows the full result set,
|
||||
* because the user has explicitly asked to browse. Defaults to
|
||||
* unlimited (`undefined`).
|
||||
*/
|
||||
idleLimit?: number;
|
||||
/**
|
||||
* Optional hoisted Show-hidden state. When provided, the toolbar
|
||||
* exposes the mod-only switch and uses this state. The page can
|
||||
* read the same value to drive a separate Hidden collapsible. When
|
||||
* omitted, the switch never appears.
|
||||
*/
|
||||
showHidden?: {
|
||||
value: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
/** Hidden-count badge for the toolbar chip. */
|
||||
count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified campaigns discovery section: section header + toolbar +
|
||||
* idle/active grid.
|
||||
*
|
||||
* The section has two display modes:
|
||||
*
|
||||
* 1. **Idle** (no search, no sort, no country picked) — renders the
|
||||
* moderator-curated featured grid. Falls back to the
|
||||
* chronological grid when nothing is featured yet so the section
|
||||
* is never blank.
|
||||
* 2. **Active** — renders the full ranked / chronological / country-
|
||||
* scoped result set.
|
||||
*
|
||||
* Hidden campaigns are excluded by default. Moderators can flip the
|
||||
* Show-hidden switch in the toolbar; the section reads that state
|
||||
* from the `showHidden` prop so a page can persist it across
|
||||
* multiple shelves (e.g. the Hidden collapsible mod section).
|
||||
*
|
||||
* Search is post-filtered client-side across title / summary / story /
|
||||
* location / categories — relay NIP-50 sort-by-top doesn't account
|
||||
* for sats raised, which is the ranking signal users actually want
|
||||
* when searching for campaigns.
|
||||
*/
|
||||
export function CampaignsDiscoverySection({
|
||||
filterPersistence,
|
||||
idleLimit,
|
||||
showHidden: showHiddenProp,
|
||||
}: CampaignsDiscoverySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const filters = useDiscoveryFilters({
|
||||
urlPrefix: filterPersistence === 'url' ? '' : undefined,
|
||||
enableCountry: true,
|
||||
});
|
||||
|
||||
const activeQuery = filters.debouncedSearch.trim();
|
||||
const isActive =
|
||||
activeQuery !== '' || filters.sort !== 'default' || !!filters.country;
|
||||
|
||||
const { data: campaigns, isLoading } = useAllCampaigns({
|
||||
sort: toQuerySort(filters.sort),
|
||||
search: activeQuery,
|
||||
countryCode: filters.country,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
|
||||
|
||||
// Featured slot list — derived from moderation labels. Hidden
|
||||
// coords dropped so a featured-then-hidden campaign disappears
|
||||
// from the row.
|
||||
const featuredCoords = useMemo(() => {
|
||||
if (!moderationReady) return [] as string[];
|
||||
return Array.from(moderation.featuredCoords)
|
||||
.filter((coord) => !moderation.hiddenCoords.has(coord))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(moderation.featuredOrder.get(b) ?? 0) -
|
||||
(moderation.featuredOrder.get(a) ?? 0),
|
||||
);
|
||||
}, [moderation, moderationReady]);
|
||||
|
||||
const { data: featuredCampaigns } = useCampaigns({
|
||||
coordinates: featuredCoords,
|
||||
limit: featuredCoords.length || 1,
|
||||
// Mirrors the pledges section's pattern: don't enable the query
|
||||
// when there are no coords to fetch. `useCampaigns` already
|
||||
// short-circuits internally on an empty `coordinates` array, so
|
||||
// this is purely about not creating a meaningless cache entry.
|
||||
enabled: moderationReady && featuredCoords.length > 0,
|
||||
});
|
||||
|
||||
const showHiddenValue = showHiddenProp?.value ?? false;
|
||||
|
||||
// Visible campaigns in the **active** branch: every campaign
|
||||
// matching the search / sort / country, minus hidden (unless the
|
||||
// moderator opted in). Featured items are intentionally NOT pulled
|
||||
// out — when the user is actively browsing, they want a ranked or
|
||||
// chronological grid, not the curated shelf.
|
||||
const visible = useMemo(() => {
|
||||
const all = campaigns ?? [];
|
||||
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
|
||||
const out: ParsedCampaign[] = [];
|
||||
for (const c of all) {
|
||||
if (hiddenCoords.has(c.aTag)) {
|
||||
if (isMod && showHiddenValue) out.push(c);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [campaigns, isMod, moderation, showHiddenValue]);
|
||||
|
||||
const orderedFeaturedCampaigns = useMemo(() => {
|
||||
if (!featuredCampaigns) return [] as ParsedCampaign[];
|
||||
return [...featuredCampaigns].sort(
|
||||
(a, b) =>
|
||||
(moderation.featuredOrder.get(b.aTag) ?? 0) -
|
||||
(moderation.featuredOrder.get(a.aTag) ?? 0),
|
||||
);
|
||||
}, [featuredCampaigns, moderation]);
|
||||
|
||||
// Idle-mode list: featured first; if nothing is featured, fall back
|
||||
// to the latest chronological grid so the section is never blank
|
||||
// when there's content to show.
|
||||
const idleCampaigns = useMemo<ParsedCampaign[]>(() => {
|
||||
const list =
|
||||
orderedFeaturedCampaigns.length > 0 ? orderedFeaturedCampaigns : visible;
|
||||
return idleLimit ? list.slice(0, idleLimit) : list;
|
||||
}, [orderedFeaturedCampaigns, visible, idleLimit]);
|
||||
|
||||
const showSkeleton = isLoading || !moderationReady;
|
||||
const listForRender = isActive ? visible : idleCampaigns;
|
||||
const hiddenCount = showHiddenProp?.count ?? 0;
|
||||
const hiddenAllOfThem = !isActive && hiddenCount > 0 && !showHiddenValue;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{activeQuery ? t('common.search') : t('campaigns.all.title')}
|
||||
</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={filters.searchInput}
|
||||
onQueryChange={filters.setSearchInput}
|
||||
sort={filters.sort}
|
||||
onSortChange={filters.setSort}
|
||||
sortOptions={['top', 'new']}
|
||||
searchPlaceholderKey="campaigns.all.searchPlaceholder"
|
||||
searchAriaLabelKey="campaigns.all.searchAriaLabel"
|
||||
showHidden={
|
||||
isMod && showHiddenProp
|
||||
? {
|
||||
value: showHiddenProp.value,
|
||||
onChange: showHiddenProp.onChange,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
country={filters.country}
|
||||
onCountryChange={filters.setCountry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
<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) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : listForRender.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center space-y-4">
|
||||
<HandHeart className="size-10 text-muted-foreground mx-auto" />
|
||||
<div className="space-y-1.5">
|
||||
{activeQuery ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t('campaigns.all.noMatch', { query: activeQuery })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.noMatchHint')}
|
||||
</p>
|
||||
</>
|
||||
) : hiddenAllOfThem ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t('campaigns.all.allHidden')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.allHiddenHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t('campaigns.all.empty')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.emptyHint')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
{t('campaigns.all.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{listForRender.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
|
||||
import {
|
||||
CommunityMiniCard,
|
||||
CommunityMiniCardSkeleton,
|
||||
} from '@/components/discovery/CommunityMiniCard';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
|
||||
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
|
||||
import { useNip50Search } from '@/hooks/useNip50Search';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
type ParsedCommunity,
|
||||
} from '@/lib/communityUtils';
|
||||
|
||||
interface GroupsDiscoverySectionProps {
|
||||
/**
|
||||
* Where this section's filter state lives. See
|
||||
* `CampaignsDiscoverySection` for the rationale.
|
||||
*/
|
||||
filterPersistence: 'url' | 'local';
|
||||
/**
|
||||
* Visible-row cap for the **idle** featured-first view. The active
|
||||
* (search / sort) view always shows the full result set. Defaults
|
||||
* to unlimited (`undefined`).
|
||||
*/
|
||||
idleLimit?: number;
|
||||
/**
|
||||
* Optional hoisted Show-hidden state. When provided, the toolbar
|
||||
* exposes the mod-only switch and uses this state. The page can
|
||||
* read the same value to drive a separate Hidden collapsible.
|
||||
*/
|
||||
showHidden?: {
|
||||
value: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
/** Hidden-count badge for the toolbar chip. */
|
||||
count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified groups discovery section: section header + toolbar +
|
||||
* idle/active grid.
|
||||
*
|
||||
* • **Idle** (default sort, empty query) — renders ONLY
|
||||
* moderator-featured groups. No fallback to a chronological "all
|
||||
* groups" grid: that produced a flash of unrelated communities
|
||||
* while the relay returned every kind-34550 event before the
|
||||
* client-side Agora-tag filter ran. The skeleton is gated on the
|
||||
* featured query itself so the idle view goes
|
||||
* skeleton → curated grid without an intermediate state.
|
||||
*
|
||||
* • **Active** (search / Top / New) — renders the full relay
|
||||
* search result set, post-filtered against name / description /
|
||||
* content client-side because group names live in tags and most
|
||||
* NIP-50 relays only match `content`.
|
||||
*
|
||||
* Groups aren't country-scoped (a community is its own scope), so
|
||||
* the country picker is intentionally omitted from the toolbar even
|
||||
* though Campaigns and Pledges expose it.
|
||||
*/
|
||||
export function GroupsDiscoverySection({
|
||||
filterPersistence,
|
||||
idleLimit,
|
||||
showHidden: showHiddenProp,
|
||||
}: GroupsDiscoverySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const filters = useDiscoveryFilters({
|
||||
urlPrefix: filterPersistence === 'url' ? '' : undefined,
|
||||
enableCountry: false,
|
||||
});
|
||||
|
||||
const trimmedSearch = filters.debouncedSearch.trim();
|
||||
const showHiddenValue = showHiddenProp?.value ?? false;
|
||||
const hiddenCount = showHiddenProp?.count ?? 0;
|
||||
|
||||
const {
|
||||
data: searchHitsRaw,
|
||||
isFetching: isSearchFetching,
|
||||
isActive: isSearching,
|
||||
} = useNip50Search<ParsedCommunity>({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
query: filters.debouncedSearch,
|
||||
sort: filters.sort,
|
||||
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];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: orgModeration, isReady: orgModerationReady } =
|
||||
useOrganizationModeration();
|
||||
|
||||
const searchHits = useMemo(() => {
|
||||
if (!searchHitsRaw) return undefined;
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
const visible: ParsedCommunity[] = [];
|
||||
for (const c of searchHitsRaw) {
|
||||
if (hiddenCoords.has(c.aTag)) {
|
||||
if (showHiddenValue) visible.push(c);
|
||||
} else {
|
||||
visible.push(c);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}, [searchHitsRaw, orgModeration, showHiddenValue]);
|
||||
|
||||
// Featured groups — the curated list moderators publish. This is
|
||||
// the entire idle-mode payload: no chronological fallback, no
|
||||
// client-side tag filter, no "fetch everything and pick the Agora
|
||||
// ones out of it" dance. Hidden coords are dropped (unless a
|
||||
// moderator has flipped Show hidden on).
|
||||
const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
|
||||
useFeaturedOrganizations();
|
||||
|
||||
const featuredGroups = useMemo<ParsedCommunity[]>(() => {
|
||||
if (!featuredOrgs) return [];
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
const list = featuredOrgs
|
||||
.map((entry) => entry.community)
|
||||
.filter((c) => (isMod && showHiddenValue) || !hiddenCoords.has(c.aTag));
|
||||
return idleLimit ? list.slice(0, idleLimit) : list;
|
||||
}, [featuredOrgs, orgModeration, isMod, showHiddenValue, idleLimit]);
|
||||
|
||||
// Idle-render skeleton gate. `useFeaturedOrganizations` is
|
||||
// internally gated on `moderationReady`, so while the moderation
|
||||
// labels are still loading, the hook is *disabled* and reports
|
||||
// `isLoading: false` / `data: undefined`. Treating that as "not
|
||||
// loading" would render the empty state for a moment before the
|
||||
// curated grid pops in; tracking moderation-readiness here keeps
|
||||
// the skeleton on screen until we know what's featured.
|
||||
const idleLoading =
|
||||
!orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
|
||||
|
||||
return (
|
||||
<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') : t('groups.list.allGroups')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isSearching && searchHits
|
||||
? t('common.searchResultsCount', { count: searchHits.length })
|
||||
: t('groups.list.allGroupsTagline')}
|
||||
</p>
|
||||
</div>
|
||||
<DiscoverySearchToolbar
|
||||
query={filters.searchInput}
|
||||
onQueryChange={filters.setSearchInput}
|
||||
sort={filters.sort}
|
||||
onSortChange={filters.setSort}
|
||||
sortOptions={['top', 'new']}
|
||||
searchPlaceholderKey="groups.list.searchPlaceholder"
|
||||
searchAriaLabelKey="groups.list.searchAriaLabel"
|
||||
showHidden={
|
||||
isMod && showHiddenProp
|
||||
? {
|
||||
value: showHiddenProp.value,
|
||||
onChange: showHiddenProp.onChange,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : idleLoading ? (
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
) : featuredGroups.length > 0 ? (
|
||||
<CommunityGrid>
|
||||
{featuredGroups.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">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('groups.list.noFeaturedBody', { appName: config.appName })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ActionShareMenu } from '@/components/ActionShareMenu';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { ModerationOverlay } from '@/components/moderation';
|
||||
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
|
||||
import { parseAction, useActions, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
|
||||
import { useNip50Search } from '@/hooks/useNip50Search';
|
||||
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
|
||||
interface PledgesDiscoverySectionProps {
|
||||
/**
|
||||
* Where this section's filter state lives. See
|
||||
* `CampaignsDiscoverySection` for the rationale.
|
||||
*/
|
||||
filterPersistence: 'url' | 'local';
|
||||
/**
|
||||
* Visible-row cap for the **idle** featured-first view. The active
|
||||
* (search / sort / country) view always shows the full result set.
|
||||
* Defaults to unlimited (`undefined`).
|
||||
*/
|
||||
idleLimit?: number;
|
||||
/**
|
||||
* Optional hoisted Show-hidden state. When provided, the toolbar
|
||||
* exposes the mod-only switch and uses this state. The page can
|
||||
* read the same value to drive a separate Hidden collapsible.
|
||||
*/
|
||||
showHidden?: {
|
||||
value: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
/** Hidden-count badge for the toolbar chip. */
|
||||
count?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified pledges discovery section: section header + toolbar +
|
||||
* idle/active grid.
|
||||
*
|
||||
* • **Idle** (no search / no sort / no country) — renders the
|
||||
* moderator-featured pledges, falling back to chronological
|
||||
* all-pledges when nothing is featured yet.
|
||||
*
|
||||
* • **Active** — renders the full search / sort / country-scoped
|
||||
* result set, post-filtered against title / content client-side.
|
||||
* Picking a country with an empty query still activates the
|
||||
* search view — narrowing kind 36639 by NIP-73 `iso3166:XX` +
|
||||
* legacy `geo:XX` tags produces a useful filtered grid even
|
||||
* without a typed term.
|
||||
*/
|
||||
export function PledgesDiscoverySection({
|
||||
filterPersistence,
|
||||
idleLimit,
|
||||
showHidden: showHiddenProp,
|
||||
}: PledgesDiscoverySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
const filters = useDiscoveryFilters({
|
||||
urlPrefix: filterPersistence === 'url' ? '' : undefined,
|
||||
enableCountry: true,
|
||||
});
|
||||
|
||||
const trimmedSearch = filters.debouncedSearch.trim();
|
||||
const showHiddenValue = showHiddenProp?.value ?? false;
|
||||
const canShowHidden = isMod && showHiddenValue;
|
||||
const hiddenCount = showHiddenProp?.count ?? 0;
|
||||
|
||||
// Country → NIP-73 `#i` tag list. Picking a country with no typed
|
||||
// query still activates the search view; narrowing a kind by
|
||||
// external identifier produces a useful filtered grid even without
|
||||
// a typed term.
|
||||
const iTags = useMemo<string[] | undefined>(() => {
|
||||
if (!filters.country) return undefined;
|
||||
const code = filters.country.toUpperCase();
|
||||
return [`iso3166:${code}`, `geo:${code}`];
|
||||
}, [filters.country]);
|
||||
|
||||
const {
|
||||
data: searchHitsRaw,
|
||||
isFetching: isSearchFetching,
|
||||
isActive: isSearching,
|
||||
} = useNip50Search<Action>({
|
||||
kind: 36639,
|
||||
query: filters.debouncedSearch,
|
||||
sort: filters.sort,
|
||||
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];
|
||||
},
|
||||
});
|
||||
|
||||
// Chronological feed that backs the idle grid (and the
|
||||
// featured-then-chronological fallback). Gated on `!isSearching`
|
||||
// because the search branch renders `searchHits` instead and never
|
||||
// reads `rawActions` / `actions` — leaving this query enabled during
|
||||
// search burns a 300-event relay round-trip on every keystroke that
|
||||
// activates the search view. The idle branch is the only consumer,
|
||||
// and the idle branch only renders when `!isSearching`, so this
|
||||
// gate strictly removes wasted work.
|
||||
const { data: rawActions, isLoading: actionsLoading } = useActions({
|
||||
countryCode: filters.country,
|
||||
limit: 300,
|
||||
enabled: !isSearching,
|
||||
});
|
||||
|
||||
const { data: pledgeModeration, isReady: pledgeModerationReady } =
|
||||
usePledgeModeration();
|
||||
|
||||
const featuredPledgeCoords = useMemo(() => {
|
||||
if (!pledgeModerationReady) return [] as string[];
|
||||
return Array.from(pledgeModeration.featuredCoords)
|
||||
.filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(pledgeModeration.featuredOrder.get(b) ?? 0) -
|
||||
(pledgeModeration.featuredOrder.get(a) ?? 0),
|
||||
);
|
||||
}, [pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const { data: featuredPledges } = useActions({
|
||||
coordinates: featuredPledgeCoords,
|
||||
limit: featuredPledgeCoords.length || 1,
|
||||
enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
|
||||
});
|
||||
|
||||
const orderedFeaturedPledges = useMemo(() => {
|
||||
if (!featuredPledges || !pledgeModerationReady) return [] as Action[];
|
||||
const order = pledgeModeration.featuredOrder;
|
||||
return [...featuredPledges].sort((a, b) => {
|
||||
const aCoord = getPledgeCoord(a);
|
||||
const bCoord = getPledgeCoord(b);
|
||||
return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
|
||||
});
|
||||
}, [featuredPledges, pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const featuredPledgeCoordSet = useMemo(
|
||||
() => new Set(featuredPledgeCoords),
|
||||
[featuredPledgeCoords],
|
||||
);
|
||||
|
||||
const searchHits = useMemo(() => {
|
||||
if (!searchHitsRaw) return undefined;
|
||||
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
|
||||
const visible: Action[] = [];
|
||||
for (const a of searchHitsRaw) {
|
||||
const coord = getPledgeCoord(a);
|
||||
if (hiddenCoords.has(coord)) {
|
||||
if (canShowHidden) visible.push(a);
|
||||
} else {
|
||||
visible.push(a);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}, [searchHitsRaw, pledgeModeration, canShowHidden]);
|
||||
|
||||
// Chronological pledge list filtered by country, with
|
||||
// moderator-hidden items dropped (unless `showHidden` is on).
|
||||
// Featured pledges are NOT excluded here — the idle render path
|
||||
// pulls them separately, and the active render path shows the
|
||||
// full list.
|
||||
const actions = useMemo(() => {
|
||||
if (!rawActions) return undefined;
|
||||
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
|
||||
const visible: Action[] = [];
|
||||
for (const action of rawActions) {
|
||||
const coord = getPledgeCoord(action);
|
||||
if (hiddenCoords.has(coord)) {
|
||||
if (canShowHidden) visible.push(action);
|
||||
} else {
|
||||
visible.push(action);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}, [rawActions, pledgeModeration, canShowHidden]);
|
||||
|
||||
const isLoading = actionsLoading || !pledgeModerationReady;
|
||||
const isSearchLoading = isSearchFetching || !pledgeModerationReady;
|
||||
|
||||
// Idle list: featured first; if none are featured, fall back to
|
||||
// the chronological all-pledges grid so the section is never blank.
|
||||
const idlePledges = useMemo<Action[]>(() => {
|
||||
const list =
|
||||
orderedFeaturedPledges.length > 0
|
||||
? orderedFeaturedPledges
|
||||
: (actions ?? []).filter(
|
||||
(action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
|
||||
);
|
||||
return idleLimit ? list.slice(0, idleLimit) : list;
|
||||
}, [orderedFeaturedPledges, actions, featuredPledgeCoordSet, idleLimit]);
|
||||
|
||||
const renderPledge = (action: Action) => (
|
||||
<PledgeCard
|
||||
key={`${action.pubkey}:${action.id}`}
|
||||
action={action}
|
||||
btcPrice={btcPrice}
|
||||
showAuthor
|
||||
showTranslate
|
||||
topRight={
|
||||
<>
|
||||
<ModerationOverlay
|
||||
coord={getPledgeCoord(action)}
|
||||
entityTitle={action.title}
|
||||
surface="pledge"
|
||||
axes={['hide', 'featured']}
|
||||
showMenu={false}
|
||||
className="flex items-center"
|
||||
/>
|
||||
<ActionShareMenu action={action} displayTitle={action.title} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<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') : t('pledges.list.allPledges')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isSearching && searchHits
|
||||
? t('common.searchResultsCount', { count: searchHits.length })
|
||||
: t('pledges.list.allPledgesTagline')}
|
||||
</p>
|
||||
</div>
|
||||
<DiscoverySearchToolbar
|
||||
query={filters.searchInput}
|
||||
onQueryChange={filters.setSearchInput}
|
||||
sort={filters.sort}
|
||||
onSortChange={filters.setSort}
|
||||
sortOptions={['top', 'new']}
|
||||
searchPlaceholderKey="pledges.list.searchPlaceholder"
|
||||
searchAriaLabelKey="pledges.list.searchAriaLabel"
|
||||
showHidden={
|
||||
isMod && showHiddenProp
|
||||
? {
|
||||
value: showHiddenProp.value,
|
||||
onChange: showHiddenProp.onChange,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
country={filters.country}
|
||||
onCountryChange={filters.setCountry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<>
|
||||
{isSearchLoading && !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) => (
|
||||
<PledgeCardSkeleton 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(renderPledge)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<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) => (
|
||||
<PledgeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : idlePledges.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{idlePledges.map(renderPledge)}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('pledges.list.emptyTitle')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,25 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { parseCampaignEvents } from '@/hooks/useCampaigns';
|
||||
import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
|
||||
/** Sort modes for the All Campaigns page. */
|
||||
export type CampaignSort = 'top' | 'none';
|
||||
|
||||
/**
|
||||
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) onto
|
||||
* `useAllCampaigns`'s vocabulary (`top` / `none`). `'new'` and `'default'`
|
||||
* both map to `'none'` (chronological) — discovery sections apply the
|
||||
* "show featured only when idle" framing on top of the chronological
|
||||
* feed, so the underlying query doesn't need to distinguish them.
|
||||
*
|
||||
* Exported so the section component and any page-level consumer using
|
||||
* the same hook stay aligned through one helper instead of two
|
||||
* hand-rolled ternaries.
|
||||
*/
|
||||
export const toQuerySort = (s: Nip50Sort): CampaignSort =>
|
||||
s === 'top' ? 'top' : 'none';
|
||||
|
||||
interface UseAllCampaignsOptions {
|
||||
/** Sort mode. `top` ranks by total sats raised; `none` is chronological. */
|
||||
sort: CampaignSort;
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
interface UseDiscoverCommunitiesOptions {
|
||||
/** Maximum number of communities to fetch. Default: 24. */
|
||||
limit?: number;
|
||||
/**
|
||||
* Gate the underlying query. Useful for callers that only need the
|
||||
* full kind-34550 universe under a moderator role (e.g. the Hidden
|
||||
* section on `/groups`); skipping the fetch for everyone else avoids
|
||||
* a global relay round-trip whose results would only feed
|
||||
* moderator-only UI. Defaults to `true`.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,12 +36,13 @@ interface UseDiscoverCommunitiesOptions {
|
||||
* the card just shows a gradient fallback.
|
||||
*/
|
||||
export function useDiscoverCommunities(options: UseDiscoverCommunitiesOptions = {}) {
|
||||
const { limit = 24 } = options;
|
||||
const { limit = 24, enabled = true } = options;
|
||||
const { nostr } = useNostr();
|
||||
const relay = nostr.relay(DITTO_RELAY);
|
||||
|
||||
return useQuery<ParsedCommunity[]>({
|
||||
queryKey: ['discover-communities', limit],
|
||||
enabled,
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await relay.query(
|
||||
[{ kinds: [COMMUNITY_DEFINITION_KIND], limit }],
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
|
||||
/**
|
||||
* Type-guard for the `?sort=` URL param value used by every discovery
|
||||
* section (Campaigns, Groups, Pledges).
|
||||
*
|
||||
* - `'top'` and `'new'` map to the toolbar's active sort modes.
|
||||
* - Anything else (missing, empty, legacy values) collapses to
|
||||
* `'default'`, the curated featured-first idle state.
|
||||
*
|
||||
* Exported because the dedicated discovery pages (`/campaigns/all`,
|
||||
* `/pledges`) read `?sort=` independently from the section's hook to
|
||||
* thread the value into ancillary derivations (hidden-list cache
|
||||
* lookups, create-X href country prefills). One canonical parser
|
||||
* keeps page-level and section-level reads in lockstep.
|
||||
*/
|
||||
export function parseSort(value: string | null): Nip50Sort {
|
||||
if (value === 'top') return 'top';
|
||||
if (value === 'new') return 'new';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export interface DiscoveryFilters {
|
||||
/**
|
||||
* Live search input value, updated on every keystroke. Bind this to
|
||||
* the toolbar's `<input>` so typing stays responsive.
|
||||
*/
|
||||
searchInput: string;
|
||||
setSearchInput: (next: string) => void;
|
||||
/**
|
||||
* Debounced search value. Use this as the input to relay queries
|
||||
* and as the source for "is this section actively searching?"
|
||||
* checks. URL writes also happen on this value, so the URL doesn't
|
||||
* churn on every keystroke.
|
||||
*/
|
||||
debouncedSearch: string;
|
||||
/** Active sort mode. */
|
||||
sort: Nip50Sort;
|
||||
setSort: (next: Nip50Sort) => void;
|
||||
/** Selected ISO-3166 alpha-2 country code, or `undefined` for global. */
|
||||
country: string | undefined;
|
||||
setCountry: (next: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface UseDiscoveryFiltersOptions {
|
||||
/**
|
||||
* URL-namespace for persisted filters, or `undefined` for local-only
|
||||
* state.
|
||||
*
|
||||
* • `''` — flat URL params (`?q=…&sort=…&country=…`). The dedicated
|
||||
* browse pages (`/campaigns/all`, `/groups`, `/pledges`) want
|
||||
* this so search results are shareable / linkable and survive
|
||||
* refresh.
|
||||
*
|
||||
* • `undefined` — purely local state, no URL writes. The home
|
||||
* page (`/`) hosts all three sections at once. Pushing each
|
||||
* section's filters into the URL there would either collide
|
||||
* (three sections want `?q=`) or pollute the path with six to
|
||||
* nine prefixed params on every keystroke. Keeping state local
|
||||
* means refreshing `/` lands on the curated idle view, which
|
||||
* matches what we want anyway.
|
||||
*
|
||||
* • Any other string — namespaced URL params
|
||||
* (`?fooQ=&fooSort=&fooCountry=`). Reserved for future surfaces
|
||||
* that need multiple coexisting filter sets in the URL.
|
||||
*/
|
||||
urlPrefix?: string;
|
||||
/**
|
||||
* Whether the section exposes a country picker. When `false`, the
|
||||
* country slot stays `undefined` and the `country` URL param is
|
||||
* never read or written even if a stale value sits in the URL.
|
||||
* Defaults to `true`.
|
||||
*/
|
||||
enableCountry?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter state machine shared by every discovery section.
|
||||
*
|
||||
* Owns three pieces of state — search input (debounced), sort mode,
|
||||
* country code — and (optionally) mirrors them to URL params so deep
|
||||
* links and browser back/forward work. Defaults are stripped on write
|
||||
* so the canonical URL stays clean (`/campaigns/all`, not
|
||||
* `/campaigns/all?q=&sort=`).
|
||||
*
|
||||
* Debouncing lives inside this hook (300ms) so consumers don't have
|
||||
* to thread the debounced value back in — that would create a
|
||||
* circular dependency with the URL-sync effect. Consumers should
|
||||
* pass `debouncedSearch` straight to their relay query.
|
||||
*
|
||||
* URL writes use `replace: true` so typing doesn't pile entries onto
|
||||
* the history stack.
|
||||
*/
|
||||
export function useDiscoveryFilters({
|
||||
urlPrefix,
|
||||
enableCountry = true,
|
||||
}: UseDiscoveryFiltersOptions): DiscoveryFilters {
|
||||
const useUrl = urlPrefix !== undefined;
|
||||
// Always call the hook — React's rules — but only read/write through
|
||||
// it when `useUrl` is true.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const qKey = useUrl ? (urlPrefix === '' ? 'q' : `${urlPrefix}Q`) : '';
|
||||
const sortKey = useUrl ? (urlPrefix === '' ? 'sort' : `${urlPrefix}Sort`) : '';
|
||||
const countryKey = useUrl
|
||||
? urlPrefix === ''
|
||||
? 'country'
|
||||
: `${urlPrefix}Country`
|
||||
: '';
|
||||
|
||||
// Seed state from the URL on first render so deep links / refreshes
|
||||
// restore the user's last view, then run the toolbar from local
|
||||
// state and push debounced changes back to the URL.
|
||||
const [searchInput, setSearchInputState] = useState(
|
||||
useUrl ? (searchParams.get(qKey) ?? '') : '',
|
||||
);
|
||||
const [sort, setSortState] = useState<Nip50Sort>(
|
||||
useUrl ? parseSort(searchParams.get(sortKey)) : 'default',
|
||||
);
|
||||
const [country, setCountryState] = useState<string | undefined>(
|
||||
useUrl && enableCountry
|
||||
? (searchParams.get(countryKey) ?? undefined)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const debouncedSearch = useDebounce(searchInput, 300);
|
||||
|
||||
// URL → state. Handles browser back/forward and direct deep-link
|
||||
// navigation while a section is mounted (e.g. clicking an internal
|
||||
// link that updates `?sort=top`). We compare before assigning to
|
||||
// avoid React render loops.
|
||||
useEffect(() => {
|
||||
if (!useUrl) return;
|
||||
const urlQuery = searchParams.get(qKey) ?? '';
|
||||
if (urlQuery !== searchInput && urlQuery !== debouncedSearch) {
|
||||
setSearchInputState(urlQuery);
|
||||
}
|
||||
const urlSort = parseSort(searchParams.get(sortKey));
|
||||
if (urlSort !== sort) setSortState(urlSort);
|
||||
if (enableCountry) {
|
||||
const urlCountry = searchParams.get(countryKey) ?? undefined;
|
||||
if (urlCountry !== country) setCountryState(urlCountry);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// Debounced search → URL. Strip empty values so the canonical URL
|
||||
// stays clean.
|
||||
useEffect(() => {
|
||||
if (!useUrl) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const trimmed = debouncedSearch.trim();
|
||||
if (trimmed) next.set(qKey, trimmed);
|
||||
else next.delete(qKey);
|
||||
if (next.toString() !== searchParams.toString()) {
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch, useUrl]);
|
||||
|
||||
const setSort = useCallback(
|
||||
(next: Nip50Sort) => {
|
||||
setSortState(next);
|
||||
if (!useUrl) return;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next === 'default') params.delete(sortKey);
|
||||
else params.set(sortKey, next);
|
||||
setSearchParams(params, { replace: true });
|
||||
},
|
||||
[useUrl, searchParams, setSearchParams, sortKey],
|
||||
);
|
||||
|
||||
const setCountry = useCallback(
|
||||
(next: string | undefined) => {
|
||||
if (!enableCountry) return;
|
||||
setCountryState(next);
|
||||
if (!useUrl) return;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (next) params.set(countryKey, next);
|
||||
else params.delete(countryKey);
|
||||
setSearchParams(params, { replace: true });
|
||||
},
|
||||
[enableCountry, useUrl, searchParams, setSearchParams, countryKey],
|
||||
);
|
||||
|
||||
const setSearchInput = useCallback((next: string) => {
|
||||
setSearchInputState(next);
|
||||
// URL writes happen on `debouncedSearch` flipping, not per keystroke.
|
||||
}, []);
|
||||
|
||||
return {
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
debouncedSearch,
|
||||
sort,
|
||||
setSort,
|
||||
country,
|
||||
setCountry,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
|
||||
|
||||
/**
|
||||
* Addressable coordinate for a pledge (kind 36639): `36639:<pubkey>:<d>`.
|
||||
*
|
||||
* Accepts any object carrying `pubkey` and `id` so this helper stays in
|
||||
* the lib layer without taking a hook dep on `Action`. Both the moderation
|
||||
* label system (NIP-32 / kind 1985 `a`-tags) and the share-link generator
|
||||
* (NIP-09 deletion requests, naddr encoders) hand-rolled the same string
|
||||
* three times before this consolidation; one source of truth now.
|
||||
*/
|
||||
export function getPledgeCoord({ pubkey, id }: { pubkey: string; id: string }): string {
|
||||
return `36639:${pubkey}:${id}`;
|
||||
}
|
||||
|
||||
export function formatPledgeAmount(sats: number, btcPrice: number | undefined): string {
|
||||
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
|
||||
return `${formatSats(sats)} sats`;
|
||||
|
||||
+6
-6
@@ -216,8 +216,8 @@
|
||||
"myPledgesTagline": "التعهدات التي أنشأتها.",
|
||||
"featuredPledges": "تعهدات مميزة",
|
||||
"featuredPledgesTagline": "تعهدات يسلّط فريق {{appName}} الضوء عليها.",
|
||||
"allPledges": "كل التعهدات",
|
||||
"allPledgesTagline": "تصفّح كل تعهد على الشبكة.",
|
||||
"allPledges": "التعهدات",
|
||||
"allPledgesTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل تعهد.",
|
||||
"sectionActive": "التعهدات النشطة",
|
||||
"sectionUpcoming": "التعهدات القادمة",
|
||||
"sectionPast": "التعهدات السابقة",
|
||||
@@ -347,8 +347,8 @@
|
||||
"myGroupsTagline": "المجموعات التي أسستها أو تشرف عليها أو تتابعها.",
|
||||
"featuredGroups": "المجموعات المميزة",
|
||||
"featuredGroupsTagline": "مجموعات بارزة تستحق اهتمامك.",
|
||||
"allGroups": "كل المجموعات",
|
||||
"allGroupsTagline": "تصفّح مجموعات {{appName}}، أو ابحث في كل المجموعات على نوستر.",
|
||||
"allGroups": "المجموعات",
|
||||
"allGroupsTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح كل مجموعة.",
|
||||
"loginToSeeTitle": "سجّل الدخول لرؤية مجموعاتك",
|
||||
"loginToSeeBody": "ستظهر هنا المجموعات التي أسستها أو التي تشرف عليها.",
|
||||
"noGroupsTitle": "لا توجد مجموعات بعد",
|
||||
@@ -704,10 +704,10 @@
|
||||
"emptyHint": "كن أول من يبدأ حملة تمويل على {{appName}}. اروِ قصتك، اختر المستفيدين، وشارك الرابط."
|
||||
},
|
||||
"all": {
|
||||
"title": "كل الحملات",
|
||||
"title": "الحملات",
|
||||
"seoTitle": "كل الحملات",
|
||||
"description": "تصفّح كل الحملات المنشورة على Agora.",
|
||||
"sectionTagline": "تصفّح كل قضيّة على الشبكة.",
|
||||
"sectionTagline": "مختارة من قِبل المشرفين. ابحث أو رتّب لتصفّح الشبكة بأكملها.",
|
||||
"heroKicker": "الحملات",
|
||||
"heroHeading": "كل قضيّة،",
|
||||
"heroHeadingLine2": "في مكان واحد.",
|
||||
|
||||
+6
-6
@@ -644,8 +644,8 @@
|
||||
"myPledgesTagline": "Pledges you've created.",
|
||||
"featuredPledges": "Featured pledges",
|
||||
"featuredPledgesTagline": "Pledges highlighted by the {{appName}} team.",
|
||||
"allPledges": "All pledges",
|
||||
"allPledgesTagline": "Browse every pledge on the network.",
|
||||
"allPledges": "Pledges",
|
||||
"allPledgesTagline": "Highlighted by moderators. Search or sort to browse every pledge.",
|
||||
"sectionActive": "Active pledges",
|
||||
"sectionUpcoming": "Upcoming pledges",
|
||||
"sectionPast": "Past pledges",
|
||||
@@ -779,8 +779,8 @@
|
||||
"myGroupsTagline": "Groups you've founded, moderate, or follow.",
|
||||
"featuredGroups": "Featured groups",
|
||||
"featuredGroupsTagline": "Standout groups worth your attention.",
|
||||
"allGroups": "All groups",
|
||||
"allGroupsTagline": "Browse {{appName}} groups, or search across every group on Nostr.",
|
||||
"allGroups": "Groups",
|
||||
"allGroupsTagline": "Highlighted by moderators. Search or sort to browse every group.",
|
||||
"searchPlaceholder": "Search groups\u2026",
|
||||
"searchAriaLabel": "Search groups",
|
||||
"noMatch": "No groups match \u201c{{query}}\u201d",
|
||||
@@ -1144,10 +1144,10 @@
|
||||
"emptyHint": "Be the first to start a fundraiser on {{appName}}. Tell your story, choose your beneficiaries, and share the link."
|
||||
},
|
||||
"all": {
|
||||
"title": "All Campaigns",
|
||||
"title": "Campaigns",
|
||||
"seoTitle": "All campaigns",
|
||||
"description": "Browse every campaign published on Agora.",
|
||||
"sectionTagline": "Browse every cause on the network.",
|
||||
"sectionTagline": "Highlighted by moderators. Search or sort to browse the full network.",
|
||||
"heroKicker": "Campaigns",
|
||||
"heroHeading": "Every cause,",
|
||||
"heroHeadingLine2": "in one place.",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "Promesas que has creado.",
|
||||
"featuredPledges": "Promesas destacadas",
|
||||
"featuredPledgesTagline": "Promesas destacadas por el equipo de {{appName}}.",
|
||||
"allPledges": "Todas las promesas",
|
||||
"allPledgesTagline": "Explora todas las promesas de la red.",
|
||||
"allPledges": "Promesas",
|
||||
"allPledgesTagline": "Destacadas por los moderadores. Busca u ordena para explorar todas las promesas.",
|
||||
"sectionActive": "Promesas activas",
|
||||
"sectionUpcoming": "Promesas próximas",
|
||||
"sectionPast": "Promesas pasadas",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "Grupos que fundaste, moderas o sigues.",
|
||||
"featuredGroups": "Grupos destacados",
|
||||
"featuredGroupsTagline": "Grupos destacados que merecen tu atención.",
|
||||
"allGroups": "Todos los grupos",
|
||||
"allGroupsTagline": "Explora los grupos de {{appName}} o busca entre todos los grupos de Nostr.",
|
||||
"allGroups": "Grupos",
|
||||
"allGroupsTagline": "Destacados por los moderadores. Busca u ordena para explorar todos los grupos.",
|
||||
"loginToSeeTitle": "Inicia sesión para ver tus grupos",
|
||||
"loginToSeeBody": "Los grupos que fundaste o moderas aparecerán aquí.",
|
||||
"noGroupsTitle": "Aún no hay grupos",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "Prueba con otro término de búsqueda o bórrala."
|
||||
},
|
||||
"all": {
|
||||
"title": "Todas las campañas",
|
||||
"title": "Campañas",
|
||||
"seoTitle": "Todas las campañas",
|
||||
"description": "Explora todas las campañas publicadas en Agora.",
|
||||
"sectionTagline": "Explora cada causa en la red.",
|
||||
"sectionTagline": "Destacadas por los moderadores. Busca u ordena para explorar toda la red.",
|
||||
"heroKicker": "Campañas",
|
||||
"heroHeading": "Cada causa,",
|
||||
"heroHeadingLine2": "en un solo lugar.",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "تعهدهایی که ایجاد کردهای.",
|
||||
"featuredPledges": "تعهدهای ویژه",
|
||||
"featuredPledgesTagline": "تعهدهایی که تیم {{appName}} برجسته کرده است.",
|
||||
"allPledges": "همهٔ تعهدها",
|
||||
"allPledgesTagline": "همهٔ تعهدهای موجود در شبکه را مرور کن.",
|
||||
"allPledges": "تعهدها",
|
||||
"allPledgesTagline": "برگزیدهٔ ناظران. برای مرور همهٔ تعهدها جستجو یا مرتبسازی کن.",
|
||||
"sectionActive": "تعهدهای فعال",
|
||||
"sectionUpcoming": "تعهدهای پیشرو",
|
||||
"sectionPast": "تعهدهای گذشته",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "گروههایی که ساختهای، مدیریت میکنی یا دنبال میکنی.",
|
||||
"featuredGroups": "گروههای ویژه",
|
||||
"featuredGroupsTagline": "گروههای برجستهای که ارزش توجه تو را دارند.",
|
||||
"allGroups": "همهٔ گروهها",
|
||||
"allGroupsTagline": "گروههای {{appName}} را مرور کن یا میان همهٔ گروههای Nostr جستجو کن.",
|
||||
"allGroups": "گروهها",
|
||||
"allGroupsTagline": "برگزیدهٔ ناظران. برای مرور همهٔ گروهها جستجو یا مرتبسازی کن.",
|
||||
"loginToSeeTitle": "برای دیدن گروههایت وارد شو",
|
||||
"loginToSeeBody": "گروههایی که ساختهای یا مدیریت میکنی اینجا ظاهر میشوند.",
|
||||
"noGroupsTitle": "هنوز گروهی نیست",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "عبارت جستجوی دیگری را امتحان کنید، یا جستجو را پاک کنید."
|
||||
},
|
||||
"all": {
|
||||
"title": "همه کمپینها",
|
||||
"title": "کمپینها",
|
||||
"seoTitle": "همه کمپینها",
|
||||
"description": "همه کمپینهای منتشرشده در Agora را مرور کنید.",
|
||||
"sectionTagline": "هر هدفی را در شبکه مرور کنید.",
|
||||
"sectionTagline": "برگزیدهٔ ناظران. برای مرور کل شبکه جستجو یا مرتبسازی کن.",
|
||||
"heroKicker": "کمپینها",
|
||||
"heroHeading": "هر هدف،",
|
||||
"heroHeadingLine2": "در یک جا.",
|
||||
|
||||
+6
-6
@@ -642,8 +642,8 @@
|
||||
"myPledgesTagline": "Les promesses que vous avez créées.",
|
||||
"featuredPledges": "Promesses en vedette",
|
||||
"featuredPledgesTagline": "Promesses mises en avant par l'équipe {{appName}}.",
|
||||
"allPledges": "Toutes les promesses",
|
||||
"allPledgesTagline": "Parcourez toutes les promesses du réseau.",
|
||||
"allPledges": "Promesses",
|
||||
"allPledgesTagline": "Sélectionnées par les modérateurs. Recherchez ou triez pour parcourir toutes les promesses.",
|
||||
"sectionActive": "Promesses actives",
|
||||
"sectionUpcoming": "Promesses à venir",
|
||||
"sectionPast": "Promesses passées",
|
||||
@@ -777,8 +777,8 @@
|
||||
"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.",
|
||||
"allGroups": "Tous les groupes",
|
||||
"allGroupsTagline": "Parcourez les groupes {{appName}}, ou cherchez parmi tous les groupes de Nostr.",
|
||||
"allGroups": "Groupes",
|
||||
"allGroupsTagline": "Sélectionnés par les modérateurs. Recherchez ou triez pour parcourir tous les groupes.",
|
||||
"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",
|
||||
@@ -1142,10 +1142,10 @@
|
||||
"noMatchHint": "Essayez un autre terme de recherche, ou effacez la recherche."
|
||||
},
|
||||
"all": {
|
||||
"title": "Toutes les campagnes",
|
||||
"title": "Campagnes",
|
||||
"seoTitle": "Toutes les campagnes",
|
||||
"description": "Parcourez toutes les campagnes publiées sur Agora.",
|
||||
"sectionTagline": "Parcourez toutes les causes du réseau.",
|
||||
"sectionTagline": "Sélectionnées par les modérateurs. Recherchez ou triez pour parcourir tout le réseau.",
|
||||
"heroKicker": "Campagnes",
|
||||
"heroHeading": "Chaque cause,",
|
||||
"heroHeadingLine2": "au même endroit.",
|
||||
|
||||
+6
-6
@@ -652,8 +652,8 @@
|
||||
"myPledgesTagline": "आपके बनाए हुए प्लेज।",
|
||||
"featuredPledges": "विशेष प्लेज",
|
||||
"featuredPledgesTagline": "{{appName}} टीम द्वारा चुने गए प्लेज।",
|
||||
"allPledges": "सभी प्लेज",
|
||||
"allPledgesTagline": "नेटवर्क पर मौजूद हर प्लेज देखें।",
|
||||
"allPledges": "प्लेज",
|
||||
"allPledgesTagline": "मॉडरेटर द्वारा चयनित। हर प्लेज देखने के लिए खोजें या क्रमबद्ध करें।",
|
||||
"sectionActive": "सक्रिय प्लेज",
|
||||
"sectionUpcoming": "आने वाले प्लेज",
|
||||
"sectionPast": "पिछले प्लेज",
|
||||
@@ -787,8 +787,8 @@
|
||||
"myGroupsTagline": "जिन ग्रुप को आपने बनाया, मॉडरेट किया, या फ़ॉलो किया है।",
|
||||
"featuredGroups": "फ़ीचर्ड ग्रुप",
|
||||
"featuredGroupsTagline": "आपके ध्यान के लायक ख़ास ग्रुप।",
|
||||
"allGroups": "सभी ग्रुप",
|
||||
"allGroupsTagline": "{{appName}} ग्रुप ब्राउज़ करें, या Nostr के हर ग्रुप में खोजें।",
|
||||
"allGroups": "ग्रुप",
|
||||
"allGroupsTagline": "मॉडरेटर द्वारा चयनित। हर ग्रुप देखने के लिए खोजें या क्रमबद्ध करें।",
|
||||
"loginToSeeTitle": "अपने ग्रुप देखने के लिए लॉग इन करें",
|
||||
"loginToSeeBody": "आपने जो ग्रुप बनाए या मॉडरेट किए हैं वे यहाँ दिखेंगे।",
|
||||
"noGroupsTitle": "अभी कोई ग्रुप नहीं",
|
||||
@@ -1152,10 +1152,10 @@
|
||||
"noMatchHint": "अलग खोज शब्द आज़माएँ, या खोज साफ़ करें।"
|
||||
},
|
||||
"all": {
|
||||
"title": "सभी कैंपेन",
|
||||
"title": "कैंपेन",
|
||||
"seoTitle": "सभी कैंपेन",
|
||||
"description": "Agora पर पब्लिश हुए हर कैंपेन को देखें।",
|
||||
"sectionTagline": "नेटवर्क पर हर मक़सद को देखें।",
|
||||
"sectionTagline": "मॉडरेटर द्वारा चयनित। पूरे नेटवर्क को देखने के लिए खोजें या क्रमबद्ध करें।",
|
||||
"heroKicker": "कैंपेन",
|
||||
"heroHeading": "हर मक़सद,",
|
||||
"heroHeadingLine2": "एक ही जगह।",
|
||||
|
||||
+6
-6
@@ -652,8 +652,8 @@
|
||||
"myPledgesTagline": "Ikrar yang Anda buat.",
|
||||
"featuredPledges": "Ikrar pilihan",
|
||||
"featuredPledgesTagline": "Ikrar yang disorot oleh tim {{appName}}.",
|
||||
"allPledges": "Semua ikrar",
|
||||
"allPledgesTagline": "Jelajahi setiap ikrar di jaringan.",
|
||||
"allPledges": "Ikrar",
|
||||
"allPledgesTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap ikrar.",
|
||||
"sectionActive": "Ikrar aktif",
|
||||
"sectionUpcoming": "Ikrar mendatang",
|
||||
"sectionPast": "Ikrar lampau",
|
||||
@@ -787,8 +787,8 @@
|
||||
"myGroupsTagline": "Grup yang Anda dirikan, moderasi, atau ikuti.",
|
||||
"featuredGroups": "Grup unggulan",
|
||||
"featuredGroupsTagline": "Grup menonjol yang layak Anda perhatikan.",
|
||||
"allGroups": "Semua grup",
|
||||
"allGroupsTagline": "Jelajahi grup {{appName}}, atau cari di setiap grup di Nostr.",
|
||||
"allGroups": "Grup",
|
||||
"allGroupsTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi setiap grup.",
|
||||
"loginToSeeTitle": "Masuk untuk melihat grup Anda",
|
||||
"loginToSeeBody": "Grup yang Anda dirikan atau moderasi akan muncul di sini.",
|
||||
"noGroupsTitle": "Belum ada grup",
|
||||
@@ -1152,10 +1152,10 @@
|
||||
"noMatchHint": "Coba kata pencarian lain, atau bersihkan pencarian."
|
||||
},
|
||||
"all": {
|
||||
"title": "Semua Kampanye",
|
||||
"title": "Kampanye",
|
||||
"seoTitle": "Semua kampanye",
|
||||
"description": "Telusuri setiap kampanye yang dipublikasikan di Agora.",
|
||||
"sectionTagline": "Jelajahi setiap aksi di jaringan.",
|
||||
"sectionTagline": "Disorot oleh moderator. Cari atau urutkan untuk menjelajahi seluruh jaringan.",
|
||||
"heroKicker": "Kampanye",
|
||||
"heroHeading": "Setiap aksi,",
|
||||
"heroHeadingLine2": "dalam satu tempat.",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "ការសន្យាដែលអ្នកបានបង្កើត។",
|
||||
"featuredPledges": "ការសន្យាលេចធ្លោ",
|
||||
"featuredPledgesTagline": "ការសន្យាដែលត្រូវបានរំលេចដោយក្រុម {{appName}}។",
|
||||
"allPledges": "ការសន្យាទាំងអស់",
|
||||
"allPledgesTagline": "រកមើលការសន្យាគ្រប់ៗមួយនៅលើបណ្ដាញ។",
|
||||
"allPledges": "ការសន្យា",
|
||||
"allPledgesTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលការសន្យាគ្រប់ៗមួយ។",
|
||||
"sectionActive": "ការសន្យាសកម្ម",
|
||||
"sectionUpcoming": "ការសន្យាខាងមុខ",
|
||||
"sectionPast": "ការសន្យាកន្លងមក",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "ក្រុមដែលអ្នកបានបង្កើត គ្រប់គ្រង ឬដាក់តាមដាន។",
|
||||
"featuredGroups": "ក្រុមលេចធ្លោ",
|
||||
"featuredGroupsTagline": "ក្រុមលេចធ្លោដែលសក្តិសមនឹងការយកចិត្តទុកដាក់របស់អ្នក។",
|
||||
"allGroups": "ក្រុមទាំងអស់",
|
||||
"allGroupsTagline": "រកមើលក្រុម {{appName}} ឬស្វែងរកក្នុងគ្រប់ក្រុមនៅលើ Nostr។",
|
||||
"allGroups": "ក្រុម",
|
||||
"allGroupsTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលក្រុមគ្រប់ៗមួយ។",
|
||||
"loginToSeeTitle": "ចូលដើម្បីមើលក្រុមរបស់អ្នក",
|
||||
"loginToSeeBody": "ក្រុមដែលអ្នកបានបង្កើត ឬគ្រប់គ្រងនឹងបង្ហាញនៅទីនេះ។",
|
||||
"noGroupsTitle": "មិនទាន់មានក្រុមទេ",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "សាកល្បងពាក្យស្វែងរកផ្សេង ឬសម្អាតការស្វែងរក។"
|
||||
},
|
||||
"all": {
|
||||
"title": "យុទ្ធនាការទាំងអស់",
|
||||
"title": "យុទ្ធនាការ",
|
||||
"seoTitle": "យុទ្ធនាការទាំងអស់",
|
||||
"description": "រកមើលរាល់យុទ្ធនាការដែលផ្សព្វផ្សាយលើ Agora។",
|
||||
"sectionTagline": "រកមើលរាល់បុព្វហេតុនៅលើបណ្ដាញ។",
|
||||
"sectionTagline": "រំលេចដោយអ្នកសម្របសម្រួល។ ស្វែងរក ឬតម្រៀបដើម្បីរកមើលបណ្ដាញទាំងមូល។",
|
||||
"heroKicker": "យុទ្ធនាការ",
|
||||
"heroHeading": "រាល់បុព្វហេតុ",
|
||||
"heroHeadingLine2": "នៅកន្លែងតែមួយ។",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "هغه ژمنې چې تاسو جوړې کړې دي.",
|
||||
"featuredPledges": "ځانګړې ژمنې",
|
||||
"featuredPledgesTagline": "هغه ژمنې چې د {{appName}} ټیم په ګوته کړې دي.",
|
||||
"allPledges": "ټولې ژمنې",
|
||||
"allPledgesTagline": "په شبکه کې هره ژمنه وګورئ.",
|
||||
"allPledges": "ژمنې",
|
||||
"allPledgesTagline": "د څارونکو لخوا ځانګړې شوې. د ټولو ژمنو لیدلو لپاره لټون یا ترتیب وکړئ.",
|
||||
"sectionActive": "فعالې ژمنې",
|
||||
"sectionUpcoming": "راتلونکې ژمنې",
|
||||
"sectionPast": "تېرې ژمنې",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "هغه ډلې چې تاسو یې جوړې کړي، اداره کوئ، یا یې تعقیبوئ.",
|
||||
"featuredGroups": "ځانګړې ډلې",
|
||||
"featuredGroupsTagline": "ځانګړې ډلې چې ستاسو د پاملرنې وړ دي.",
|
||||
"allGroups": "ټولې ډلې",
|
||||
"allGroupsTagline": "د {{appName}} ډلې وڅیړئ، یا د Nostr په هره ډله کې لټون وکړئ.",
|
||||
"allGroups": "ډلې",
|
||||
"allGroupsTagline": "د څارونکو لخوا ځانګړې شوې. د ټولو ډلو لیدلو لپاره لټون یا ترتیب وکړئ.",
|
||||
"loginToSeeTitle": "د خپلو ډلو لیدلو لپاره ننوځئ",
|
||||
"loginToSeeBody": "هغه ډلې چې تاسو یې جوړې کړي یا یې اداره کوئ به دلته راڅرګندې شي.",
|
||||
"noGroupsTitle": "تر اوسه ډلې نشته",
|
||||
@@ -726,10 +726,10 @@
|
||||
"heroBody": "په Nostr کې خپور شوی هر د مرستو راټولولو کمپاین په یوه ځای کې راټول شوی. د بشپړې شبکې لټون وکړئ، هغه هدف ومومئ چې درته اهمیت لري، او په مستقیم ډول یې د بټکوین له لارې ملاتړ وکړئ.",
|
||||
"campaignsCount_one": "په شبکه کې کمپاین",
|
||||
"campaignsCount_other": "په شبکه کې کمپاینونه",
|
||||
"title": "ټول کمپاینونه",
|
||||
"title": "کمپاینونه",
|
||||
"seoTitle": "ټول کمپاینونه",
|
||||
"description": "په Agora کې خپور شوي ټول کمپاینونه وګورئ.",
|
||||
"sectionTagline": "په شبکه کې هر هدف وپلټئ.",
|
||||
"sectionTagline": "د څارونکو لخوا ځانګړي شوي. د ټولې شبکې لیدلو لپاره لټون یا ترتیب وکړئ.",
|
||||
"searchAriaLabel": "د کمپاینونو لټون",
|
||||
"searchPlaceholder": "د کمپاینونو لټون…",
|
||||
"clearSearch": "د لټون پاکول",
|
||||
|
||||
+6
-6
@@ -652,8 +652,8 @@
|
||||
"myPledgesTagline": "Promessas que você criou.",
|
||||
"featuredPledges": "Promessas em destaque",
|
||||
"featuredPledgesTagline": "Promessas destacadas pela equipe do {{appName}}.",
|
||||
"allPledges": "Todas as promessas",
|
||||
"allPledgesTagline": "Explore todas as promessas da rede.",
|
||||
"allPledges": "Promessas",
|
||||
"allPledgesTagline": "Destacadas pelos moderadores. Pesquise ou ordene para explorar todas as promessas.",
|
||||
"sectionActive": "Promessas ativas",
|
||||
"sectionUpcoming": "Promessas futuras",
|
||||
"sectionPast": "Promessas passadas",
|
||||
@@ -787,8 +787,8 @@
|
||||
"myGroupsTagline": "Grupos que você fundou, modera ou segue.",
|
||||
"featuredGroups": "Grupos em destaque",
|
||||
"featuredGroupsTagline": "Grupos que se destacam e merecem sua atenção.",
|
||||
"allGroups": "Todos os grupos",
|
||||
"allGroupsTagline": "Explore os grupos do {{appName}} ou pesquise em todos os grupos do Nostr.",
|
||||
"allGroups": "Grupos",
|
||||
"allGroupsTagline": "Destacados pelos moderadores. Pesquise ou ordene para explorar todos os grupos.",
|
||||
"loginToSeeTitle": "Entre para ver seus grupos",
|
||||
"loginToSeeBody": "Grupos que você fundou ou modera aparecerão aqui.",
|
||||
"noGroupsTitle": "Nenhum grupo ainda",
|
||||
@@ -1152,10 +1152,10 @@
|
||||
"noMatchHint": "Tente um termo de pesquisa diferente, ou limpe a pesquisa."
|
||||
},
|
||||
"all": {
|
||||
"title": "Todas as campanhas",
|
||||
"title": "Campanhas",
|
||||
"seoTitle": "Todas as campanhas",
|
||||
"description": "Navegue por todas as campanhas publicadas no Agora.",
|
||||
"sectionTagline": "Conheça todas as causas da rede.",
|
||||
"sectionTagline": "Destacadas pelos moderadores. Pesquise ou ordene para explorar toda a rede.",
|
||||
"heroKicker": "Campanhas",
|
||||
"heroHeading": "Cada causa,",
|
||||
"heroHeadingLine2": "em um só lugar.",
|
||||
|
||||
+6
-6
@@ -652,8 +652,8 @@
|
||||
"myPledgesTagline": "Обещания, которые вы создали.",
|
||||
"featuredPledges": "Избранные обещания",
|
||||
"featuredPledgesTagline": "Обещания, отмеченные командой {{appName}}.",
|
||||
"allPledges": "Все обещания",
|
||||
"allPledgesTagline": "Просматривайте все обещания в сети.",
|
||||
"allPledges": "Обещания",
|
||||
"allPledgesTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все обещания.",
|
||||
"sectionActive": "Активные обещания",
|
||||
"sectionUpcoming": "Предстоящие обещания",
|
||||
"sectionPast": "Прошлые обещания",
|
||||
@@ -787,8 +787,8 @@
|
||||
"myGroupsTagline": "Группы, которые вы основали, модерируете или на которые подписаны.",
|
||||
"featuredGroups": "Избранные группы",
|
||||
"featuredGroupsTagline": "Заметные группы, достойные вашего внимания.",
|
||||
"allGroups": "Все группы",
|
||||
"allGroupsTagline": "Просматривайте группы {{appName}} или ищите среди всех групп в Nostr.",
|
||||
"allGroups": "Группы",
|
||||
"allGroupsTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть все группы.",
|
||||
"loginToSeeTitle": "Войдите, чтобы увидеть свои группы",
|
||||
"loginToSeeBody": "Группы, которые вы основали или модерируете, появятся здесь.",
|
||||
"noGroupsTitle": "Пока нет групп",
|
||||
@@ -1152,10 +1152,10 @@
|
||||
"noMatchHint": "Попробуйте другой поисковый запрос или очистите поиск."
|
||||
},
|
||||
"all": {
|
||||
"title": "Все кампании",
|
||||
"title": "Кампании",
|
||||
"seoTitle": "Все кампании",
|
||||
"description": "Просмотрите все кампании, опубликованные на Agora.",
|
||||
"sectionTagline": "Просмотрите каждое дело в сети.",
|
||||
"sectionTagline": "Отмечено модераторами. Используйте поиск или сортировку, чтобы просмотреть всю сеть.",
|
||||
"heroKicker": "Кампании",
|
||||
"heroHeading": "Каждое дело —",
|
||||
"heroHeadingLine2": "в одном месте.",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "Zvitsidziro zvawakagadzira.",
|
||||
"featuredPledges": "Zvitsidziro zvakasarudzwa",
|
||||
"featuredPledgesTagline": "Zvitsidziro zvakasimudzirwa nechikwata che{{appName}}.",
|
||||
"allPledges": "Zvitsidziro zvose",
|
||||
"allPledgesTagline": "Tarisa chitsidziro chega chega chiri pamutambo wenetiweki.",
|
||||
"allPledges": "Zvitsidziro",
|
||||
"allPledgesTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise chitsidziro chega chega.",
|
||||
"sectionActive": "Zvitsidziro zviri kushanda",
|
||||
"sectionUpcoming": "Zvitsidziro zviri kuuya",
|
||||
"sectionPast": "Zvitsidziro zvapfuura",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "Mapoka awakatanga, aunotungamira, kana aunotevera.",
|
||||
"featuredGroups": "Mapoka anokurumbira",
|
||||
"featuredGroupsTagline": "Mapoka anobudirira anokodzera kutariswa nemi.",
|
||||
"allGroups": "Mapoka ose",
|
||||
"allGroupsTagline": "Tarisa mapoka e{{appName}}, kana kutsvaga mumapoka ose ari paNostr.",
|
||||
"allGroups": "Mapoka",
|
||||
"allGroupsTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise boka rega rega.",
|
||||
"loginToSeeTitle": "Pinda kuti uone mapoka ako",
|
||||
"loginToSeeBody": "Mapoka awakatanga kana aunotungamira achaonekwa pano.",
|
||||
"noGroupsTitle": "Hapana mapoka parizvino",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "Edza chimwe chinotsvagwa, kana ubvise kutsvaga."
|
||||
},
|
||||
"all": {
|
||||
"title": "Mishandirapamwe Yose",
|
||||
"title": "Mishandirapamwe",
|
||||
"seoTitle": "Mishandirapamwe yose",
|
||||
"description": "Tarisa mishandirapamwe yose yakaiswa paAgora.",
|
||||
"sectionTagline": "Tarisa chinangwa chimwe nechimwe panetwork.",
|
||||
"sectionTagline": "Zvakatarisirwa nevatariri. Tsvaga kana kuronga kuti utarise netiweki yose.",
|
||||
"heroKicker": "Mishandirapamwe",
|
||||
"heroHeading": "Chinangwa chimwe nechimwe,",
|
||||
"heroHeadingLine2": "panzvimbo imwe chete.",
|
||||
|
||||
+6
-6
@@ -651,8 +651,8 @@
|
||||
"myPledgesTagline": "Ahadi ulizounda.",
|
||||
"featuredPledges": "Ahadi maalum",
|
||||
"featuredPledgesTagline": "Ahadi zilizoangaziwa na timu ya {{appName}}.",
|
||||
"allPledges": "Ahadi zote",
|
||||
"allPledgesTagline": "Vinjari kila ahadi kwenye mtandao.",
|
||||
"allPledges": "Ahadi",
|
||||
"allPledgesTagline": "Zimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila ahadi.",
|
||||
"sectionActive": "Ahadi zinazoendelea",
|
||||
"sectionUpcoming": "Ahadi zijazo",
|
||||
"sectionPast": "Ahadi zilizopita",
|
||||
@@ -786,8 +786,8 @@
|
||||
"myGroupsTagline": "Vikundi ulivyounda, unavyosimamia, au unavyofuata.",
|
||||
"featuredGroups": "Vikundi maarufu",
|
||||
"featuredGroupsTagline": "Vikundi vinavyojitokeza vinavyostahili usikivu wako.",
|
||||
"allGroups": "Vikundi vyote",
|
||||
"allGroupsTagline": "Vinjari vikundi vya {{appName}}, au tafuta kati ya kila kikundi kwenye Nostr.",
|
||||
"allGroups": "Vikundi",
|
||||
"allGroupsTagline": "Vimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari kila kikundi.",
|
||||
"loginToSeeTitle": "Ingia ili kuona vikundi vyako",
|
||||
"loginToSeeBody": "Vikundi ulivyounda au unavyosimamia vitaonekana hapa.",
|
||||
"noGroupsTitle": "Hakuna vikundi bado",
|
||||
@@ -1151,10 +1151,10 @@
|
||||
"noMatchHint": "Jaribu neno tofauti la utafutaji, au futa utafutaji."
|
||||
},
|
||||
"all": {
|
||||
"title": "Kampeni Zote",
|
||||
"title": "Kampeni",
|
||||
"seoTitle": "Kampeni zote",
|
||||
"description": "Vinjari kila kampeni iliyochapishwa kwenye Agora.",
|
||||
"sectionTagline": "Vinjari kila lengo kwenye mtandao.",
|
||||
"sectionTagline": "Zimeangaziwa na wasimamizi. Tafuta au panga ili kuvinjari mtandao mzima.",
|
||||
"searchAriaLabel": "Tafuta kampeni",
|
||||
"searchPlaceholder": "Tafuta kampeni…",
|
||||
"clearSearch": "Futa utafutaji",
|
||||
|
||||
+6
-6
@@ -651,8 +651,8 @@
|
||||
"myPledgesTagline": "Oluşturduğunuz taahhütler.",
|
||||
"featuredPledges": "Öne çıkan taahhütler",
|
||||
"featuredPledgesTagline": "{{appName}} ekibinin öne çıkardığı taahhütler.",
|
||||
"allPledges": "Tüm taahhütler",
|
||||
"allPledgesTagline": "Ağdaki her taahhüde göz atın.",
|
||||
"allPledges": "Taahhütler",
|
||||
"allPledgesTagline": "Moderatörler tarafından öne çıkarıldı. Her taahhüde göz atmak için arama yapın veya sıralayın.",
|
||||
"sectionActive": "Aktif taahhütler",
|
||||
"sectionUpcoming": "Yaklaşan taahhütler",
|
||||
"sectionPast": "Geçmiş taahhütler",
|
||||
@@ -786,8 +786,8 @@
|
||||
"myGroupsTagline": "Kurduğunuz, yönettiğiniz veya takip ettiğiniz gruplar.",
|
||||
"featuredGroups": "Öne çıkan gruplar",
|
||||
"featuredGroupsTagline": "Dikkatinize değer öne çıkan gruplar.",
|
||||
"allGroups": "Tüm gruplar",
|
||||
"allGroupsTagline": "{{appName}} gruplarına göz atın veya Nostr'daki her grup arasında arama yapın.",
|
||||
"allGroups": "Gruplar",
|
||||
"allGroupsTagline": "Moderatörler tarafından öne çıkarıldı. Her gruba göz atmak için arama yapın veya sıralayın.",
|
||||
"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",
|
||||
@@ -1151,10 +1151,10 @@
|
||||
"noMatchHint": "Farklı bir arama terimi deneyin ya da aramayı temizleyin."
|
||||
},
|
||||
"all": {
|
||||
"title": "Tüm Kampanyalar",
|
||||
"title": "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.",
|
||||
"sectionTagline": "Moderatörler tarafından öne çıkarıldı. Ağın tamamına göz atmak için arama yapın veya sıralayın.",
|
||||
"searchAriaLabel": "Kampanyaları ara",
|
||||
"searchPlaceholder": "Kampanya ara…",
|
||||
"clearSearch": "Aramayı temizle",
|
||||
|
||||
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "你建立的懸賞。",
|
||||
"featuredPledges": "精選懸賞",
|
||||
"featuredPledgesTagline": "{{appName}} 團隊重點推薦的懸賞。",
|
||||
"allPledges": "全部懸賞",
|
||||
"allPledgesTagline": "瀏覽網路上的每一個懸賞。",
|
||||
"allPledges": "懸賞",
|
||||
"allPledgesTagline": "由版主重點推薦。搜尋或排序以瀏覽所有懸賞。",
|
||||
"sectionActive": "進行中的懸賞",
|
||||
"sectionUpcoming": "即將開始的懸賞",
|
||||
"sectionPast": "已結束的懸賞",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "你建立、管理或追蹤的群組。",
|
||||
"featuredGroups": "精選群組",
|
||||
"featuredGroupsTagline": "值得你關注的出色群組。",
|
||||
"allGroups": "全部群組",
|
||||
"allGroupsTagline": "瀏覽 {{appName}} 群組,或在 Nostr 上的每個群組中搜尋。",
|
||||
"allGroups": "群組",
|
||||
"allGroupsTagline": "由版主重點推薦。搜尋或排序以瀏覽所有群組。",
|
||||
"loginToSeeTitle": "登入以檢視你的群組",
|
||||
"loginToSeeBody": "你建立或管理的群組會顯示在這裡。",
|
||||
"noGroupsTitle": "還沒有群組",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "請嘗試不同的搜尋字詞,或清除搜尋。"
|
||||
},
|
||||
"all": {
|
||||
"title": "所有活動",
|
||||
"title": "活動",
|
||||
"seoTitle": "所有活動",
|
||||
"description": "瀏覽 Agora 上釋出的所有活動。",
|
||||
"sectionTagline": "瀏覽網路上的每一項事業。",
|
||||
"sectionTagline": "由版主重點推薦。搜尋或排序以瀏覽整個網路。",
|
||||
"heroKicker": "活動",
|
||||
"heroHeading": "每一份心意,",
|
||||
"heroHeadingLine2": "匯聚於此。",
|
||||
|
||||
+6
-6
@@ -220,8 +220,8 @@
|
||||
"myPledgesTagline": "你创建的悬赏。",
|
||||
"featuredPledges": "精选悬赏",
|
||||
"featuredPledgesTagline": "{{appName}} 团队重点推荐的悬赏。",
|
||||
"allPledges": "全部悬赏",
|
||||
"allPledgesTagline": "浏览网络上的每一个悬赏。",
|
||||
"allPledges": "悬赏",
|
||||
"allPledgesTagline": "由版主精选推荐。可搜索或排序以浏览全部悬赏。",
|
||||
"sectionActive": "进行中的悬赏",
|
||||
"sectionUpcoming": "即将开始的悬赏",
|
||||
"sectionPast": "已结束的悬赏",
|
||||
@@ -355,8 +355,8 @@
|
||||
"myGroupsTagline": "你创建、管理或关注的群组。",
|
||||
"featuredGroups": "精选群组",
|
||||
"featuredGroupsTagline": "值得你关注的出色群组。",
|
||||
"allGroups": "全部群组",
|
||||
"allGroupsTagline": "浏览 {{appName}} 群组,或在 Nostr 上的每个群组中搜索。",
|
||||
"allGroups": "群组",
|
||||
"allGroupsTagline": "由版主精选推荐。可搜索或排序以浏览全部群组。",
|
||||
"loginToSeeTitle": "登录以查看你的群组",
|
||||
"loginToSeeBody": "你创建或管理的群组会显示在这里。",
|
||||
"noGroupsTitle": "还没有群组",
|
||||
@@ -720,10 +720,10 @@
|
||||
"noMatchHint": "尝试其他搜索词,或清除搜索。"
|
||||
},
|
||||
"all": {
|
||||
"title": "所有活动",
|
||||
"title": "活动",
|
||||
"seoTitle": "所有活动",
|
||||
"description": "浏览 Agora 上发布的所有活动。",
|
||||
"sectionTagline": "浏览网络上的每一项事业。",
|
||||
"sectionTagline": "由版主精选推荐。可搜索或排序以浏览整个网络。",
|
||||
"heroKicker": "活动",
|
||||
"heroHeading": "每一个理念,",
|
||||
"heroHeadingLine2": "汇聚于此。",
|
||||
|
||||
+117
-504
@@ -1,211 +1,50 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
EyeOff,
|
||||
Megaphone,
|
||||
PlusCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { parseAction, useActions, type Action } from '@/hooks/useActions';
|
||||
import { ActionShareMenu } from '@/components/ActionShareMenu';
|
||||
import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
|
||||
import { useActions, type Action } from '@/hooks/useActions';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
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 { getGeoDisplayName } from '@/lib/countries';
|
||||
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
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 { Card } from '@/components/ui/card';
|
||||
import {
|
||||
ModerationOverlay,
|
||||
ModeratorCollapsibleSection,
|
||||
} from '@/components/moderation';
|
||||
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2,
|
||||
Link as LinkIcon, Check, MoreHorizontal, Trash2,
|
||||
Megaphone, Sparkles, EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Skeletons / Cards
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPledgeCoord(action: Action) {
|
||||
return `36639:${action.pubkey}:${action.id}`;
|
||||
}
|
||||
|
||||
function ActionSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="flex-1 p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
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,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const actionUrl = `${shareOrigin}/${naddr}`;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(actionUrl);
|
||||
setCopied(true);
|
||||
toast({ title: t('pledges.card.linkCopied') });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy link:', error);
|
||||
toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!user || !isOwner) return;
|
||||
|
||||
const confirmed = window.confirm(t('pledges.card.confirmDelete'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't
|
||||
// honour a-tag-only deletions for addressable events.
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
content: t('pledges.card.deletedContent'),
|
||||
tags: [
|
||||
['e', action.event.id],
|
||||
['a', getPledgeCoord(action)],
|
||||
],
|
||||
});
|
||||
// Extract any organization `A` tag the pledge was associated with so
|
||||
// the org's activity shelf and community feeds refresh too.
|
||||
const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1];
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-actions'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-action'] }),
|
||||
...(orgATag
|
||||
? [
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['community-actions', orgATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return root === 'community-activity-feed'
|
||||
&& typeof aTagsKey === 'string'
|
||||
&& aTagsKey.split(',').includes(orgATag);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
toast({ title: t('pledges.card.deleted') });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pledge:', error);
|
||||
toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
{isOwner && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('pledges.card.deletePledge')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{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={getPledgeCoord(action)}
|
||||
entityTitle={displayTitle}
|
||||
surface="pledge"
|
||||
axes={['hide', 'featured']}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Page
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dedicated `/pledges` page.
|
||||
*
|
||||
* Thin shell around the shared {@link PledgesDiscoverySection}:
|
||||
* hero, optional "My pledges" shelf, the unified search-and-discover
|
||||
* section, and a moderator-only Hidden collapsible.
|
||||
*
|
||||
* URL state (`?q=&sort=&country=`) lives inside the section's
|
||||
* `useDiscoveryFilters` hook so search results stay shareable. The
|
||||
* page reads `?country=` independently to thread it into the
|
||||
* create-pledge href so "Create pledge" preserves the active country
|
||||
* filter into the form.
|
||||
*/
|
||||
export default function ActionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
@@ -213,138 +52,44 @@ export default function ActionsPage() {
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | undefined>(undefined);
|
||||
// Mirror the section's `?country=` so the create-pledge href can
|
||||
// carry it forward into the form pre-fill (matches the old modal's
|
||||
// `countryCode` prop behaviour). The section's filters hook is the
|
||||
// source of truth; we only read here.
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedCountry = searchParams.get('country') ?? undefined;
|
||||
|
||||
// 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];
|
||||
},
|
||||
});
|
||||
|
||||
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
|
||||
// the pledge moderation namespace rides the same signer set as the
|
||||
// campaign and group surfaces.
|
||||
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox)
|
||||
// — the pledge moderation namespace rides the same signer set as
|
||||
// the campaign and group surfaces.
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const canShowHidden = isMod && showHidden;
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const { data: rawActions, isLoading: actionsLoading } = useActions({
|
||||
countryCode: selectedCountry,
|
||||
limit: 300,
|
||||
});
|
||||
|
||||
const { data: myPledges, isLoading: myPledgesLoading } = useActions({
|
||||
const { data: myPledges } = useActions({
|
||||
authors: user ? [user.pubkey] : undefined,
|
||||
limit: 100,
|
||||
enabled: !!user,
|
||||
});
|
||||
// Moderator-only feed of every pledge on the network — drives the
|
||||
// Hidden collapsible and the toolbar's hidden-count badge.
|
||||
const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({
|
||||
limit: 300,
|
||||
enabled: isMod,
|
||||
});
|
||||
const { data: pledgeModeration, isReady: pledgeModerationReady } =
|
||||
usePledgeModeration();
|
||||
|
||||
const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration();
|
||||
const hiddenPledges = useMemo<Action[]>(() => {
|
||||
if (!isMod || !pledgeModerationReady) return [];
|
||||
return (allPledgesForMods ?? []).filter((pledge) =>
|
||||
pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)),
|
||||
);
|
||||
}, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const featuredPledgeCoords = useMemo(() => {
|
||||
if (!pledgeModerationReady) return [] as string[];
|
||||
return Array.from(pledgeModeration.featuredCoords)
|
||||
.filter((coord) => !pledgeModeration.hiddenCoords.has(coord))
|
||||
.sort((a, b) => (pledgeModeration.featuredOrder.get(b) ?? 0) - (pledgeModeration.featuredOrder.get(a) ?? 0));
|
||||
}, [pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({
|
||||
coordinates: featuredPledgeCoords,
|
||||
limit: featuredPledgeCoords.length || 1,
|
||||
enabled: pledgeModerationReady,
|
||||
});
|
||||
|
||||
const orderedFeaturedPledges = useMemo(() => {
|
||||
if (!featuredPledges) return [] as Action[];
|
||||
const order = pledgeModeration.featuredOrder;
|
||||
return [...featuredPledges].sort((a, b) => {
|
||||
const aCoord = getPledgeCoord(a);
|
||||
const bCoord = getPledgeCoord(b);
|
||||
return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0);
|
||||
});
|
||||
}, [featuredPledges, pledgeModeration]);
|
||||
|
||||
const featuredPledgeCoordSet = useMemo(() => new Set(featuredPledgeCoords), [featuredPledgeCoords]);
|
||||
|
||||
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 = getPledgeCoord(a);
|
||||
if (hiddenCoords.has(coord)) {
|
||||
hidden += 1;
|
||||
if (canShowHidden) visible.push(a);
|
||||
} else {
|
||||
visible.push(a);
|
||||
}
|
||||
}
|
||||
return { searchHits: visible, searchHiddenCount: hidden };
|
||||
}, [searchHitsRaw, pledgeModeration, canShowHidden]);
|
||||
|
||||
const { actions, listHiddenCount } = useMemo(() => {
|
||||
if (!rawActions) return { actions: undefined, listHiddenCount: 0 };
|
||||
const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set<string>();
|
||||
let hidden = 0;
|
||||
const visible: Action[] = [];
|
||||
|
||||
for (const action of rawActions) {
|
||||
const coord = getPledgeCoord(action);
|
||||
if (hiddenCoords.has(coord)) {
|
||||
hidden += 1;
|
||||
if (canShowHidden) visible.push(action);
|
||||
} else {
|
||||
visible.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
return { actions: visible, listHiddenCount: hidden };
|
||||
}, [rawActions, pledgeModeration, canShowHidden]);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const createActionHref = selectedCountry
|
||||
? `/pledges/new?country=${encodeURIComponent(selectedCountry)}`
|
||||
: '/pledges/new';
|
||||
@@ -354,216 +99,62 @@ export default function ActionsPage() {
|
||||
: t('pledges.list.global');
|
||||
|
||||
useSeoMeta({
|
||||
title: `${selectedCountry
|
||||
? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
|
||||
: t('pledges.list.seoTitle')} | ${config.appName}`,
|
||||
title: `${
|
||||
selectedCountry
|
||||
? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName })
|
||||
: t('pledges.list.seoTitle')
|
||||
} | ${config.appName}`,
|
||||
description: t('pledges.list.seoDescription'),
|
||||
});
|
||||
|
||||
const isLoading = actionsLoading || !pledgeModerationReady;
|
||||
const isSearchLoading = isSearchFetching || !pledgeModerationReady;
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
const [showAllMine, setShowAllMine] = useState(false);
|
||||
const [showAllFeatured, setShowAllFeatured] = useState(false);
|
||||
const [showAllPledges, setShowAllPledges] = useState(false);
|
||||
|
||||
const allPledges = useMemo(
|
||||
() => (actions ?? []).filter((action) => !featuredPledgeCoordSet.has(getPledgeCoord(action))),
|
||||
[actions, featuredPledgeCoordSet],
|
||||
);
|
||||
const visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
const visibleFeatured = showAllFeatured ? orderedFeaturedPledges : orderedFeaturedPledges.slice(0, DEFAULT_VISIBLE);
|
||||
const visibleAllPledges = showAllPledges ? allPledges : allPledges.slice(0, DEFAULT_VISIBLE);
|
||||
const hiddenPledges = useMemo<Action[]>(() => {
|
||||
if (!isMod || !pledgeModerationReady) return [];
|
||||
return (allPledgesForMods ?? []).filter((pledge) => pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)));
|
||||
}, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const headerControls = (
|
||||
<DiscoverySearchToolbar
|
||||
query={searchInput}
|
||||
onQueryChange={setSearchInput}
|
||||
sort={sortMode}
|
||||
onSortChange={setSortMode}
|
||||
searchPlaceholderKey="pledges.list.searchPlaceholder"
|
||||
searchAriaLabelKey="pledges.list.searchAriaLabel"
|
||||
showHidden={isMod ? {
|
||||
value: canShowHidden,
|
||||
onChange: setShowHidden,
|
||||
count: isSearching ? searchHiddenCount : listHiddenCount,
|
||||
} : undefined}
|
||||
country={selectedCountry}
|
||||
onCountryChange={setSelectedCountry}
|
||||
/>
|
||||
);
|
||||
const visibleMine = showAllMine
|
||||
? (myPledges ?? [])
|
||||
: (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<main className="pb-16 sidebar:pb-0">
|
||||
<ActionsHero
|
||||
actionCount={actions?.length ?? 0}
|
||||
actionCount={allPledgesForMods?.length ?? myPledges?.length ?? 0}
|
||||
canCreate={!!user}
|
||||
onCreateAction={() => navigate(createActionHref)}
|
||||
/>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12">
|
||||
{user && (myPledgesLoading || (myPledges && myPledges.length > 0)) && (
|
||||
{user && myPledges && myPledges.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('pledges.list.myPledges')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.myPledgesTagline')}</p>
|
||||
</div>
|
||||
{myPledgesLoading && !myPledges ? (
|
||||
<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>
|
||||
) : (
|
||||
<ActionSection
|
||||
items={visibleMine}
|
||||
total={myPledges?.length ?? 0}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllMine}
|
||||
onToggle={() => setShowAllMine(!showAllMine)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(featuredPledgesLoading || orderedFeaturedPledges.length > 0) && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
{t('pledges.list.featuredPledges')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('pledges.list.featuredPledgesTagline')}</p>
|
||||
</div>
|
||||
{featuredPledgesLoading && orderedFeaturedPledges.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<ActionSection
|
||||
items={visibleFeatured}
|
||||
total={orderedFeaturedPledges.length}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllFeatured}
|
||||
onToggle={() => setShowAllFeatured(!showAllFeatured)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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')
|
||||
: isSearching && sortMode === 'top'
|
||||
? t('common.sortTop')
|
||||
: isSearching && sortMode === 'new'
|
||||
? t('common.sortNew')
|
||||
: t('pledges.list.allPledges')}
|
||||
{t('pledges.list.myPledges')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isSearching && searchHits
|
||||
? t('common.searchResultsCount', { count: searchHits.length })
|
||||
: t('pledges.list.allPledgesTagline')}
|
||||
{t('pledges.list.myPledgesTagline')}
|
||||
</p>
|
||||
</div>
|
||||
{headerControls}
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<>
|
||||
{isSearchLoading && !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}
|
||||
btcPrice={btcPrice}
|
||||
showAuthor
|
||||
showTranslate
|
||||
topRight={
|
||||
<>
|
||||
<ModerationOverlay
|
||||
coord={getPledgeCoord(action)}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<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>
|
||||
) : allPledges.length > 0 ? (
|
||||
<ActionSection
|
||||
items={visibleAllPledges}
|
||||
total={allPledges.length}
|
||||
items={visibleMine}
|
||||
total={myPledges.length}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllPledges}
|
||||
onToggle={() => setShowAllPledges(!showAllPledges)}
|
||||
showAll={showAllMine}
|
||||
onToggle={() => setShowAllMine(!showAllMine)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
) : (
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center space-y-4">
|
||||
<HandHeart className="size-10 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t('pledges.list.emptyTitle')}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{selectedCountry
|
||||
? t('pledges.list.emptyHintCountry', { country: selectedCountryName })
|
||||
: t('pledges.list.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
{user && (
|
||||
<Button onClick={() => navigate(createActionHref)}>
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
{t('pledges.list.createPledge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<PledgesDiscoverySection
|
||||
filterPersistence="url"
|
||||
showHidden={
|
||||
isMod
|
||||
? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenPledges.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
@@ -575,7 +166,9 @@ export default function ActionsPage() {
|
||||
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} />)}
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<PledgeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -597,7 +190,10 @@ export default function ActionsPage() {
|
||||
showMenu={false}
|
||||
className="flex items-center"
|
||||
/>
|
||||
<ActionShareMenu action={action} displayTitle={action.title} />
|
||||
<ActionShareMenu
|
||||
action={action}
|
||||
displayTitle={action.title}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -638,10 +234,10 @@ interface ActionsHeroProps {
|
||||
/**
|
||||
* Photo-led hero for the Pledges index. Same structural recipe as the
|
||||
* Organize hero (rotating banner + atmospheric tint + scrims + overlay
|
||||
* copy + glassy CTA), but tuned for the pledge page's "dawn / golden hour" vibe:
|
||||
* uses {@link HOPE_PALETTE} instead of the cool palette so the warm
|
||||
* hues land on top of the protest photography rather than competing
|
||||
* with it.
|
||||
* copy + glassy CTA), but tuned for the pledge page's "dawn / golden
|
||||
* hour" vibe: uses {@link HOPE_PALETTE} instead of the cool palette
|
||||
* so the warm hues land on top of the protest photography rather
|
||||
* than competing with it.
|
||||
*/
|
||||
function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -707,7 +303,10 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Megaphone className="size-5 text-amber-200 shrink-0 drop-shadow" aria-hidden />
|
||||
<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>
|
||||
@@ -733,7 +332,11 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
)}
|
||||
aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')}
|
||||
aria-label={
|
||||
canCreate
|
||||
? t('pledges.list.createPledge')
|
||||
: t('pledges.list.loginToCreate')
|
||||
}
|
||||
>
|
||||
<PlusCircle className="mr-2" />
|
||||
{t('pledges.list.createPledge')}
|
||||
@@ -745,9 +348,19 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp
|
||||
}
|
||||
|
||||
function ActionSection({
|
||||
items, total, visible, showAll, onToggle, btcPrice,
|
||||
items,
|
||||
total,
|
||||
visible,
|
||||
showAll,
|
||||
onToggle,
|
||||
btcPrice,
|
||||
}: {
|
||||
items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; btcPrice: number | undefined;
|
||||
items: Action[];
|
||||
total: number;
|
||||
visible: number;
|
||||
showAll: boolean;
|
||||
onToggle: () => void;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
|
||||
+78
-277
@@ -5,193 +5,100 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronUp, EyeOff, HandHeart, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { ModeratorCollapsibleSection } from '@/components/moderation';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
|
||||
import { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { parseSort } from '@/hooks/useDiscoveryFilters';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
/** 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`).
|
||||
* Lists every campaign found on relays.
|
||||
*
|
||||
* 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:
|
||||
* The page itself is a thin shell: hero, optional "Your campaigns"
|
||||
* shelf, the shared {@link CampaignsDiscoverySection} (which owns
|
||||
* search / sort / country + idle / active grids), and a
|
||||
* moderator-only Hidden collapsible.
|
||||
*
|
||||
* - **Top** (default): ranked by total sats raised (kind 8333 donation receipts).
|
||||
* - **New**: chronological by `created_at`.
|
||||
*
|
||||
* Both modes share a free-text search bar that filters across title,
|
||||
* summary, story, location, and category tags client-side.
|
||||
*
|
||||
* Hidden campaigns are excluded by default — flip the "Show hidden"
|
||||
* 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.
|
||||
* URL state (`?q=&sort=&country=`) lives inside the section's
|
||||
* `useDiscoveryFilters` hook so search results stay shareable. The
|
||||
* page reads the same params independently to compute the Hidden
|
||||
* collapsible's contents — TanStack Query dedupes the underlying
|
||||
* `useAllCampaigns` call, so there's no extra network round-trip.
|
||||
*/
|
||||
export function AllCampaignsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// URL state — sort, query, and country live in the URL so results are
|
||||
// shareable.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchParams] = 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.
|
||||
const [searchInput, setSearchInput] = useState(urlQuery);
|
||||
const debouncedSearch = useDebounce(searchInput, 300);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
// Sync the debounced search → URL. Empty / default values are stripped
|
||||
// so the canonical URL is `/campaigns/all` (not
|
||||
// `/campaigns/all?sort=none&q=`).
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const trimmed = debouncedSearch.trim();
|
||||
if (trimmed) next.set('q', trimmed);
|
||||
else next.delete('q');
|
||||
// Only replace history when the params actually change, to avoid
|
||||
// looping when the URL is already in sync.
|
||||
if (next.toString() !== searchParams.toString()) {
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
}, [debouncedSearch, searchParams, setSearchParams]);
|
||||
|
||||
// Sync URL → input (e.g. browser back/forward or a deep link).
|
||||
useEffect(() => {
|
||||
if (urlQuery !== debouncedSearch) {
|
||||
setSearchInput(urlQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlQuery]);
|
||||
|
||||
const setSortFromToolbar = (value: Nip50Sort) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
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(),
|
||||
// Mirror the section's underlying query so the Hidden collapsible
|
||||
// can list the exact set of hidden items matching the current
|
||||
// search / sort / country. TanStack dedupes; this is a cache read
|
||||
// on the same key the section uses.
|
||||
const { data: campaigns } = useAllCampaigns({
|
||||
sort: toQuerySort(sort),
|
||||
search: urlQuery,
|
||||
countryCode: urlCountry,
|
||||
limit: 200,
|
||||
});
|
||||
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
|
||||
const { data: myCampaigns, isLoading: myCampaignsLoading } = useCampaigns({
|
||||
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
|
||||
const { hiddenCount, hiddenCampaigns } = useMemo(() => {
|
||||
const all = campaigns ?? [];
|
||||
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
|
||||
let count = 0;
|
||||
const list: ParsedCampaign[] = [];
|
||||
for (const c of all) {
|
||||
if (hiddenCoords.has(c.aTag)) {
|
||||
count += 1;
|
||||
list.push(c);
|
||||
}
|
||||
}
|
||||
return { hiddenCount: count, hiddenCampaigns: list };
|
||||
}, [campaigns, moderation]);
|
||||
|
||||
const { data: myCampaigns } = useCampaigns({
|
||||
authors: user ? [user.pubkey] : undefined,
|
||||
limit: 100,
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const featuredCoords = useMemo(() => {
|
||||
if (!moderationReady) return [] as string[];
|
||||
return Array.from(moderation.featuredCoords)
|
||||
.filter((coord) => !moderation.hiddenCoords.has(coord))
|
||||
.sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0));
|
||||
}, [moderation, moderationReady]);
|
||||
|
||||
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns({
|
||||
coordinates: featuredCoords,
|
||||
limit: featuredCoords.length || 1,
|
||||
enabled: moderationReady,
|
||||
});
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('campaigns.all.seoTitle')} | ${config.appName}`,
|
||||
description: t('campaigns.all.description'),
|
||||
});
|
||||
|
||||
const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => {
|
||||
const all = campaigns ?? [];
|
||||
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
|
||||
const featuredCoordSet = new Set(featuredCoords);
|
||||
let hiddenCount = 0;
|
||||
const visible: ParsedCampaign[] = [];
|
||||
const hiddenCampaigns: ParsedCampaign[] = [];
|
||||
|
||||
for (const c of all) {
|
||||
if (hiddenCoords.has(c.aTag)) {
|
||||
hiddenCount += 1;
|
||||
hiddenCampaigns.push(c);
|
||||
if (isMod && showHidden) visible.push(c);
|
||||
} else if (!featuredCoordSet.has(c.aTag)) {
|
||||
visible.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return { visible, hiddenCount, hiddenCampaigns };
|
||||
}, [campaigns, featuredCoords, isMod, moderation, showHidden]);
|
||||
|
||||
const orderedFeaturedCampaigns = useMemo(() => {
|
||||
if (!featuredCampaigns) return [] as ParsedCampaign[];
|
||||
return [...featuredCampaigns].sort(
|
||||
(a, b) => (moderation.featuredOrder.get(b.aTag) ?? 0) - (moderation.featuredOrder.get(a.aTag) ?? 0),
|
||||
);
|
||||
}, [featuredCampaigns, moderation]);
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
const [showAllMine, setShowAllMine] = useState(false);
|
||||
const [showAllFeatured, setShowAllFeatured] = useState(false);
|
||||
const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
const visibleFeatured = showAllFeatured ? orderedFeaturedCampaigns : orderedFeaturedCampaigns.slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
const showSkeleton = isLoading || !moderationReady;
|
||||
const activeQuery = debouncedSearch.trim();
|
||||
const totalCampaigns = campaigns?.length ?? 0;
|
||||
const visibleMine = showAllMine
|
||||
? (myCampaigns ?? [])
|
||||
: (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<AllCampaignsHero campaignCount={totalCampaigns} />
|
||||
<AllCampaignsHero campaignCount={campaigns?.length ?? 0} />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-8">
|
||||
{user && (myCampaignsLoading || (myCampaigns && myCampaigns.length > 0)) && (
|
||||
{user && myCampaigns && myCampaigns.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
@@ -201,156 +108,48 @@ export function AllCampaignsPage() {
|
||||
{t('campaigns.home.yourCampaignsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{myCampaignsLoading && !myCampaigns ? (
|
||||
<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) => <CampaignCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<CampaignSection
|
||||
campaigns={visibleMine}
|
||||
total={myCampaigns?.length ?? 0}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllMine}
|
||||
onToggle={() => setShowAllMine(!showAllMine)}
|
||||
/>
|
||||
)}
|
||||
<CampaignSection
|
||||
campaigns={visibleMine}
|
||||
total={myCampaigns.length}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllMine}
|
||||
onToggle={() => setShowAllMine(!showAllMine)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(featuredLoading || orderedFeaturedCampaigns.length > 0) && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
{t('campaigns.home.featured')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('campaigns.home.featuredDesc', { appName: config.appName })}
|
||||
</p>
|
||||
</div>
|
||||
{featuredLoading && orderedFeaturedCampaigns.length === 0 ? (
|
||||
<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) => <CampaignCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<CampaignSection
|
||||
campaigns={visibleFeatured}
|
||||
total={orderedFeaturedCampaigns.length}
|
||||
visible={DEFAULT_VISIBLE}
|
||||
showAll={showAllFeatured}
|
||||
onToggle={() => setShowAllFeatured(!showAllFeatured)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<CampaignsDiscoverySection
|
||||
filterPersistence="url"
|
||||
showHidden={
|
||||
isMod
|
||||
? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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')
|
||||
: t('campaigns.all.title')}
|
||||
</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={isMod ? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenCount,
|
||||
} : undefined}
|
||||
country={urlCountry}
|
||||
onCountryChange={setCountry}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Grid — widens to 3 columns at lg and 4 at xl so desktop users
|
||||
can scan more campaigns at once, matching the Pledge index's
|
||||
card density. Mobile and small tablets stay single / double
|
||||
column so the cards keep their tappable size. */}
|
||||
{showSkeleton ? (
|
||||
<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) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center space-y-4">
|
||||
<HandHeart className="size-10 text-muted-foreground mx-auto" />
|
||||
<div className="space-y-1.5">
|
||||
{activeQuery ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t('campaigns.all.noMatch', { query: activeQuery })}
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.noMatchHint')}
|
||||
</p>
|
||||
</>
|
||||
) : hiddenCount > 0 && !showHidden ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold">{t('campaigns.all.allHidden')}</h2>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.allHiddenHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold">{t('campaigns.all.empty')}</h2>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.all.emptyHint')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
{t('campaigns.all.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{visible.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Moderator-only: every hidden campaign on the network. Mirrors
|
||||
the section on `/campaigns` so moderators see the same
|
||||
"Hidden" affordance whether they're browsing the curated
|
||||
home or the full index. */}
|
||||
{/* Moderator-only: every hidden campaign on the network matching
|
||||
the current section filters. The section drops hidden items
|
||||
from its main grid unless the toolbar's Show-hidden switch
|
||||
is on; this collapsible always exposes them so a moderator
|
||||
can act on hidden coords without flipping the visibility
|
||||
mode. */}
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
icon={<EyeOff className="size-4" />}
|
||||
title={t('campaigns.home.hidden')}
|
||||
description={t('campaigns.home.hiddenDesc')}
|
||||
count={hiddenCampaigns.length}
|
||||
isLoading={showSkeleton}
|
||||
isLoading={!moderation}
|
||||
emptyText={t('campaigns.home.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) => <CampaignCardSkeleton key={i} />)}
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -361,7 +160,6 @@ export function AllCampaignsPage() {
|
||||
</div>
|
||||
</ModeratorCollapsibleSection>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -491,7 +289,10 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) {
|
||||
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 />
|
||||
<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>
|
||||
|
||||
+143
-437
@@ -1,471 +1,177 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
|
||||
import { ArrowRight, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { HeroLightningMap } from '@/components/HeroLightningMap';
|
||||
import { ModeratorCollapsibleSection } from '@/components/moderation';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
|
||||
import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
|
||||
import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
/** Cap on how many featured campaigns we render in the home-page row. */
|
||||
const MAX_FEATURED = 4;
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
/**
|
||||
* Home page (`/`).
|
||||
*
|
||||
* Hero on top, then the three discovery sections back-to-back —
|
||||
* Campaigns, Groups, Pledges — each with the same title, tagline,
|
||||
* and search/sort/country toolbar as its dedicated page. Filter
|
||||
* state is purely local here (`filterPersistence="local"`):
|
||||
* persisting three sets of `?q=&sort=&country=` would either
|
||||
* collide (three sections want `?q=`) or pollute the URL with
|
||||
* prefixed variants on every keystroke. Refreshing `/` always
|
||||
* lands on the curated idle view, which matches what we want
|
||||
* anyway. Users who want shareable / persistent filters go to
|
||||
* `/campaigns/all`, `/groups`, or `/pledges`.
|
||||
*
|
||||
* The home page intentionally omits the moderator-only Hidden
|
||||
* collapsibles and per-viewer "My X" shelves — those live on the
|
||||
* dedicated pages so the home stays scannable on every visit.
|
||||
*/
|
||||
export function CampaignsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Moderator pack + per-campaign label state. The label query is gated on
|
||||
// moderators arriving, so during a cold load we render skeleton cards
|
||||
// until both resolve. Avoids flashing the full unmoderated grid.
|
||||
const { data: moderators, isLoading: moderatorsLoading } = useCampaignModerators();
|
||||
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
// Featured slot list — derived from moderation labels. Sorted newest-
|
||||
// featured first, capped at MAX_FEATURED, and hidden coords removed so a
|
||||
// featured-then-hidden campaign disappears from the row.
|
||||
const featuredCoords = useMemo(() => {
|
||||
if (!moderation) return [] as string[];
|
||||
return Array.from(moderation.featuredCoords)
|
||||
.filter((c) => !moderation.hiddenCoords.has(c))
|
||||
.sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0))
|
||||
.slice(0, MAX_FEATURED);
|
||||
}, [moderation]);
|
||||
|
||||
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns(
|
||||
moderationReady && featuredCoords.length > 0
|
||||
? { coordinates: featuredCoords, limit: MAX_FEATURED }
|
||||
: { coordinates: [], limit: MAX_FEATURED },
|
||||
);
|
||||
|
||||
// Sort the fetched featured campaigns to match the newest-label order.
|
||||
// `useCampaigns` returns them in network order; we want the row to match
|
||||
// the moderation-label ordering.
|
||||
const orderedFeatured = useMemo<ParsedCampaign[]>(() => {
|
||||
if (!moderation || !featuredCampaigns) return [];
|
||||
const order = moderation.featuredOrder;
|
||||
return [...featuredCampaigns]
|
||||
.filter((c) => featuredCoords.includes(c.aTag))
|
||||
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0))
|
||||
.slice(0, MAX_FEATURED);
|
||||
}, [featuredCampaigns, featuredCoords, moderation]);
|
||||
|
||||
const featuredCoordSet = useMemo(() => new Set(featuredCoords), [featuredCoords]);
|
||||
|
||||
// The community grid is the approved-and-not-hidden set, minus featured
|
||||
// (which gets its own row above). We fetch by coordinate (one filter per
|
||||
// author, bundled in one REQ) to avoid pulling the entire kind-30223
|
||||
// stream when only a handful are surfaced.
|
||||
const approvedNotHidden = useMemo(() => {
|
||||
if (!moderation) return [] as string[];
|
||||
return Array.from(moderation.approvedCoords).filter((c) => !moderation.hiddenCoords.has(c));
|
||||
}, [moderation]);
|
||||
|
||||
// Pass `coordinates: []` only once moderation is ready and the allowlist is
|
||||
// empty; before that, pass `undefined` so the query is enabled but doesn't
|
||||
// discriminate. We block render of the grid on `moderationReady` anyway.
|
||||
const { data: approvedCampaigns, isLoading: approvedLoading } = useCampaigns(
|
||||
moderationReady
|
||||
? { coordinates: approvedNotHidden, limit: 60 }
|
||||
: { limit: 60 },
|
||||
);
|
||||
|
||||
// For moderators we also pull the *entire* recent kind-30223 stream so we
|
||||
// can populate the Pending and Hidden sections. This second query only
|
||||
// runs for mods and reuses TanStack's cache on identical keys.
|
||||
const { data: allCampaignsForMods, isLoading: allLoading } = useCampaigns({
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
// For non-mod creators: their own campaigns regardless of moderation state,
|
||||
// so the "Your campaigns" shelf can explain why theirs aren't on the home
|
||||
// page. Skip the query entirely for mods and logged-out viewers.
|
||||
const { data: ownCampaigns } = useCampaigns({
|
||||
authors: user && !isMod ? [user.pubkey] : undefined,
|
||||
limit: 30,
|
||||
});
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('campaigns.home.seoTitle')} | ${config.appName}`,
|
||||
description: t('campaigns.home.seoDescription'),
|
||||
});
|
||||
|
||||
// Main grid excludes featured (they're shown above) and excludes any
|
||||
// hidden coord just in case approvedCoords/hiddenCoords overlap (a mod can
|
||||
// approve, another can hide — hide wins).
|
||||
const mainGridCampaigns = useMemo(
|
||||
() =>
|
||||
(approvedCampaigns ?? []).filter(
|
||||
(c) => !featuredCoordSet.has(c.aTag) && !moderation?.hiddenCoords.has(c.aTag),
|
||||
),
|
||||
[approvedCampaigns, featuredCoordSet, moderation],
|
||||
);
|
||||
|
||||
// Pending (mod-only): campaigns that exist on the network but lack an
|
||||
// approval AND aren't hidden.
|
||||
const pendingCampaigns = useMemo(() => {
|
||||
if (!isMod || !moderation) return [] as ParsedCampaign[];
|
||||
return (allCampaignsForMods ?? []).filter(
|
||||
(c) => !moderation.approvedCoords.has(c.aTag) && !moderation.hiddenCoords.has(c.aTag),
|
||||
);
|
||||
}, [isMod, moderation, allCampaignsForMods]);
|
||||
|
||||
// Hidden (mod-only): campaigns where the latest hide-axis label is `hidden`.
|
||||
const hiddenCampaigns = useMemo(() => {
|
||||
if (!isMod || !moderation) return [] as ParsedCampaign[];
|
||||
return (allCampaignsForMods ?? []).filter((c) => moderation.hiddenCoords.has(c.aTag));
|
||||
}, [isMod, moderation, allCampaignsForMods]);
|
||||
|
||||
// "Your campaigns" (non-mod creators only): the logged-in user's own
|
||||
// campaigns that aren't yet surfaced — i.e. not approved, or hidden.
|
||||
// We exclude already-approved ones so we don't double-render the same
|
||||
// card in two sections; if their own campaign is in the main grid they
|
||||
// already know it's live.
|
||||
const yourPendingCampaigns = useMemo(() => {
|
||||
if (isMod || !user || !moderation) return [] as ParsedCampaign[];
|
||||
return (ownCampaigns ?? []).filter(
|
||||
(c) => !moderation.approvedCoords.has(c.aTag) || moderation.hiddenCoords.has(c.aTag),
|
||||
);
|
||||
}, [isMod, user, moderation, ownCampaigns]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
{/* Hero.
|
||||
<Hero loggedIn={!!user} />
|
||||
|
||||
Dark, brand-driven, type-led. Three layers:
|
||||
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
|
||||
every other element sits on. No campaign photo, no random
|
||||
hue cycling: the hero looks the same on every visit, so
|
||||
quality doesn't depend on which campaign is featured.
|
||||
2. HeroLightningMap — decorative dark world map with curated
|
||||
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
|
||||
negligible render cost, animations honor reduced-motion.
|
||||
3. Headline column on the left, lifted by a left-edge gradient
|
||||
inside HeroLightningMap so type stays readable without any
|
||||
text-shadow at all. */}
|
||||
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
|
||||
<HeroLightningMap />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h1
|
||||
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
|
||||
style={{
|
||||
// Bebas Neue only ships at weight 400. Paint a stroke the
|
||||
// same color as the fill to fatten the letterforms without
|
||||
// the fuzz a synthetic-bold transform would produce.
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="campaigns.home.heroTagline"
|
||||
components={[
|
||||
// Index 0: solid brand-orange highlighter block.
|
||||
// i18next injects the matched translation segment
|
||||
// (the text between <0>...</0>) as this span's
|
||||
// `children`, so the word renders *inside* the
|
||||
// orange background.
|
||||
//
|
||||
// Padding: `ps-0 pe-3` keeps the first letter flush
|
||||
// with the box's start edge while the box extends
|
||||
// past the trailing edge as a deliberate visual
|
||||
// flourish. Logical properties so RTL languages
|
||||
// (ar, fa, ps) flip automatically.
|
||||
//
|
||||
// `text-indent: -0.06em` compensates for Bebas
|
||||
// Neue's italic skew, which shifts the visible
|
||||
// left edge of "U" rightward of its geometric box
|
||||
// — without the nudge there's a visible gap
|
||||
// between the orange block's left edge and the
|
||||
// letter. The shift is small enough that other
|
||||
// scripts (Arabic, Khmer, Chinese) tolerate it.
|
||||
//
|
||||
// NOTE: `components` MUST be an array, not an
|
||||
// object keyed by `{0: ..., 1: ...}`. The object
|
||||
// form silently drops the indexed tags in this
|
||||
// react-i18next version, rendering the text
|
||||
// without any wrapping element.
|
||||
<span
|
||||
key="hl"
|
||||
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
|
||||
style={{ textIndent: '-0.06em' }}
|
||||
/>,
|
||||
// Index 1: line break. English wants the
|
||||
// highlighted word on its own line as a standalone
|
||||
// block. Translations that prefer inline flow
|
||||
// simply omit `<1></1>` from their string.
|
||||
<br key="br" />,
|
||||
]}
|
||||
/>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/80 max-w-xl">
|
||||
{t('campaigns.home.heroBody')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
{/* Primary CTA — solid brand-orange pill. The dark hero gives
|
||||
the brand color the spotlight without competing with it. */}
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
{t('campaigns.home.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50 [&_svg]:size-[18px]"
|
||||
>
|
||||
<Link to="/about">
|
||||
{t('campaigns.home.howItWorks')}
|
||||
<ArrowRight className="ml-2 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
{!user && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
|
||||
>
|
||||
<a href="#campaigns">{t('campaigns.home.exploreCampaigns')}</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
|
||||
{/* Featured — only rendered when at least one campaign is featured
|
||||
(or the featured query is still loading on first paint). */}
|
||||
{(featuredCoords.length > 0 || (featuredLoading && !moderationReady)) && (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.featured')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('campaigns.home.featuredDesc', { appName: config.appName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeaturedRow
|
||||
campaigns={orderedFeatured}
|
||||
isLoading={featuredLoading || !moderationReady}
|
||||
expectedCount={featuredCoords.length}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Community Campaigns — approved-and-not-hidden, minus featured.
|
||||
Skeletons until the moderator pack + label state both resolve,
|
||||
so we never flash an unmoderated grid. */}
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{t('campaigns.home.community')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('campaigns.home.communityDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="hidden sm:inline-flex">
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
{t('campaigns.home.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{moderatorsLoading || !moderationReady || approvedLoading ? (
|
||||
<CampaignGridSkeleton />
|
||||
) : mainGridCampaigns.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{mainGridCampaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Browse all campaigns" link — reveals the page that includes
|
||||
campaigns not yet moderated (and, optionally, hidden ones). */}
|
||||
<div className="pt-2 text-center sm:text-left">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/campaigns/all">{t('campaigns.home.browseAll')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Moderator-only: campaigns awaiting an approval decision. */}
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
icon={<Hourglass className="size-4" />}
|
||||
title={t('campaigns.home.pending')}
|
||||
description={t('campaigns.home.pendingDesc')}
|
||||
count={pendingCampaigns.length}
|
||||
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 && (
|
||||
<ModeratorCollapsibleSection
|
||||
icon={<EyeOff className="size-4" />}
|
||||
title={t('campaigns.home.hidden')}
|
||||
description={t('campaigns.home.hiddenDesc')}
|
||||
count={hiddenCampaigns.length}
|
||||
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
|
||||
so they understand the campaign is live on the network but
|
||||
isn't on the homepage yet. */}
|
||||
{!isMod && user && yourPendingCampaigns.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight inline-flex items-center gap-2">
|
||||
<ShieldCheck className="size-6 text-primary" />
|
||||
{t('campaigns.home.yourCampaigns')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
||||
{t('campaigns.home.yourCampaignsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{yourPendingCampaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div
|
||||
className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12"
|
||||
id="discover"
|
||||
>
|
||||
<CampaignsDiscoverySection filterPersistence="local" />
|
||||
<GroupsDiscoverySection filterPersistence="local" />
|
||||
<PledgesDiscoverySection filterPersistence="local" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the grid class string for an adaptive featured row.
|
||||
* Mobile stays 1-column; desktop expands to 2/3/4 columns based on count.
|
||||
* Tailwind JIT requires literal class strings, so we spell each variant
|
||||
* out rather than building the class name dynamically.
|
||||
*/
|
||||
function featuredGridClass(n: number): string {
|
||||
if (n <= 1) return 'grid grid-cols-1 gap-5';
|
||||
if (n === 2) return 'grid grid-cols-1 md:grid-cols-2 gap-5';
|
||||
if (n === 3) return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5';
|
||||
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5';
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Hero
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Renders the featured row with an adaptive column count. */
|
||||
function FeaturedRow({
|
||||
campaigns,
|
||||
isLoading,
|
||||
expectedCount,
|
||||
}: {
|
||||
campaigns: ParsedCampaign[];
|
||||
isLoading: boolean;
|
||||
/** How many featured slots we expect once data resolves. Drives the skeleton column count. */
|
||||
expectedCount: number;
|
||||
}) {
|
||||
if (isLoading && campaigns.length === 0) {
|
||||
const skeletonCount = Math.max(1, Math.min(MAX_FEATURED, expectedCount || 2));
|
||||
return (
|
||||
<div className={featuredGridClass(skeletonCount)}>
|
||||
{Array.from({ length: skeletonCount }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} variant={skeletonCount === 1 ? 'featured' : 'compact'} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (campaigns.length === 0) {
|
||||
// Defensive — the parent guards on `featuredCoords.length > 0`, but if
|
||||
// a hidden-after-featured race leaves us with no campaigns to render,
|
||||
// collapse silently rather than show an empty card.
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1 featured campaign gets the hero `variant="featured"` treatment;
|
||||
// 2-4 use the regular compact card sized to the dynamic grid.
|
||||
const useFeaturedVariant = campaigns.length === 1;
|
||||
|
||||
return (
|
||||
<div className={featuredGridClass(campaigns.length)}>
|
||||
{campaigns.map((campaign) => (
|
||||
<CampaignCard
|
||||
key={campaign.aTag}
|
||||
campaign={campaign}
|
||||
variant={useFeaturedVariant ? 'featured' : 'compact'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampaignGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
function Hero({ loggedIn }: { loggedIn: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-12 px-8 text-center space-y-4">
|
||||
<HandHeart className="size-10 text-muted-foreground mx-auto" />
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">{t('campaigns.home.empty')}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{t('campaigns.home.emptyHint', { appName: config.appName })}
|
||||
/* Hero.
|
||||
|
||||
Dark, brand-driven, type-led. Three layers:
|
||||
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
|
||||
every other element sits on. No campaign photo, no random
|
||||
hue cycling: the hero looks the same on every visit, so
|
||||
quality doesn't depend on which campaign is featured.
|
||||
2. HeroLightningMap — decorative dark world map with curated
|
||||
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
|
||||
negligible render cost, animations honor reduced-motion.
|
||||
3. Headline column on the left, lifted by a left-edge gradient
|
||||
inside HeroLightningMap so type stays readable without any
|
||||
text-shadow at all. */
|
||||
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
|
||||
<HeroLightningMap />
|
||||
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<h1
|
||||
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
|
||||
style={{
|
||||
// Bebas Neue only ships at weight 400. Paint a stroke the
|
||||
// same color as the fill to fatten the letterforms without
|
||||
// the fuzz a synthetic-bold transform would produce.
|
||||
WebkitTextStroke: '0.022em currentColor',
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="campaigns.home.heroTagline"
|
||||
components={[
|
||||
// Index 0: solid brand-orange highlighter block.
|
||||
// i18next injects the matched translation segment
|
||||
// (the text between <0>...</0>) as this span's
|
||||
// `children`, so the word renders *inside* the
|
||||
// orange background.
|
||||
//
|
||||
// Padding: `ps-0 pe-3` keeps the first letter flush
|
||||
// with the box's start edge while the box extends
|
||||
// past the trailing edge as a deliberate visual
|
||||
// flourish. Logical properties so RTL languages
|
||||
// (ar, fa, ps) flip automatically.
|
||||
//
|
||||
// `text-indent: -0.06em` compensates for Bebas
|
||||
// Neue's italic skew, which shifts the visible
|
||||
// left edge of "U" rightward of its geometric box
|
||||
// — without the nudge there's a visible gap
|
||||
// between the orange block's left edge and the
|
||||
// letter. The shift is small enough that other
|
||||
// scripts (Arabic, Khmer, Chinese) tolerate it.
|
||||
//
|
||||
// NOTE: `components` MUST be an array, not an
|
||||
// object keyed by `{0: ..., 1: ...}`. The object
|
||||
// form silently drops the indexed tags in this
|
||||
// react-i18next version, rendering the text
|
||||
// without any wrapping element.
|
||||
<span
|
||||
key="hl"
|
||||
className="inline-block w-fit ps-0 pe-3 bg-primary text-white leading-[0.95] align-baseline"
|
||||
style={{ textIndent: '-0.06em' }}
|
||||
/>,
|
||||
// Index 1: line break. English wants the
|
||||
// highlighted word on its own line as a standalone
|
||||
// block. Translations that prefer inline flow
|
||||
// simply omit `<1></1>` from their string.
|
||||
<br key="br" />,
|
||||
]}
|
||||
/>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/80 max-w-xl">
|
||||
{t('campaigns.home.heroBody')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
{/* Primary CTA — solid brand-orange pill. The dark hero gives
|
||||
the brand color the spotlight without competing with it. */}
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
{t('campaigns.home.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50 [&_svg]:size-[18px]"
|
||||
>
|
||||
<Link to="/about">
|
||||
{t('campaigns.home.howItWorks')}
|
||||
<ArrowRight className="ml-2 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
{!loggedIn && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
|
||||
>
|
||||
<a href="#discover">{t('campaigns.home.exploreCampaigns')}</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
{t('campaigns.home.startCampaign')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+91
-301
@@ -7,32 +7,41 @@ import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, PlusCircle, Users }
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
|
||||
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import {
|
||||
CommunityMiniCard,
|
||||
CommunityMiniCardSkeleton,
|
||||
} from '@/components/discovery/CommunityMiniCard';
|
||||
import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
|
||||
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 { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────────
|
||||
import type { ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
/**
|
||||
* Dedicated `/groups` page.
|
||||
*
|
||||
* Thin shell around the shared {@link GroupsDiscoverySection}: hero,
|
||||
* optional "My groups" shelf, the unified search-and-discover
|
||||
* section, and a moderator-only Hidden collapsible.
|
||||
*
|
||||
* URL state (`?q=&sort=`) lives inside the section's
|
||||
* `useDiscoveryFilters` hook so search results stay shareable. The
|
||||
* page only owns the Show-hidden flag and the moderator-only data
|
||||
* needed for the Hidden collapsible.
|
||||
*/
|
||||
export function CommunitiesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
@@ -63,198 +72,46 @@ 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];
|
||||
},
|
||||
|
||||
// Moderator-only: fetch the full kind-34550 universe so we can list
|
||||
// hidden groups and surface a hidden-count badge on the toolbar.
|
||||
// Non-moderators don't need this query — the section drives the
|
||||
// public idle/active grids straight from featured + search.
|
||||
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({
|
||||
limit: 200,
|
||||
enabled: isMod,
|
||||
});
|
||||
|
||||
// Lift org moderation to the page so search results can drop hidden
|
||||
// groups (or include them when the Show-hidden switch is on). The
|
||||
// Hidden ModeratorCollapsibleSection below derives its data from the
|
||||
// same `allOrgs` fetch, so no additional query round-trip is needed.
|
||||
const { data: orgModeration } = useOrganizationModeration();
|
||||
const { searchHits, searchHiddenCount } = useMemo(() => {
|
||||
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
|
||||
const { hiddenGroups, hiddenCount } = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({ limit: 200 });
|
||||
const { allGroups, allHiddenCount, hiddenGroups } = useMemo(() => {
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
const featuredCoords = orgModeration?.featuredCoords ?? new Set<string>();
|
||||
let hidden = 0;
|
||||
const visible: ParsedCommunity[] = [];
|
||||
const hiddenList: ParsedCommunity[] = [];
|
||||
const list: ParsedCommunity[] = [];
|
||||
for (const org of allOrgs ?? []) {
|
||||
if (hiddenCoords.has(org.aTag)) {
|
||||
hidden += 1;
|
||||
hiddenList.push(org);
|
||||
if (isMod && showHidden) visible.push(org);
|
||||
} else if (hasAgoraTag(org.tags) && !featuredCoords.has(org.aTag)) {
|
||||
visible.push(org);
|
||||
}
|
||||
if (hiddenCoords.has(org.aTag)) list.push(org);
|
||||
}
|
||||
return { allGroups: visible, allHiddenCount: hidden, hiddenGroups: hiddenList };
|
||||
}, [allOrgs, isMod, orgModeration, showHidden]);
|
||||
|
||||
// Search + sort + show-hidden cluster for the All section.
|
||||
const searchToolbar = (
|
||||
<DiscoverySearchToolbar
|
||||
query={searchInput}
|
||||
onQueryChange={setSearchInput}
|
||||
sort={sortMode}
|
||||
onSortChange={setSortMode}
|
||||
searchPlaceholderKey="groups.list.searchPlaceholder"
|
||||
searchAriaLabelKey="groups.list.searchAriaLabel"
|
||||
showHidden={isMod ? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: isSearching ? searchHiddenCount : allHiddenCount,
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
return { hiddenGroups: list, hiddenCount: list.length };
|
||||
}, [allOrgs, orgModeration]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<CommunitiesHero onCreateCommunity={handleCreateCommunity} />
|
||||
|
||||
<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">
|
||||
<MyCommunitiesShelf
|
||||
userOrganizations={userOrganizations}
|
||||
<MyCommunitiesShelf userOrganizations={userOrganizations} />
|
||||
|
||||
<GroupsDiscoverySection
|
||||
filterPersistence="url"
|
||||
showHidden={
|
||||
isMod
|
||||
? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FeaturedOrganizationsShelf />
|
||||
|
||||
<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')
|
||||
: isSearching && sortMode === 'top'
|
||||
? t('common.sortTop')
|
||||
: isSearching && sortMode === 'new'
|
||||
? t('common.sortNew')
|
||||
: t('groups.list.allGroups')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isSearching && searchHits
|
||||
? t('common.searchResultsCount', { count: searchHits.length })
|
||||
: t('groups.list.allGroupsTagline')}
|
||||
</p>
|
||||
</div>
|
||||
{searchToolbar}
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : allOrgsLoading ? (
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
) : allGroups.length > 0 ? (
|
||||
<CommunityGrid>
|
||||
{allGroups.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">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('groups.list.noFeaturedBody', { appName: config.appName })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
icon={<EyeOff className="size-4" />}
|
||||
@@ -460,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Community shelves
|
||||
// "My groups" shelf
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UserOrganizationsResult = ReturnType<typeof useUserOrganizations>;
|
||||
@@ -472,8 +329,23 @@ function MyCommunitiesShelf({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
// "My organizations" = orgs the user founded, moderates, or follows.
|
||||
// Sorting is founder first, moderator second, followed-only last,
|
||||
// with newest community definition revisions first inside each
|
||||
// bucket.
|
||||
const { data: organizations } = userOrganizations;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
// Suppress the entire section (header + tagline included) until at
|
||||
// least one group is known. Rendering the header while the query is
|
||||
// still pending causes a flash when the result resolves to an empty
|
||||
// list.
|
||||
if (!organizations || organizations.length === 0) return null;
|
||||
|
||||
const COLLAPSED_COUNT = 4;
|
||||
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
|
||||
const canExpand = organizations.length > COLLAPSED_COUNT;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
@@ -485,122 +357,40 @@ function MyCommunitiesShelf({
|
||||
{t('groups.list.myGroupsTagline')}
|
||||
</p>
|
||||
</div>
|
||||
<MyCommunitiesShelfContent userOrganizations={userOrganizations} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MyCommunitiesShelfContent({
|
||||
userOrganizations,
|
||||
}: {
|
||||
userOrganizations: UserOrganizationsResult;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// "My organizations" = orgs the user founded, moderates, or follows.
|
||||
// Sorting is founder first, moderator second, followed-only last, with
|
||||
// newest community definition revisions first inside each bucket.
|
||||
const { data: organizations, isLoading } = userOrganizations;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
);
|
||||
}
|
||||
|
||||
if (!organizations || organizations.length === 0) return null;
|
||||
|
||||
const COLLAPSED_COUNT = 4;
|
||||
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
|
||||
const canExpand = organizations.length > COLLAPSED_COUNT;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CommunityGrid>
|
||||
{visible.map((entry) => (
|
||||
<CommunityMiniCard
|
||||
key={entry.community.aTag}
|
||||
community={entry.community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</CommunityGrid>
|
||||
{canExpand && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded-full text-sm"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-4 mr-1.5" />
|
||||
{t('groups.list.showLess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-4 mr-1.5" />
|
||||
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedOrganizationsShelf() {
|
||||
const { data: featured, isLoading, isPending } = useFeaturedOrganizations();
|
||||
const hasFeatured = !!featured && featured.length > 0;
|
||||
|
||||
if ((isPending || isLoading) && !hasFeatured) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<FeaturedOrganizationsHeading />
|
||||
<div className="space-y-4">
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
{visible.map((entry) => (
|
||||
<CommunityMiniCard
|
||||
key={entry.community.aTag}
|
||||
community={entry.community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</CommunityGrid>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasFeatured) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<FeaturedOrganizationsHeading />
|
||||
<CommunityGrid>
|
||||
{featured.map((entry) => (
|
||||
<CommunityMiniCard
|
||||
key={entry.community.aTag}
|
||||
community={entry.community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</CommunityGrid>
|
||||
{canExpand && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded-full text-sm"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-4 mr-1.5" />
|
||||
{t('groups.list.showLess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-4 mr-1.5" />
|
||||
{t('groups.list.showMore', { count: organizations.length - COLLAPSED_COUNT })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedOrganizationsHeading() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user