Hoist three duplicated discovery helpers to shared modules
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.
- getPledgeCoord → src/lib/pledges.ts. Was defined three times
(PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
the same '36639:<pubkey>:<d>' template. Lifted into the existing
pledges lib and typed structurally on { pubkey, id } so the lib
layer doesn't take a hook dep on Action.
- parseSort + toQuerySort → exported from useDiscoveryFilters and
useAllCampaigns respectively. AllCampaignsPage was carrying its own
copy of both with an apologetic comment ('mirroring the one in
useDiscoveryFilters'); CampaignsDiscoverySection had its own
toQuerySort. One source of truth each now, with the pages and the
section importing from the same module as the hook that consumes
the result.
- PledgeCardSkeleton → exported from PledgeCard. Replaces two
byte-identical ActionSkeleton components in PledgesDiscoverySection
and ActionsPage. Naming matches the existing CampaignCardSkeleton /
CommunityMiniCardSkeleton convention of placing the skeleton next
to its card.
This commit is contained in:
@@ -24,12 +24,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { AuthorByline } from '@/components/AuthorByline';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useEventTranslation } from '@/hooks/useEventTranslation';
|
||||
import { parseAction, type Action } from '@/hooks/useActions';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
@@ -172,3 +173,30 @@ export function PledgeCard({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading placeholder that matches `PledgeCard`'s grid-variant shape:
|
||||
* 16:9 cover, then title, two lines of body, a progress bar row, and
|
||||
* a footer line. Sized to slot into the same `<DiscoveryGrid>` / 4-col
|
||||
* grids as the real card so the skeleton row doesn't reflow when data
|
||||
* arrives.
|
||||
*
|
||||
* Lives next to `PledgeCard` for parity with `CampaignCardSkeleton`
|
||||
* and `CommunityMiniCardSkeleton`, which sit next to their cards too.
|
||||
* Was duplicated as `ActionSkeleton` in `PledgesDiscoverySection` and
|
||||
* `ActionsPage` before this consolidation.
|
||||
*/
|
||||
export function PledgeCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm h-full flex flex-col">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="flex-1 p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,23 +7,14 @@ 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 { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters';
|
||||
import type { 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:
|
||||
|
||||
@@ -3,10 +3,9 @@ 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 { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
|
||||
import { parseAction, useActions, type Action } from '@/hooks/useActions';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
@@ -14,25 +13,7 @@ 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>
|
||||
);
|
||||
}
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
|
||||
interface PledgesDiscoverySectionProps {
|
||||
/**
|
||||
@@ -285,7 +266,7 @@ export function PledgesDiscoverySection({
|
||||
{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} />
|
||||
<PledgeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : searchHits && searchHits.length > 0 ? (
|
||||
@@ -316,7 +297,7 @@ export function PledgesDiscoverySection({
|
||||
) : 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} />
|
||||
<PledgeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : idlePledges.length > 0 ? (
|
||||
|
||||
@@ -5,10 +5,25 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { parseCampaignEvents } from '@/hooks/useCampaigns';
|
||||
import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
|
||||
/** Sort modes for the All Campaigns page. */
|
||||
export type CampaignSort = 'top' | 'none';
|
||||
|
||||
/**
|
||||
* Map the toolbar's sort vocabulary (`default` / `top` / `new`) onto
|
||||
* `useAllCampaigns`'s vocabulary (`top` / `none`). `'new'` and `'default'`
|
||||
* both map to `'none'` (chronological) — discovery sections apply the
|
||||
* "show featured only when idle" framing on top of the chronological
|
||||
* feed, so the underlying query doesn't need to distinguish them.
|
||||
*
|
||||
* Exported so the section component and any page-level consumer using
|
||||
* the same hook stay aligned through one helper instead of two
|
||||
* hand-rolled ternaries.
|
||||
*/
|
||||
export const toQuerySort = (s: Nip50Sort): CampaignSort =>
|
||||
s === 'top' ? 'top' : 'none';
|
||||
|
||||
interface UseAllCampaignsOptions {
|
||||
/** Sort mode. `top` ranks by total sats raised; `none` is chronological. */
|
||||
sort: CampaignSort;
|
||||
|
||||
@@ -11,8 +11,14 @@ import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
* - `'top'` and `'new'` map to the toolbar's active sort modes.
|
||||
* - Anything else (missing, empty, legacy values) collapses to
|
||||
* `'default'`, the curated featured-first idle state.
|
||||
*
|
||||
* Exported because the dedicated discovery pages (`/campaigns/all`,
|
||||
* `/pledges`) read `?sort=` independently from the section's hook to
|
||||
* thread the value into ancillary derivations (hidden-list cache
|
||||
* lookups, create-X href country prefills). One canonical parser
|
||||
* keeps page-level and section-level reads in lockstep.
|
||||
*/
|
||||
function parseSort(value: string | null): Nip50Sort {
|
||||
export function parseSort(value: string | null): Nip50Sort {
|
||||
if (value === 'top') return 'top';
|
||||
if (value === 'new') return 'new';
|
||||
return 'default';
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
|
||||
|
||||
/**
|
||||
* Addressable coordinate for a pledge (kind 36639): `36639:<pubkey>:<d>`.
|
||||
*
|
||||
* Accepts any object carrying `pubkey` and `id` so this helper stays in
|
||||
* the lib layer without taking a hook dep on `Action`. Both the moderation
|
||||
* label system (NIP-32 / kind 1985 `a`-tags) and the share-link generator
|
||||
* (NIP-09 deletion requests, naddr encoders) hand-rolled the same string
|
||||
* three times before this consolidation; one source of truth now.
|
||||
*/
|
||||
export function getPledgeCoord({ pubkey, id }: { pubkey: string; id: string }): string {
|
||||
return `36639:${pubkey}:${id}`;
|
||||
}
|
||||
|
||||
export function formatPledgeAmount(sats: number, btcPrice: number | undefined): string {
|
||||
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
|
||||
return `${formatSats(sats)} sats`;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { usePledgeModeration } from '@/hooks/usePledgeModeration';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { getPledgeCoord } from '@/lib/pledges';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
@@ -28,29 +29,8 @@ import {
|
||||
ModerationOverlay,
|
||||
ModeratorCollapsibleSection,
|
||||
} from '@/components/moderation';
|
||||
import { PledgeCard } from '@/components/PledgeCard';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { PledgeCard, PledgeCardSkeleton } from '@/components/PledgeCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated `/pledges` page.
|
||||
@@ -187,7 +167,7 @@ export default function ActionsPage() {
|
||||
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} />
|
||||
<PledgeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,30 +11,16 @@ import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { ModeratorCollapsibleSection } from '@/components/moderation';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns';
|
||||
import { useAllCampaigns, toQuerySort } from '@/hooks/useAllCampaigns';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { parseSort } from '@/hooks/useDiscoveryFilters';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Nip50Sort } from '@/hooks/useNip50Search';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
/**
|
||||
* Type-guard 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';
|
||||
if (value === 'new') return 'new';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/** Toolbar sort vocabulary → useAllCampaigns vocabulary. */
|
||||
const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none');
|
||||
|
||||
/**
|
||||
* Lists every campaign found on relays.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user