Extract three reusable discovery sections shared by home and dedicated pages
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:
CampaignsDiscoverySection src/components/discovery/
GroupsDiscoverySection
PledgesDiscoverySection
Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:
filterPersistence='url' - flat ?q=&sort=&country= params. Used by
the dedicated pages so search results
are shareable and survive refresh.
filterPersistence='local' - local-only state. Used by / where three
sections coexist and can't all own ?q=.
Refreshing the home lands on the curated
idle view, which matches what we want.
The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.
Side effects:
- ActionShareMenu moves from inside ActionsPage to its own file
so PledgesDiscoverySection can render it on every card without
re-importing the page module.
- useDiscoverCommunities is unchanged but only the dedicated
/groups page calls it now (for the Hidden collapsible /
hidden-count badge). The home page never triggers it.
- browseAllGroups and browseAllPledges locale keys drop from all
16 locales since the launchpad layout that needed them no
longer exists.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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 type { Action } from '@/hooks/useActions';
|
||||
|
||||
function getPledgeCoord(action: Action) {
|
||||
return `36639:${action.pubkey}:${action.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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, type CampaignSort } 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 { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
/**
|
||||
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the
|
||||
* `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and
|
||||
* `'default'` both map to `'none'` (chronological) — the section
|
||||
* handles the "show featured only" framing on top of that.
|
||||
*/
|
||||
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
|
||||
|
||||
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,
|
||||
enabled: moderationReady,
|
||||
});
|
||||
|
||||
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,328 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ActionShareMenu } from '@/components/ActionShareMenu';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar';
|
||||
import { ModerationOverlay } from '@/components/moderation';
|
||||
import { PledgeCard } 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';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: rawActions, isLoading: actionsLoading } = useActions({
|
||||
countryCode: filters.country,
|
||||
limit: 300,
|
||||
});
|
||||
|
||||
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) => (
|
||||
<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(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) => (
|
||||
<ActionSkeleton 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -692,8 +692,6 @@
|
||||
"community": "حملات المجتمع",
|
||||
"communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.",
|
||||
"browseAll": "تصفّح كل الحملات ←",
|
||||
"browseAllGroups": "تصفّح كل المجموعات ←",
|
||||
"browseAllPledges": "تصفّح كل التعهدات ←",
|
||||
"pending": "بانتظار الموافقة",
|
||||
"pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.",
|
||||
"pendingEmpty": "لا يوجد شيء بانتظار المراجعة.",
|
||||
|
||||
@@ -1128,8 +1128,6 @@
|
||||
"community": "Community Campaigns",
|
||||
"communityDesc": "Help fund the changes worth making.",
|
||||
"browseAll": "Browse all campaigns →",
|
||||
"browseAllGroups": "Browse all groups →",
|
||||
"browseAllPledges": "Browse all pledges →",
|
||||
"searchPlaceholder": "Search campaigns\u2026",
|
||||
"searchAriaLabel": "Search campaigns",
|
||||
"noMatch": "No campaigns match \u201c{{query}}\u201d",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "Campañas de la comunidad",
|
||||
"communityDesc": "Ayuda a financiar los cambios que valen la pena.",
|
||||
"browseAll": "Ver todas las campañas →",
|
||||
"browseAllGroups": "Ver todos los grupos →",
|
||||
"browseAllPledges": "Ver todas las promesas →",
|
||||
"pending": "Pendientes de aprobación",
|
||||
"pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.",
|
||||
"pendingEmpty": "Nada pendiente de revisión.",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "کمپینهای جامعه",
|
||||
"communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.",
|
||||
"browseAll": "← مرور همه کمپینها",
|
||||
"browseAllGroups": "← مرور همه گروهها",
|
||||
"browseAllPledges": "← مرور همه تعهدها",
|
||||
"pending": "در انتظار تأیید",
|
||||
"pendingDesc": "کمپینهایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آنها را تأیید یا پنهان نکرده است.",
|
||||
"pendingEmpty": "چیزی برای بررسی نیست.",
|
||||
|
||||
@@ -1126,8 +1126,6 @@
|
||||
"community": "Campagnes communautaires",
|
||||
"communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.",
|
||||
"browseAll": "Parcourir toutes les campagnes →",
|
||||
"browseAllGroups": "Parcourir tous les groupes →",
|
||||
"browseAllPledges": "Parcourir toutes les promesses →",
|
||||
"pending": "En attente d'approbation",
|
||||
"pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.",
|
||||
"pendingEmpty": "Rien en attente d'examen.",
|
||||
|
||||
@@ -1136,8 +1136,6 @@
|
||||
"community": "कम्युनिटी कैंपेन",
|
||||
"communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।",
|
||||
"browseAll": "सभी कैंपेन देखें →",
|
||||
"browseAllGroups": "सभी ग्रुप देखें →",
|
||||
"browseAllPledges": "सभी प्लेज देखें →",
|
||||
"pending": "मंज़ूरी का इंतज़ार",
|
||||
"pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।",
|
||||
"pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।",
|
||||
|
||||
@@ -1136,8 +1136,6 @@
|
||||
"community": "Kampanye Komunitas",
|
||||
"communityDesc": "Bantu danai perubahan yang patut dilakukan.",
|
||||
"browseAll": "Telusuri semua kampanye →",
|
||||
"browseAllGroups": "Telusuri semua grup →",
|
||||
"browseAllPledges": "Telusuri semua ikrar →",
|
||||
"pending": "Menunggu persetujuan",
|
||||
"pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.",
|
||||
"pendingEmpty": "Tidak ada yang menunggu peninjauan.",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "យុទ្ធនាការសហគមន៍",
|
||||
"communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។",
|
||||
"browseAll": "មើលយុទ្ធនាការទាំងអស់ →",
|
||||
"browseAllGroups": "មើលក្រុមទាំងអស់ →",
|
||||
"browseAllPledges": "មើលការសន្យាទាំងអស់ →",
|
||||
"pending": "កំពុងរង់ចាំការអនុម័ត",
|
||||
"pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។",
|
||||
"pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "د ټولنې کمپاینونه",
|
||||
"communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.",
|
||||
"browseAll": "← ټول کمپاینونه وګورئ",
|
||||
"browseAllGroups": "← ټولې ډلې وګورئ",
|
||||
"browseAllPledges": "← ټولې ژمنې وګورئ",
|
||||
"pending": "د منلو په تمه",
|
||||
"pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.",
|
||||
"pendingEmpty": "د بیاکتنې لپاره څه نشته.",
|
||||
|
||||
@@ -1136,8 +1136,6 @@
|
||||
"community": "Campanhas da comunidade",
|
||||
"communityDesc": "Ajude a financiar as mudanças que valem a pena.",
|
||||
"browseAll": "Navegar por todas as campanhas →",
|
||||
"browseAllGroups": "Navegar por todos os grupos →",
|
||||
"browseAllPledges": "Navegar por todas as promessas →",
|
||||
"pending": "Aguardando aprovação",
|
||||
"pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.",
|
||||
"pendingEmpty": "Nada aguardando revisão.",
|
||||
|
||||
@@ -1136,8 +1136,6 @@
|
||||
"community": "Кампании сообщества",
|
||||
"communityDesc": "Помогите финансировать изменения, которые стоит сделать.",
|
||||
"browseAll": "Просмотреть все кампании →",
|
||||
"browseAllGroups": "Просмотреть все группы →",
|
||||
"browseAllPledges": "Просмотреть все обещания →",
|
||||
"pending": "Ожидают одобрения",
|
||||
"pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.",
|
||||
"pendingEmpty": "Ничего не ждёт проверки.",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "Mishandirapamwe yeNharaunda",
|
||||
"communityDesc": "Batsira kupa mari kushanduko dzakakodzera.",
|
||||
"browseAll": "Tarisa mishandirapamwe yose →",
|
||||
"browseAllGroups": "Tarisa mapoka ose →",
|
||||
"browseAllPledges": "Tarisa zvitsidziro zvose →",
|
||||
"pending": "Yakamirira kutenderwa",
|
||||
"pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.",
|
||||
"pendingEmpty": "Hapana chinomirira kutariswa.",
|
||||
|
||||
@@ -1135,8 +1135,6 @@
|
||||
"community": "Kampeni za Jumuiya",
|
||||
"communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.",
|
||||
"browseAll": "Vinjari kampeni zote →",
|
||||
"browseAllGroups": "Vinjari vikundi vyote →",
|
||||
"browseAllPledges": "Vinjari ahadi zote →",
|
||||
"pending": "Inasubiri idhini",
|
||||
"pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.",
|
||||
"pendingEmpty": "Hakuna kinachosubiri ukaguzi.",
|
||||
|
||||
@@ -1135,8 +1135,6 @@
|
||||
"community": "Topluluk Kampanyaları",
|
||||
"communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.",
|
||||
"browseAll": "Tüm kampanyalara göz at →",
|
||||
"browseAllGroups": "Tüm gruplara göz at →",
|
||||
"browseAllPledges": "Tüm taahhütlere göz at →",
|
||||
"pending": "Onay bekliyor",
|
||||
"pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.",
|
||||
"pendingEmpty": "İncelemeyi bekleyen bir şey yok.",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "社群活動",
|
||||
"communityDesc": "為值得做的改變提供資金。",
|
||||
"browseAll": "瀏覽所有活動 →",
|
||||
"browseAllGroups": "瀏覽所有群組 →",
|
||||
"browseAllPledges": "瀏覽所有懸賞 →",
|
||||
"pending": "等待審批",
|
||||
"pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。",
|
||||
"pendingEmpty": "沒有等待審查的內容。",
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
"community": "社区活动",
|
||||
"communityDesc": "为值得做的改变提供资金。",
|
||||
"browseAll": "浏览所有活动 →",
|
||||
"browseAllGroups": "浏览所有群组 →",
|
||||
"browseAllPledges": "浏览所有悬赏 →",
|
||||
"pending": "等待审批",
|
||||
"pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。",
|
||||
"pendingEmpty": "没有等待审查的内容。",
|
||||
|
||||
+110
-469
@@ -1,47 +1,37 @@
|
||||
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 { 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 {
|
||||
ModerationOverlay,
|
||||
ModeratorCollapsibleSection,
|
||||
} from '@/components/moderation';
|
||||
import { PledgeCard } from '@/components/PledgeCard';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
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, EyeOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Skeletons / Cards
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPledgeCoord(action: Action) {
|
||||
return `36639:${action.pubkey}:${action.id}`;
|
||||
@@ -62,150 +52,19 @@ function ActionSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
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,142 +72,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 { data: rawActions, isLoading: actionsLoading } = useActions({
|
||||
countryCode: selectedCountry,
|
||||
limit: 300,
|
||||
});
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
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 } = 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]);
|
||||
|
||||
// `actions` is the 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, 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';
|
||||
@@ -358,57 +119,24 @@ 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 visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
// Idle list: featured first; if none are featured, fall back to the
|
||||
// chronological all-pledges grid so the page is never blank.
|
||||
const idlePledges = useMemo<Action[]>(() => {
|
||||
if (orderedFeaturedPledges.length > 0) return orderedFeaturedPledges;
|
||||
return (actions ?? []).filter(
|
||||
(action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)),
|
||||
);
|
||||
}, [orderedFeaturedPledges, actions, featuredPledgeCoordSet]);
|
||||
|
||||
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}
|
||||
sortOptions={['top', 'new']}
|
||||
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)}
|
||||
/>
|
||||
@@ -417,8 +145,12 @@ export default function ActionsPage() {
|
||||
{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>
|
||||
<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>
|
||||
<ActionSection
|
||||
items={visibleMine}
|
||||
@@ -431,131 +163,18 @@ export default function ActionsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Unified Pledges section.
|
||||
- Idle (no search / no sort / no country): renders the
|
||||
moderator-featured grid, or a chronological fallback when
|
||||
no pledges are featured yet.
|
||||
- Active: renders the full search/sort/country result set. */}
|
||||
<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>
|
||||
{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>
|
||||
) : 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((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-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>
|
||||
<PledgesDiscoverySection
|
||||
filterPersistence="url"
|
||||
showHidden={
|
||||
isMod
|
||||
? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenPledges.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
@@ -567,7 +186,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) => (
|
||||
<ActionSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -589,7 +210,10 @@ export default function ActionsPage() {
|
||||
showMenu={false}
|
||||
className="flex items-center"
|
||||
/>
|
||||
<ActionShareMenu action={action} displayTitle={action.title} />
|
||||
<ActionShareMenu
|
||||
action={action}
|
||||
displayTitle={action.title}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -630,10 +254,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();
|
||||
@@ -699,7 +323,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>
|
||||
@@ -725,7 +352,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')}
|
||||
@@ -737,9 +368,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 (
|
||||
|
||||
+70
-249
@@ -5,9 +5,8 @@ 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';
|
||||
@@ -16,7 +15,6 @@ import { useAllCampaigns, type CampaignSort } 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 { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -24,11 +22,9 @@ import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
/**
|
||||
* Type-guard for the `?sort=` URL param.
|
||||
*
|
||||
* - `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.
|
||||
* Type-guard mirroring the one in `useDiscoveryFilters`. Pulled out
|
||||
* to keep the page's hidden-section derivation self-contained, since
|
||||
* it needs to read the same URL params the section reads.
|
||||
*/
|
||||
function parseSort(value: string | null): Nip50Sort {
|
||||
if (value === 'top') return 'top';
|
||||
@@ -36,183 +32,84 @@ function parseSort(value: string | null): Nip50Sort {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the
|
||||
* `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and
|
||||
* `'default'` both map to `'none'` (chronological) — the page handles
|
||||
* the "show featured only" framing on top of that.
|
||||
*/
|
||||
/** Toolbar sort vocabulary → useAllCampaigns vocabulary. */
|
||||
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
|
||||
|
||||
/**
|
||||
* Lists every campaign found on relays. The page has two display modes:
|
||||
* Lists every campaign found on relays.
|
||||
*
|
||||
* 1. **Idle** (no search, no sort, no country picked) — shows
|
||||
* moderator-featured campaigns only. If there are no featured
|
||||
* campaigns, falls back to the latest chronological grid so the page
|
||||
* is never blank.
|
||||
* 2. **Active** (user typed a query, picked Top/New, or chose a
|
||||
* country) — shows the full set, ranked by sats raised (Top) or
|
||||
* chronological (New / search default).
|
||||
* 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.
|
||||
*
|
||||
* Search 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=top|new&q=<search>&country=<iso>`. 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=&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);
|
||||
if (value === 'top') next.set('sort', 'top');
|
||||
else if (value === 'new') next.set('sort', 'new');
|
||||
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({
|
||||
// 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: debouncedSearch.trim(),
|
||||
search: urlQuery,
|
||||
countryCode: urlCountry,
|
||||
limit: 200,
|
||||
});
|
||||
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
|
||||
|
||||
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 } = useCampaigns({
|
||||
coordinates: featuredCoords,
|
||||
limit: featuredCoords.length || 1,
|
||||
enabled: moderationReady,
|
||||
});
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('campaigns.all.seoTitle')} | ${config.appName}`,
|
||||
description: t('campaigns.all.description'),
|
||||
});
|
||||
|
||||
const activeQuery = debouncedSearch.trim();
|
||||
|
||||
// The unified section is in "active" mode when the user has expressed
|
||||
// intent to browse the full set: typed a query, picked Top/New, or
|
||||
// chosen a country. Otherwise it's the curated featured-first view.
|
||||
const isActive = activeQuery !== '' || sort !== 'default' || !!urlCountry;
|
||||
|
||||
// Visible campaigns in the **active** branch: every campaign matching
|
||||
// the search/sort/country, minus hidden (unless the moderator opted
|
||||
// in to seeing hidden). Featured items are intentionally NOT pulled
|
||||
// out of this list — when the user is actively browsing, they want a
|
||||
// ranked or chronological grid, not the curated shelf.
|
||||
const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => {
|
||||
const all = campaigns ?? [];
|
||||
const hiddenCoords = moderation?.hiddenCoords ?? new Set<string>();
|
||||
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 {
|
||||
visible.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return { visible, hiddenCount, hiddenCampaigns };
|
||||
}, [campaigns, 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]);
|
||||
|
||||
// Idle-mode list: featured first; if none are featured, fall back to
|
||||
// the latest chronological grid so the page never lands on an empty
|
||||
// state when there's content to show.
|
||||
const idleCampaigns = useMemo<ParsedCampaign[]>(() => {
|
||||
if (orderedFeaturedCampaigns.length > 0) return orderedFeaturedCampaigns;
|
||||
return visible;
|
||||
}, [orderedFeaturedCampaigns, visible]);
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
const [showAllMine, setShowAllMine] = useState(false);
|
||||
const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
const showSkeleton = isLoading || !moderationReady;
|
||||
const visibleMine = showAllMine
|
||||
? (myCampaigns ?? [])
|
||||
: (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<AllCampaignsHero campaignCount={visible.length} />
|
||||
<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 && myCampaigns && myCampaigns.length > 0 && (
|
||||
@@ -235,116 +132,38 @@ export function AllCampaignsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Unified Campaigns section.
|
||||
- Idle (no search / no sort / no country): renders the
|
||||
moderator-curated featured grid, or a chronological
|
||||
fallback if nothing is featured yet.
|
||||
- Active: renders the full search/sort/country result set. */}
|
||||
<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={searchInput}
|
||||
onQueryChange={setSearchInput}
|
||||
sort={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>
|
||||
<CampaignsDiscoverySection
|
||||
filterPersistence="url"
|
||||
showHidden={
|
||||
isMod
|
||||
? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: hiddenCount,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
) : (isActive ? visible : idleCampaigns).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">
|
||||
{(isActive ? visible : idleCampaigns).map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
}
|
||||
>
|
||||
@@ -355,7 +174,6 @@ export function AllCampaignsPage() {
|
||||
</div>
|
||||
</ModeratorCollapsibleSection>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
@@ -485,7 +303,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>
|
||||
|
||||
+24
-380
@@ -1,60 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, PlusCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import {
|
||||
CommunityMiniCard,
|
||||
CommunityMiniCardSkeleton,
|
||||
} from '@/components/discovery/CommunityMiniCard';
|
||||
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
|
||||
import { HeroLightningMap } from '@/components/HeroLightningMap';
|
||||
import { PledgeCard } from '@/components/PledgeCard';
|
||||
import { useActions, type Action } from '@/hooks/useActions';
|
||||
import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection';
|
||||
import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection';
|
||||
import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import type { ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
/** Cap on how many featured campaigns we render in the home-page row. */
|
||||
const MAX_FEATURED_CAMPAIGNS = 4;
|
||||
/**
|
||||
* Cap on featured groups and featured pledges in their respective home-page
|
||||
* sections. The dedicated pages render unlimited featured items; the home
|
||||
* page is a launchpad, not the canonical list.
|
||||
*/
|
||||
const MAX_FEATURED_PER_SECTION = 8;
|
||||
|
||||
function getPledgeCoord(action: Action) {
|
||||
return `36639:${action.pubkey}:${action.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home page (`/`).
|
||||
*
|
||||
* A curated launchpad: hero on top, then three featured sections — campaigns,
|
||||
* groups, pledges — each capped to a digestible row and linking out to its
|
||||
* dedicated browse page (`/campaigns/all`, `/groups`, `/pledges`). The home
|
||||
* page intentionally does *not* show community/pending/hidden grids,
|
||||
* unmoderated streams, or per-viewer "your X" shelves — those live on the
|
||||
* dedicated pages so the home stays scannable on every visit.
|
||||
* 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`.
|
||||
*
|
||||
* Each section's "featured" set is derived from the same moderation labels
|
||||
* used on its dedicated page, so what surfaces here matches what surfaces
|
||||
* there (just truncated). Sections with no featured items collapse silently
|
||||
* rather than render an empty card; the page can degrade to "hero + one
|
||||
* section" without looking broken.
|
||||
* 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();
|
||||
@@ -70,10 +43,13 @@ export function CampaignsPage() {
|
||||
<main className="min-h-screen pb-16">
|
||||
<Hero loggedIn={!!user} />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="featured">
|
||||
<FeaturedCampaignsSection />
|
||||
<FeaturedGroupsSection />
|
||||
<FeaturedPledgesSection />
|
||||
<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>
|
||||
);
|
||||
@@ -189,7 +165,7 @@ function Hero({ loggedIn }: { loggedIn: boolean }) {
|
||||
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="#featured">{t('campaigns.home.exploreCampaigns')}</a>
|
||||
<a href="#discover">{t('campaigns.home.exploreCampaigns')}</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -199,336 +175,4 @@ function Hero({ loggedIn }: { loggedIn: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Section header
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
browseLabel,
|
||||
browseHref,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
browseLabel: string;
|
||||
browseHref: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="shrink-0">
|
||||
<Link to={browseHref}>
|
||||
{browseLabel}
|
||||
<ArrowRight className="size-4 ms-1 rtl:rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Featured Campaigns
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function FeaturedCampaignsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { data: moderation, isReady: moderationReady } = useCampaignModeration();
|
||||
|
||||
// Featured slot list — derived from moderation labels. Sorted newest-
|
||||
// featured first, capped, 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_CAMPAIGNS);
|
||||
}, [moderation]);
|
||||
|
||||
const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns(
|
||||
moderationReady && featuredCoords.length > 0
|
||||
? { coordinates: featuredCoords, limit: MAX_FEATURED_CAMPAIGNS }
|
||||
: { coordinates: [], limit: MAX_FEATURED_CAMPAIGNS },
|
||||
);
|
||||
|
||||
// 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_CAMPAIGNS);
|
||||
}, [featuredCampaigns, featuredCoords, moderation]);
|
||||
|
||||
const isLoading = !moderationReady || featuredLoading;
|
||||
|
||||
// Once moderation is ready and there's nothing featured, collapse the
|
||||
// section silently — the home page can degrade to "hero + the sections
|
||||
// that have content".
|
||||
if (moderationReady && featuredCoords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title={t('campaigns.home.featured')}
|
||||
description={t('campaigns.home.featuredDesc', { appName: config.appName })}
|
||||
browseLabel={t('campaigns.home.browseAll')}
|
||||
browseHref="/campaigns/all"
|
||||
/>
|
||||
|
||||
<FeaturedCampaignsRow
|
||||
campaigns={orderedFeatured}
|
||||
isLoading={isLoading}
|
||||
expectedCount={featuredCoords.length}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
function FeaturedCampaignsRow({
|
||||
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_CAMPAIGNS, 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Featured Groups
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function FeaturedGroupsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { data: orgModeration, isReady: orgModerationReady } =
|
||||
useOrganizationModeration();
|
||||
const { data: featuredOrgs, isLoading: featuredOrgsLoading } =
|
||||
useFeaturedOrganizations();
|
||||
|
||||
const featuredGroups = useMemo<ParsedCommunity[]>(() => {
|
||||
if (!featuredOrgs) return [];
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
return featuredOrgs
|
||||
.map((entry) => entry.community)
|
||||
.filter((c) => !hiddenCoords.has(c.aTag))
|
||||
.slice(0, MAX_FEATURED_PER_SECTION);
|
||||
}, [featuredOrgs, orgModeration]);
|
||||
|
||||
// `useFeaturedOrganizations` is internally gated on moderation readiness,
|
||||
// so while moderation labels are still resolving the underlying query is
|
||||
// disabled and reports `isLoading: false` / `data: undefined`. Treat any
|
||||
// of "moderation not ready / featured query in flight / featured data
|
||||
// not yet defined" as loading so the skeleton stays on screen until we
|
||||
// know what's featured.
|
||||
const isLoading =
|
||||
!orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined;
|
||||
|
||||
if (!isLoading && featuredGroups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title={t('groups.list.featuredGroups')}
|
||||
description={t('groups.list.featuredGroupsTagline')}
|
||||
browseLabel={t('campaigns.home.browseAllGroups')}
|
||||
browseHref="/groups"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
) : (
|
||||
<CommunityGrid>
|
||||
{featuredGroups.map((community) => (
|
||||
<CommunityMiniCard
|
||||
key={community.aTag}
|
||||
community={community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</CommunityGrid>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Featured Pledges
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function FeaturedPledgesSection() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppContext();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
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),
|
||||
)
|
||||
.slice(0, MAX_FEATURED_PER_SECTION);
|
||||
}, [pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({
|
||||
coordinates: featuredPledgeCoords,
|
||||
limit: featuredPledgeCoords.length || 1,
|
||||
enabled: pledgeModerationReady && featuredPledgeCoords.length > 0,
|
||||
});
|
||||
|
||||
const orderedFeaturedPledges = useMemo<Action[]>(() => {
|
||||
if (!featuredPledges || !pledgeModerationReady) return [];
|
||||
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);
|
||||
})
|
||||
.slice(0, MAX_FEATURED_PER_SECTION);
|
||||
}, [featuredPledges, pledgeModeration, pledgeModerationReady]);
|
||||
|
||||
const isLoading =
|
||||
!pledgeModerationReady ||
|
||||
(featuredPledgeCoords.length > 0 && featuredPledgesLoading);
|
||||
|
||||
// Same silent-collapse rule as the other two sections: once we know
|
||||
// there's nothing featured, drop the heading rather than render an
|
||||
// empty container.
|
||||
if (pledgeModerationReady && featuredPledgeCoords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title={t('pledges.list.featuredPledges')}
|
||||
description={t('pledges.list.featuredPledgesTagline', {
|
||||
appName: config.appName,
|
||||
})}
|
||||
browseLabel={t('campaigns.home.browseAllPledges')}
|
||||
browseHref="/pledges"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{Array.from({
|
||||
length: Math.max(
|
||||
1,
|
||||
Math.min(MAX_FEATURED_PER_SECTION, featuredPledgeCoords.length || 4),
|
||||
),
|
||||
}).map((_, i) => (
|
||||
<PledgeSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : orderedFeaturedPledges.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{orderedFeaturedPledges.map((action) => (
|
||||
<PledgeCard
|
||||
key={`${action.pubkey}:${action.id}`}
|
||||
action={action}
|
||||
btcPrice={btcPrice}
|
||||
showAuthor
|
||||
showTranslate
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Defensive — featured coords resolved to a non-empty set but the
|
||||
// events didn't come back (e.g. relay miss). Collapse silently.
|
||||
null
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PledgeSkeleton() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-exported so AppRouter's lazy import shape stays identical.
|
||||
export default CampaignsPage;
|
||||
|
||||
+48
-228
@@ -7,31 +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 { 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();
|
||||
@@ -62,234 +72,46 @@ export function CommunitiesPage() {
|
||||
navigate('/groups/new');
|
||||
};
|
||||
|
||||
// On-page NIP-50 search + sort + show-hidden toolbar state.
|
||||
//
|
||||
// Default sort, empty query → curated featured-first idle view.
|
||||
// If no groups are featured yet, falls back to the chronological
|
||||
// "all groups" grid so the page is never blank.
|
||||
// Default sort, with query → relay search for kind 34550, results
|
||||
// post-filtered against name/description/content client-side.
|
||||
// Top / New → always active. Top sends `sort:top`;
|
||||
// New sends a raw chronological feed of the kind.
|
||||
//
|
||||
// Groups aren't country-scoped on the discovery surface (a community
|
||||
// is its own scope), so the country picker is intentionally omitted
|
||||
// from the toolbar here even though Campaigns and Pledges expose it.
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [sortMode, setSortMode] = useState<Nip50Sort>('default');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const debouncedSearch = useDebounce(searchInput, 300);
|
||||
const trimmedSearch = debouncedSearch.trim();
|
||||
const {
|
||||
data: searchHitsRaw,
|
||||
isFetching: isSearchFetching,
|
||||
isActive: isSearching,
|
||||
} = useNip50Search<ParsedCommunity>({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
query: debouncedSearch,
|
||||
sort: sortMode,
|
||||
parse: parseCommunityEvent,
|
||||
// Group names and descriptions live in tags, not `content`. Relay
|
||||
// NIP-50 implementations that only match content silently miss
|
||||
// obvious title hits — widen client-side by also checking these
|
||||
// tag values.
|
||||
getKeywordHaystack: (event) => {
|
||||
const name = event.tags.find(([n]) => n === 'name')?.[1] ?? '';
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1] ?? '';
|
||||
return [name, description, event.content];
|
||||
},
|
||||
});
|
||||
|
||||
// Lift org moderation to the page so search results can drop hidden
|
||||
// groups (or include them when the Show-hidden switch is on).
|
||||
const { data: orgModeration, isReady: orgModerationReady } = useOrganizationModeration();
|
||||
const { searchHits, searchHiddenCount } = useMemo(() => {
|
||||
if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 };
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
let hidden = 0;
|
||||
const visible: ParsedCommunity[] = [];
|
||||
for (const c of searchHitsRaw) {
|
||||
if (hiddenCoords.has(c.aTag)) {
|
||||
hidden += 1;
|
||||
if (showHidden) visible.push(c);
|
||||
} else {
|
||||
visible.push(c);
|
||||
}
|
||||
}
|
||||
return { searchHits: visible, searchHiddenCount: hidden };
|
||||
}, [searchHitsRaw, orgModeration, showHidden]);
|
||||
|
||||
// The full kind-34550 fetch is only used by moderators — it feeds the
|
||||
// "Hidden" collapsible section and the hidden-count badge inside the
|
||||
// toolbar's filter menu. Non-moderators don't need it: the unified
|
||||
// section renders moderator-featured groups directly (or relay
|
||||
// search results when the user is actively browsing), so there's no
|
||||
// "render everything, then filter for the Agora tag" round-trip and
|
||||
// therefore no flash of unrelated groups before the curated set
|
||||
// appears.
|
||||
// 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,
|
||||
});
|
||||
const { allHiddenCount, hiddenGroups } = useMemo(() => {
|
||||
const { data: orgModeration } = useOrganizationModeration();
|
||||
const { hiddenGroups, hiddenCount } = useMemo(() => {
|
||||
const hiddenCoords = orgModeration?.hiddenCoords ?? new Set<string>();
|
||||
let hidden = 0;
|
||||
const hiddenList: ParsedCommunity[] = [];
|
||||
const list: ParsedCommunity[] = [];
|
||||
for (const org of allOrgs ?? []) {
|
||||
if (hiddenCoords.has(org.aTag)) {
|
||||
hidden += 1;
|
||||
hiddenList.push(org);
|
||||
}
|
||||
if (hiddenCoords.has(org.aTag)) list.push(org);
|
||||
}
|
||||
return { allHiddenCount: hidden, hiddenGroups: hiddenList };
|
||||
return { hiddenGroups: list, hiddenCount: list.length };
|
||||
}, [allOrgs, orgModeration]);
|
||||
|
||||
// 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>();
|
||||
return featuredOrgs
|
||||
.map((entry) => entry.community)
|
||||
.filter((c) => (isMod && showHidden) || !hiddenCoords.has(c.aTag));
|
||||
}, [featuredOrgs, orgModeration, isMod, showHidden]);
|
||||
|
||||
// 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;
|
||||
|
||||
// Search + sort + show-hidden cluster for the unified section.
|
||||
const searchToolbar = (
|
||||
<DiscoverySearchToolbar
|
||||
query={searchInput}
|
||||
onQueryChange={setSearchInput}
|
||||
sort={sortMode}
|
||||
onSortChange={setSortMode}
|
||||
sortOptions={['top', 'new']}
|
||||
searchPlaceholderKey="groups.list.searchPlaceholder"
|
||||
searchAriaLabelKey="groups.list.searchAriaLabel"
|
||||
showHidden={isMod ? {
|
||||
value: showHidden,
|
||||
onChange: setShowHidden,
|
||||
count: isSearching ? searchHiddenCount : allHiddenCount,
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Unified Groups section.
|
||||
- Idle (no search / no sort): renders ONLY moderator-featured
|
||||
groups. No fallback to a chronological "all groups" grid —
|
||||
that produced a brief 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 any intermediate state.
|
||||
- Active (search / Top / New): renders the full relay
|
||||
search result set. */}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : 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>
|
||||
|
||||
{isMod && (
|
||||
<ModeratorCollapsibleSection
|
||||
icon={<EyeOff className="size-4" />}
|
||||
@@ -495,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Community shelves
|
||||
// "My groups" shelf
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UserOrganizationsResult = ReturnType<typeof useUserOrganizations>;
|
||||
@@ -508,15 +330,17 @@ 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.
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -570,7 +394,3 @@ function MyCommunitiesShelf({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user