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

This commit is contained in:
Alex Gleason
2026-05-22 01:21:57 -05:00
25 changed files with 2433 additions and 2409 deletions
+10
View File
@@ -34,6 +34,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
@@ -1456,6 +1457,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bungee-shade": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
+1
View File
@@ -41,6 +41,7 @@
"@fontsource-variable/nunito": "^5.2.7",
"@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/playfair-display": "^5.2.8",
"@fontsource/bebas-neue": "^5.2.7",
"@fontsource/bungee-shade": "^5.2.7",
"@fontsource/caveat": "^5.2.8",
"@fontsource/cherry-bomb-one": "^5.2.7",
-114
View File
@@ -1,114 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface CampaignHeroBackgroundProps {
/**
* Image URL for the active campaign. Each new URL crossfades over the
* previous one — we keep up to two layers mounted at a time so the
* transition is smooth even when the source changes mid-fade.
*/
imageUrl: string | undefined;
/** Optional className for the outer wrapper. */
className?: string;
}
interface Layer {
/** Stable key so React doesn't tear down the layer mid-transition. */
id: number;
/** Sanitized URL (or `null` for the gradient-only fallback). */
url: string | null;
}
const FADE_MS = 1500;
/**
* Full-bleed crossfading background built from the active campaign's banner
* image. Modelled after Treasures' HeroGallery: each image gets its own
* stacked layer and we toggle opacity to crossfade. The previous layer
* unmounts after the fade completes, so we never accumulate more than a
* couple of layers in the DOM.
*
* A warm tint + subtle film-grain SVG sit on top so headlines stay readable
* over any photo.
*/
export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
const idRef = useRef(0);
const [layers, setLayers] = useState<Layer[]>([]);
const lastUrlRef = useRef<string | null>(null);
useEffect(() => {
const safe = sanitizeUrl(imageUrl) ?? null;
if (safe === lastUrlRef.current) return;
lastUrlRef.current = safe;
const id = ++idRef.current;
// Add the new layer; existing layers stay mounted so the crossfade has
// something to fade from.
setLayers((prev) => [...prev, { id, url: safe }]);
// After the fade completes, drop everything except the most recent
// layer to keep the DOM tidy.
const timeout = window.setTimeout(() => {
setLayers((prev) => prev.filter((l) => l.id === id));
}, FADE_MS + 50);
return () => window.clearTimeout(timeout);
}, [imageUrl]);
return (
<div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
{layers.map((layer, i) => {
const isTop = i === layers.length - 1;
return (
<div
key={layer.id}
className="absolute inset-0"
style={{
opacity: isTop ? 1 : 0,
transition: `opacity ${FADE_MS}ms ease-in-out`,
}}
>
{layer.url ? (
<img
src={layer.url}
alt=""
loading="eager"
decoding="async"
// Slow continuous pan toward the left — pairs with the
// right-anchored globe so the scene reads as moving toward
// the headline copy.
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/40" />
)}
</div>
);
})}
{/* Dark vertical scrim — strong at the bottom (spotlight card) and
lighter at the top so the photo still reads. Uses black instead of
background so the overlay is consistent across light/dark themes. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/45 to-black/15" />
{/* Warm primary tint — gives the hero its brand feel. */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10" />
{/* Left wash — mobile only, where the globe arc crosses the headline.
Dark so white headline text has a reliable backdrop. */}
<div className="absolute inset-0 bg-gradient-to-r from-black/75 via-black/35 to-transparent sm:hidden" />
{/* Film grain — same trick as Treasures' HeroGallery. Helps the
composited globe + photo feel like one image. */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity: 0.18 }}
>
<filter id="hero-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter="url(#hero-grain)" />
</svg>
</div>
);
}
+1 -1
View File
@@ -202,7 +202,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
// Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
// Strip any legacy avatar shape data from old Ditto-style profiles
// Strip any legacy avatar-shape field carried over from older clients.
delete data.shape;
// Clean up empty values in standard metadata
-160
View File
@@ -1,160 +0,0 @@
import { Link } from 'react-router-dom';
import { ArrowRight, MapPin, ShieldCheck } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
interface HeroCampaignSpotlightProps {
/** Campaign to feature. `null` renders the empty placeholder. */
campaign: ParsedCampaign | null;
/** Show a skeleton while the parent is still loading featured campaigns. */
isLoading?: boolean;
/** Extra classes for the outer wrapper. */
className?: string;
}
/**
* Banner-overlay spotlight for the active campaign — title, summary,
* author, location, and a "View campaign" CTA — rendered directly on the
* hero photo (no card chrome). The hero photo IS the background, so this
* component is purely a text overlay.
*
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
* or pinning to whichever marker the user clicked on the globe.
*/
export function HeroCampaignSpotlight({
campaign,
isLoading = false,
className,
}: HeroCampaignSpotlightProps) {
// useAuthor must be called unconditionally to keep hook order stable —
// when there's no campaign yet we pass an empty pubkey and ignore the
// (no-op) result below. Same for donations + BTC price.
const author = useAuthor(campaign?.pubkey ?? '');
const { data: stats } = useCampaignDonations(campaign ?? undefined);
const { data: btcPrice } = useBtcPrice();
if (isLoading && !campaign) {
return (
<div className={cn('space-y-1.5', className)}>
<Skeleton className="h-5 w-52 bg-white/20" />
<Skeleton className="h-3 w-64 bg-white/20" />
<Skeleton className="h-3 w-40 bg-white/20" />
</div>
);
}
if (!campaign) return null;
const naddr = encodeCampaignNaddr(campaign);
const meta = author.data?.metadata;
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
const authorPicture = sanitizeUrl(meta?.picture);
const countryLabel = getCampaignCountryLabel(campaign);
const isSilentPayment = campaign.wallet.mode === 'sp';
return (
<div
className={cn(
// Compact text block over the photo — always white regardless of
// theme since the hero is always a dark-scrimed photo.
'space-y-1.5 text-white hero-text-shadow-soft',
className,
)}
>
<p className="text-base font-semibold leading-snug line-clamp-1">
{campaign.title}
</p>
{campaign.summary && (
<p className="text-xs text-white/80 line-clamp-2 max-w-xs">
{campaign.summary}
</p>
)}
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
so we can tune the bar for legibility on top of a photo: dark
translucent track, glowing primary fill. When the campaign has no
goal tag, the bar is omitted entirely and we only show the raised
total. Silent-payment campaigns hide totals by design (per
NIP.md Kind 33863). */}
{isSilentPayment ? (
<div className="space-y-1.5 pt-1 max-w-xs">
<div className="inline-flex items-center gap-1.5 text-[11px] text-white/85 [text-shadow:none]">
<ShieldCheck className="size-3" />
<span>Private campaign totals not public</span>
</div>
{campaign.goalUsd && campaign.goalUsd > 0 && (
<div className="text-[11px] text-white/70 [text-shadow:none]">
Target: {formatUsdGoal(campaign.goalUsd)}
</div>
)}
</div>
) : (() => {
const raised = stats?.totalSats ?? 0;
const goal = campaign.goalUsd;
const hasGoal = !!goal && goal > 0;
const raisedUsd = satsToUsd(raised, btcPrice);
const pct = hasGoal && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / goal!) * 100))
: 0;
return (
<div className="space-y-1.5 pt-1 max-w-xs">
{hasGoal && (
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
style={{ width: `${pct}%` }}
/>
</div>
)}
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
<span className="font-semibold text-white">
{formatCampaignAmount(raised, btcPrice)}
{!hasGoal && <span className="ml-1 font-normal text-white/70">raised</span>}
</span>
{hasGoal && (
<span className="text-white/70">
of {formatUsdGoal(goal!)} goal
</span>
)}
</div>
</div>
);
})()}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/75 pt-0.5">
<span className="inline-flex items-center gap-1.5">
<Avatar className="size-4 ring-1 ring-white/40">
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
<AvatarFallback className="text-[8px]">
{authorName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="font-medium">{authorName}</span>
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span className="truncate max-w-[16ch]">{countryLabel}</span>
</span>
)}
<Link
to={`/${naddr}`}
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
>
View
<ArrowRight className="size-3" />
</Link>
</div>
</div>
);
}
-470
View File
@@ -1,470 +0,0 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { LAND_RINGS } from '@/lib/landPolygons';
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
/** Geographic point used by the globe projection. */
interface GeoPoint {
/** Latitude in degrees, [-90, 90]. */
lat: number;
/** Longitude in degrees, [-180, 180]. */
lng: number;
}
/**
* Visual variant for a globe marker. Each kind gets its own glyph + halo
* so the three "threads" of Discover — campaigns, communities, and
* country activity — read distinctly without needing legend chrome.
*/
export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
interface CampaignMarker extends GeoPoint {
/** Stable key for the marker (e.g. the campaign aTag). */
key: string;
/** Tooltip / accessible label shown on hover. */
label?: string;
/**
* Visual style of this marker. Defaults to `'campaign'` so existing
* callers (the campaigns hero) keep their heart markers unchanged.
*/
kind?: GlobeMarkerKind;
}
interface HeroGlobeProps {
/** Markers to plot on top of the globe — one per geo-located campaign. */
markers?: CampaignMarker[];
/**
* Marker the user has selected. The selected marker gets a stronger glow
* and a slightly larger heart so it reads as the "live" one.
*/
selectedKey?: string | null;
/** Fires when the user clicks a marker. */
onMarkerClick?: (key: string) => void;
/**
* Active hopeful hue. Drives the outer halo color and the back-lit
* limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
*/
hue?: HopeHue;
/** Optional className applied to the outer container. */
className?: string;
/** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
style?: CSSProperties;
}
/** Pre-parsed land rings as arrays of {lat, lng} points. */
const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
const out: GeoPoint[] = [];
for (let i = 0; i < flat.length; i += 2) {
out.push({ lng: flat[i], lat: flat[i + 1] });
}
return out;
});
const RADIUS = 285;
const CENTER = 300;
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
const ROTATION_PERIOD_SECONDS = 140;
/**
* Orthographic projection: turns a (lat, lng) pair into 2D screen
* coordinates plus a `z` depth value. Points with `z <= 0` are on the
* back hemisphere and should be hidden (or drawn with low opacity).
*/
function project(lat: number, lng: number, rotationDeg: number) {
const phi = (lat * Math.PI) / 180;
// Subtract rotation so the globe appears to spin west-to-east.
const lambda = ((lng - rotationDeg) * Math.PI) / 180;
const cosPhi = Math.cos(phi);
const x = cosPhi * Math.sin(lambda);
const y = Math.sin(phi);
const z = cosPhi * Math.cos(lambda);
return {
x: CENTER + x * RADIUS,
// Negate so positive latitudes render upward in SVG.
y: CENTER - y * RADIUS,
z,
};
}
/**
* Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
*
* Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
* - a soft cream sphere lit from the upper-left,
* - sandy-amber landmasses (real Natural Earth continent shapes,
* pre-simplified to ~1.5k vertices), and
* - small glowing marker dots for active campaigns.
*
* Rotation is driven by `requestAnimationFrame` and applied imperatively via
* refs so the component never re-renders during animation. Respects
* `prefers-reduced-motion` by holding at a static angle.
*/
export function HeroGlobe({
markers = [],
selectedKey = null,
onMarkerClick,
hue = HOPE_PALETTE[0],
className,
style,
}: HeroGlobeProps) {
const landRef = useRef<SVGGElement | null>(null);
const markersRef = useRef<SVGGElement | null>(null);
// Stable per-ring point counts so the animation loop knows how many polygon
// elements to update without re-reading the DOM each frame.
const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
// Live refs so the rAF loop can read the latest markers / selection
// without retriggering the effect — otherwise every spotlight tick
// would tear down the loop and snap rotation back to 0°.
const markersRefValue = useRef(markers);
const selectedKeyRef = useRef(selectedKey);
useEffect(() => {
markersRefValue.current = markers;
selectedKeyRef.current = selectedKey;
}, [markers, selectedKey]);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
let start: number | null = null;
const tick = (timestamp: number) => {
if (start === null) start = timestamp;
const elapsedSeconds = (timestamp - start) / 1000;
const rotation = prefersReducedMotion
? 25 // Hold at a flattering static angle.
: (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
// --- Landmass polygons ---
//
// For each ring we walk vertex-by-vertex projecting through the
// orthographic camera. Vertices on the *front* of the sphere
// (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
// otherwise project on top of front-side land — orthographic
// projection collapses depth — so we drop them.
//
// Where a ring crosses the visible limb (front ↔ back) we emit an
// interpolated point on the limb itself, so polygons that wrap
// around the side of the globe close cleanly along the sphere's
// outline instead of cutting across the disc interior.
//
// We also fade rings out over a narrow band near the limb so they
// don't pop on/off when crossing z = 0. Anything with maxZ below
// FADE_OUT is considered fully hidden; rings between FADE_OUT and
// FADE_IN ease in/out.
const FADE_OUT = 0.0;
const FADE_IN = 0.08;
const landEl = landRef.current;
if (landEl) {
const polygons = landEl.children;
for (let i = 0; i < LANDMASSES.length; i++) {
const ring = LANDMASSES[i];
const polygon = polygons[i] as SVGPolygonElement | undefined;
if (!polygon) continue;
// First pass: project every vertex, remembering z so we can
// detect front/back transitions cheaply.
const n = ring.length;
const xs = new Array<number>(n);
const ys = new Array<number>(n);
const zs = new Array<number>(n);
let maxZ = -1;
for (let j = 0; j < n; j++) {
const p = project(ring[j].lat, ring[j].lng, rotation);
xs[j] = p.x;
ys[j] = p.y;
zs[j] = p.z;
if (p.z > maxZ) maxZ = p.z;
}
if (maxZ <= FADE_OUT) {
polygon.setAttribute('opacity', '0');
continue;
}
// Second pass: emit only the visible portion. For each edge we
// include the endpoint when it's in front, and any limb-crossing
// we step over gets an interpolated point on the sphere edge.
const parts: string[] = [];
for (let j = 0; j < n; j++) {
const k = (j + 1) % n;
const zj = zs[j];
const zk = zs[k];
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
if ((zj > 0) !== (zk > 0)) {
// Find the parameter t in [0,1] along this edge where z=0.
const t = zj / (zj - zk);
const ex = xs[j] + (xs[k] - xs[j]) * t;
const ey = ys[j] + (ys[k] - ys[j]) * t;
// Project the limb point onto the actual sphere edge so it
// never lands inside the disc.
const dx = ex - CENTER;
const dy = ey - CENTER;
const d = Math.hypot(dx, dy) || 1;
const lx = CENTER + (dx / d) * RADIUS;
const ly = CENTER + (dy / d) * RADIUS;
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
}
}
if (parts.length < 3) {
polygon.setAttribute('opacity', '0');
continue;
}
polygon.setAttribute('points', parts.join(' '));
// Smooth fade as rings come around the limb. `fade` clamps to
// [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
// adding the small depth-based dimming used before.
const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
}
}
// --- Campaign markers ---
const markersEl = markersRef.current;
const liveMarkers = markersRefValue.current;
const liveSelectedKey = selectedKeyRef.current;
if (markersEl) {
const groups = markersEl.children;
for (let i = 0; i < liveMarkers.length; i++) {
const m = liveMarkers[i];
const group = groups[i] as SVGGElement | undefined;
if (!group) continue;
const p = project(m.lat, m.lng, rotation);
if (p.z <= 0) {
group.setAttribute('opacity', '0');
// Pull off-canvas so backside markers don't intercept clicks.
group.setAttribute('transform', 'translate(-1000 -1000)');
continue;
}
// Selected marker scales up subtly to read as "you are here".
const scale = m.key === liveSelectedKey ? 1.35 : 1;
group.setAttribute(
'transform',
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
);
group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
}
}
if (!prefersReducedMotion) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
// `markers` and `selectedKey` are read inside `tick` via refs above,
// so we deliberately omit them from this dep list to keep the
// rotation loop alive across spotlight cycles.
}, [ringSizes]);
return (
<div className={className} style={style}>
{/* Wrapper so the outer halo can sit behind the SVG. The halo is a
plain div (not part of the SVG) so its blur extends past the
sphere without needing a giant viewBox, and so we can drive it
with a CSS keyframe animation independent of the rotation. */}
<div className="relative size-full">
{/* Outer atmospheric halo. Scaled larger than the wrapper so light
spills out into the photo, blurred for softness, and tinted
with the active campaign's hopeful hue. Breathes slowly via
the .hero-globe-halo-breath class defined in index.css. */}
<div
className="hero-globe-halo-breath absolute inset-[-15%] pointer-events-none"
aria-hidden="true"
style={{
backgroundImage: `radial-gradient(closest-side, ${hue.glow} 0%, ${hue.rim} 30%, transparent 70%)`,
filter: 'blur(40px)',
// background-image isn't actually transitionable across
// gradient stops in CSS, but keeping the declaration here
// documents that the hue swap is driven by React re-renders
// synced to the HeroAtmosphere crossfade.
}}
/>
<svg
viewBox="0 0 600 600"
className="relative size-full"
role="img"
aria-label="Globe showing locations of active fundraising campaigns"
focusable="false"
>
<defs>
{/* Sphere base: warm dawn gold lit from the upper-left, fading
into a deeper honey shadow on the lower-right. The whole
sphere is meant to read as "lit from within" — like the
moment before sunrise — not as a slab of dirt. */}
<radialGradient id="hero-globe-base" cx="32%" cy="28%" r="78%">
<stop offset="0%" stopColor="hsl(46 100% 96% / 0.92)" />
<stop offset="40%" stopColor="hsl(38 90% 82% / 0.82)" />
<stop offset="100%" stopColor="hsl(28 65% 60% / 0.72)" />
</radialGradient>
{/* Back-lit limb light. Reads as light pooling on the inside of
the sphere edge — Earthrise rather than satellite. Tinted
with the active hopeful hue, kept narrow + low-opacity so it
feels like atmosphere, not a neon ring. */}
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
<stop offset="86%" stopColor={hue.rim} stopOpacity="0" />
<stop offset="97%" stopColor={hue.rim} stopOpacity="0.55" />
<stop offset="100%" stopColor={hue.glow} stopOpacity="0" />
</radialGradient>
{/* Soft highlight in the upper-left to sell the sphere shape. */}
<radialGradient id="hero-globe-highlight" cx="30%" cy="25%" r="35%">
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.58)" />
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
</radialGradient>
{/* Marker glow halo. Soft, warm, no pulsing. */}
<radialGradient id="hero-marker-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.55" />
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.12" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Stronger halo used for the selected marker so it visibly leads
the eye to whatever the spotlight card is currently showing. */}
<radialGradient id="hero-marker-glow-active" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
<stop offset="55%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</radialGradient>
{/* Clip everything to the sphere so polygons straddling the
terminator don't leak outside the circle. */}
<clipPath id="hero-globe-clip">
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
</clipPath>
</defs>
{/* Base sphere with light shading. */}
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#hero-globe-base)" />
{/* Landmasses, clipped to the sphere. */}
<g clipPath="url(#hero-globe-clip)">
<g
ref={landRef}
fill="hsl(30 55% 52%)"
stroke="hsl(28 50% 40% / 0.25)"
strokeWidth="0.3"
strokeLinejoin="round"
>
{LANDMASSES.map((_, i) => (
<polygon key={i} opacity={0} />
))}
</g>
</g>
{/* Warm highlight + rim shading sit above the land so the sphere
still reads as a lit ball, not a flat map. */}
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="url(#hero-globe-highlight)"
pointerEvents="none"
/>
<circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="url(#hero-globe-rim)"
pointerEvents="none"
/>
{/* Campaign markers — a small heart glyph with a warm glow halo.
Each marker is a button: clicking selects the campaign, which
the parent uses to populate the spotlight card.
On the Discover page the same `<g>` slots are reused for
community and country-pulse markers, distinguished by `m.kind`
and rendered with a softer glyph + halo so campaigns stay the
visual lead. */}
<g ref={markersRef}>
{markers.map((m) => {
const isSelected = m.key === selectedKey;
const kind: GlobeMarkerKind = m.kind ?? 'campaign';
return (
<g
key={m.key}
opacity={0}
transform="translate(-1000 -1000)"
role={onMarkerClick ? 'button' : undefined}
tabIndex={onMarkerClick ? 0 : undefined}
aria-label={m.label ?? 'View campaign'}
aria-pressed={onMarkerClick ? isSelected : undefined}
onClick={onMarkerClick ? () => onMarkerClick(m.key) : undefined}
onKeyDown={
onMarkerClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onMarkerClick(m.key);
}
}
: undefined
}
style={{
cursor: onMarkerClick ? 'pointer' : undefined,
outline: 'none',
}}
>
{kind === 'campaign' ? (
<>
{/* Glow halo (stronger for the active marker). */}
<circle
r={isSelected ? 16 : 12}
fill={`url(#hero-marker-glow${isSelected ? '-active' : ''})`}
/>
{/* Heart glyph. Path is centered at the origin (~14×12 units)
so the parent <g>'s translate+scale lands it on the globe. */}
<path
d="M0,3.5 C-3.5,1 -7,-1.5 -7,-4.5 C-7,-7 -5,-8.5 -3,-8.5 C-1.5,-8.5 -0.5,-7.5 0,-6.5 C0.5,-7.5 1.5,-8.5 3,-8.5 C5,-8.5 7,-7 7,-4.5 C7,-1.5 3.5,1 0,3.5 Z"
fill="hsl(var(--primary))"
stroke="hsl(40 100% 98%)"
strokeWidth="0.6"
strokeLinejoin="round"
/>
{/* Tiny inner highlight to make the heart pop on the warm
landmass without needing a heavy outline. */}
<ellipse cx={-2.5} cy={-5.5} rx={1.5} ry={1} fill="hsl(40 100% 98% / 0.55)" />
</>
) : kind === 'community' ? (
<>
{/* Community: a softly-glowing ring. Reads as a circle of
people, gathered. Smaller than the heart so campaigns
stay the dominant signal. */}
<circle r={10} fill="url(#hero-marker-glow)" />
<circle
r={4.2}
fill="hsl(40 100% 96% / 0.92)"
stroke="hsl(28 65% 45% / 0.55)"
strokeWidth="0.7"
/>
<circle r={1.4} fill="hsl(28 70% 50%)" />
</>
) : (
<>
{/* Country pulse: tiny warm sun-dot, no halo button feel.
These are decorative — they trace where the world is
currently posting without inviting interaction. */}
<circle r={6} fill="url(#hero-marker-glow)" opacity={0.65} />
<circle r={1.8} fill="hsl(38 100% 70%)" />
</>
)}
{/* Transparent hit target — much easier to click/tap than the
tiny visible glyph, especially on touch. */}
<circle
r={14}
fill="transparent"
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
/>
</g>
);
})}
</g>
</svg>
</div>
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { memo, useId, useMemo } from 'react';
import { LAND_RINGS } from '@/lib/landPolygons';
import { cn } from '@/lib/utils';
/**
* Decorative dark world map with glowing brand-orange Lightning arcs and
* pulsing city nodes. Designed as a hero backdrop on near-black surfaces:
* type sits comfortably over it without any text shadow.
*
* Composition (back to front):
* 1. Equirectangular world map drawn from {@link LAND_RINGS} — barely
* lit so it reads as texture, not focus.
* 2. Central radial glow tinted in brand orange behind the visual
* center of gravity.
* 3. Curated set of arcs between major cities, drawn as quadratic
* Bézier paths with a flowing dash animation (the "lightning" hops).
* 4. Pulsing dot at every endpoint, with a soft halo.
*
* The data is intentionally curated — no campaign coupling. The map is a
* brand visual, not a state visualization. Arc list lives at the bottom
* of this file and can be swapped freely without touching layout.
*
* Pure SVG, no WebGL, no canvas. ~12 arcs + ~150 polygons — render cost
* is negligible. Animations honor `prefers-reduced-motion`.
*/
function HeroLightningMapImpl({ className }: { className?: string }) {
const uid = useId();
const arcId = (key: string) => `${uid}-${key}`;
// viewBox is the equirectangular world: 360 wide × 180 tall, recentered
// so (0,0) is the geographic origin. We project [lng, lat] -> [lng, -lat]
// (SVG y grows downward).
const W = 360;
const H = 180;
const landPaths = useMemo(() => {
return LAND_RINGS.map((ring, idx) => {
// Rings are flat [lng, lat, lng, lat, ...]. Convert to an SVG path.
//
// Antimeridian handling: a few rings (notably Russia and Antarctica
// in the Natural Earth source) cross the ±180° seam. The data stores
// those rings as a single polygon whose longitude jumps from +180 to
// -180 (or vice versa) in one step. Drawn naively with a continuous
// `L` command, that jump renders as a long horizontal slash spanning
// the whole equirectangular viewBox — the "two lines" sitting at
// ~lat 41 and ~lat 77 across the map are exactly Russia's bounding
// edges drawn by such a connection.
//
// Detect any longitude step > 180° and close + restart the subpath
// with `M` instead, so the two halves of the country render in their
// actual hemispheres without a connecting line through the middle.
let d = '';
let prevLng: number | null = null;
for (let i = 0; i < ring.length; i += 2) {
const lng = ring[i];
const lat = ring[i + 1];
const isFirst = i === 0;
const wraps = prevLng !== null && Math.abs(lng - prevLng) > 180;
const cmd = isFirst || wraps ? 'M' : 'L';
d += `${cmd}${lng.toFixed(2)} ${(-lat).toFixed(2)}`;
prevLng = lng;
}
d += 'Z';
return <path key={idx} d={d} />;
});
}, []);
// All endpoints across the curated arc set, deduplicated, so we render
// one pulsing node per city even if multiple arcs share it.
const nodes = useMemo(() => {
const seen = new Map<string, { lng: number; lat: number }>();
for (const arc of CURATED_ARCS) {
const a = `${arc.from[0]},${arc.from[1]}`;
const b = `${arc.to[0]},${arc.to[1]}`;
if (!seen.has(a)) seen.set(a, { lng: arc.from[0], lat: arc.from[1] });
if (!seen.has(b)) seen.set(b, { lng: arc.to[0], lat: arc.to[1] });
}
return Array.from(seen.values());
}, []);
return (
<div
className={cn('absolute inset-0 overflow-hidden pointer-events-none', className)}
aria-hidden="true"
>
{/* Central radial brand-orange glow. Sits behind the map texture so
the map reads as illuminated by it, not pasted over it. Position
biased slightly right so the headline column on the left stays
on the cooler side of the glow. */}
<div
className="absolute -inset-[10%]"
style={{
background:
'radial-gradient(60% 55% at 62% 45%, hsl(24 100% 55% / 0.12) 0%, hsl(24 95% 50% / 0.07) 28%, hsl(220 30% 8% / 0) 65%)',
}}
/>
<svg
viewBox={`-${W / 2} -${H / 2} ${W} ${H}`}
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 w-full h-full"
>
<defs>
{/* Land fill — brand-orange wash, fully opaque. The transparency
lives on the wrapping <g opacity=…> below so that overlapping
country polygons don't stack their alpha at shared borders
(which is what painted the visible "latitude line" along the
equator and other country seams). */}
<linearGradient id={arcId('land')} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(24 80% 50%)" />
<stop offset="100%" stopColor="hsl(24 70% 45%)" />
</linearGradient>
{/* Arc gradient — bright at midpoint, fading at endpoints, so
the line reads as energy traveling rather than a solid wire. */}
<linearGradient id={arcId('arc')} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(24 100% 60%)" stopOpacity="0.0" />
<stop offset="35%" stopColor="hsl(24 100% 60%)" stopOpacity="0.85" />
<stop offset="65%" stopColor="hsl(30 100% 65%)" stopOpacity="0.85" />
<stop offset="100%" stopColor="hsl(30 100% 65%)" stopOpacity="0.0" />
</linearGradient>
{/* Glow filter for arcs and nodes — wider and softer than a CSS
shadow, and crucially, applied inside the SVG so it scales
cleanly with the viewBox. */}
<filter id={arcId('glow')} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Stronger glow for the nodes themselves so they punch through
the arcs at intersections. */}
<radialGradient id={arcId('node-halo')}>
<stop offset="0%" stopColor="hsl(30 100% 70%)" stopOpacity="0.9" />
<stop offset="40%" stopColor="hsl(24 100% 55%)" stopOpacity="0.45" />
<stop offset="100%" stopColor="hsl(24 100% 50%)" stopOpacity="0" />
</radialGradient>
</defs>
{/* Land. Each country is its own ring; rendered as separate paths
with semi-transparent fill, every shared country border doubles
up where polygons overlap. The most jarring of those overlaps
falls along the equator (Kenya/Tanzania, DRC/Angola, Indonesian
islands) and reads as a horizontal "latitude line."
Fix: paint each country with a fully-opaque fill, then put the
transparency on the wrapping <g opacity=…>. SVG group opacity
renders the children into an offscreen buffer first and then
composites the buffer at the group's alpha, so internal overlaps
don't stack. No stroke for the same reason. */}
<g
fill={`url(#${arcId('land')})`}
stroke="none"
opacity="0.18"
>
{landPaths}
</g>
{/* Arcs. Each arc is a quadratic Bézier with the control point
lifted above the great-circle path, giving the curved silhouette
from the reference. Stroke-dasharray + animated stroke-dashoffset
produces the flowing-energy effect.
`vector-effect="non-scaling-stroke"` keeps the stroke at a fixed
pixel width regardless of viewBox-to-screen scale, which is what
kills the line jitter — without it, sub-pixel stroke widths in
user-space combine with the SVG glow filter to shimmer at any
responsive size. */}
<g
fill="none"
stroke={`url(#${arcId('arc')})`}
strokeWidth="1.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
filter={`url(#${arcId('glow')})`}
>
{CURATED_ARCS.map((arc, i) => {
const [x1, y1] = [arc.from[0], -arc.from[1]];
const [x2, y2] = [arc.to[0], -arc.to[1]];
// Lift the control point above the chord, scaled with chord
// length so short arcs stay tight and trans-oceanic arcs
// sweep dramatically.
const len = Math.hypot(x2 - x1, y2 - y1);
const lift = Math.min(60, len * 0.42);
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
// Push the lift toward whichever hemisphere the chord midpoint
// already favors, so equator-crossing arcs sweep clearly into
// their dominant hemisphere instead of all stacking through
// y=0. Pure equatorial midpoints (my≈0) default to lifting
// north (negative y in SVG space).
const direction = my > 0 ? 1 : -1;
const cx = mx;
const cy = my + lift * direction;
return (
<path
key={i}
d={`M${x1.toFixed(2)} ${y1.toFixed(2)} Q${cx.toFixed(2)} ${cy.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}`}
className="hero-arc-flow"
style={{
// Stagger each arc's animation so the flow feels
// organic, not lockstep.
animationDelay: `${(i * 0.43).toFixed(2)}s`,
}}
/>
);
})}
</g>
{/* City nodes. Two layers per node: a soft halo behind, a hot dot
in front. Halo is what reads at distance; dot is what reads up
close. */}
<g>
{nodes.map((n, i) => {
const x = n.lng;
const y = -n.lat;
return (
<g key={i}>
<circle
cx={x}
cy={y}
r="2.2"
fill={`url(#${arcId('node-halo')})`}
className="hero-node-pulse"
style={{ animationDelay: `${(i * 0.31).toFixed(2)}s` }}
/>
<circle
cx={x}
cy={y}
r="0.55"
fill="hsl(36 100% 70%)"
/>
</g>
);
})}
</g>
</svg>
</div>
);
}
export const HeroLightningMap = memo(HeroLightningMapImpl);
/**
* Hand-picked arcs between major activist hubs across continents. Order
* matters only for the staggered animation start times — pairs are
* otherwise independent. Coordinates are `[lng, lat]` in degrees.
*
* Edit freely. Twelve arcs is roughly the sweet spot — fewer feels
* sparse, more turns into a tangle that competes with the headline.
*/
const CURATED_ARCS: ReadonlyArray<{
from: readonly [number, number];
to: readonly [number, number];
}> = [
// Trans-Atlantic — North America ↔ Europe / Africa
{ from: [-74.0, 40.7], to: [-0.1, 51.5] }, // New York → London
{ from: [-122.4, 37.8], to: [13.4, 52.5] }, // San Francisco → Berlin
{ from: [-79.4, 43.7], to: [2.35, 48.9] }, // Toronto → Paris
{ from: [-0.1, 51.5], to: [3.4, 6.5] }, // London → Lagos
// Trans-Pacific — Americas ↔ Asia / Oceania
{ from: [-118.2, 34.0], to: [139.7, 35.7] }, // Los Angeles → Tokyo
{ from: [-99.1, 19.4], to: [121.5, 25.0] }, // Mexico City → Taipei
{ from: [151.2, -33.9], to: [-122.4, 37.8] }, // Sydney → San Francisco
// South America bridges
{ from: [-58.4, -34.6], to: [-43.2, -22.9] }, // Buenos Aires → Rio
{ from: [-43.2, -22.9], to: [3.4, 6.5] }, // Rio → Lagos
// Asia / Africa lattice
{ from: [77.2, 28.6], to: [55.3, 25.3] }, // Delhi → Dubai
{ from: [55.3, 25.3], to: [31.2, 30.0] }, // Dubai → Cairo
{ from: [31.2, 30.0], to: [13.4, 52.5] }, // Cairo → Berlin
{ from: [103.8, 1.35], to: [121.5, 25.0] }, // Singapore → Taipei
{ from: [18.4, -33.9], to: [3.4, 6.5] }, // Cape Town → Lagos
];
+4 -1
View File
@@ -95,6 +95,8 @@ export interface ProfileCardProps {
onRemoveAvatar?: () => void;
/** Show NIP-05 row (default true) */
showNip05?: boolean;
/** Show NIP-58 badge showcase row (default true). */
showBadges?: boolean;
/** When provided, render an editable profile fields section below bio */
extraFields?: ProfileField[];
onExtraFieldsChange?: (fields: ProfileField[]) => void;
@@ -107,6 +109,7 @@ export function ProfileCard({
onPickImage,
onRemoveAvatar,
showNip05 = true,
showBadges = true,
extraFields,
onExtraFieldsChange,
}: ProfileCardProps) {
@@ -332,7 +335,7 @@ export function ProfileCard({
</div>
{/* Badge showcase */}
{(badgeRefs.length > 0 || badgesLoading) && (
{showBadges && (badgeRefs.length > 0 || badgesLoading) && (
<div className="px-4 pb-3">
<BadgeShowcaseGrid
items={badgeRefs.map((ref) => ({
+16 -2
View File
@@ -82,6 +82,16 @@ interface ProfileRightSidebarProps {
onMediaClick?: (url: string) => void;
/** Override the root element's className (e.g. to show on mobile). */
className?: string;
/**
* Layout variant.
*
* - `'rail'` (default) — legacy 1/4-width fixed sidebar with full-height
* sticky scrolling. Designed for the old MainLayout shell.
* - `'inline'` — fills its parent's width with no positioning, so it can
* be slotted into a grid cell or another container that manages
* layout (e.g. the ProfilePage two-column body).
*/
variant?: 'rail' | 'inline';
}
interface MediaItem {
@@ -495,7 +505,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
return rows;
}
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className, variant = 'rail' }: ProfileRightSidebarProps) {
const { config } = useAppContext();
const { nostr } = useNostr();
@@ -534,8 +544,12 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
const rootClass = variant === 'inline'
? 'flex flex-col w-full'
: 'w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3';
return (
<aside className={cn("w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
<aside className={cn(rootClass, className)}>
{/* Media Section — only shown when pubkey prop is provided */}
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
@@ -0,0 +1,73 @@
import { useState, type ReactNode } from 'react';
import { Users } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CommunityMiniCard } from '@/components/discovery/CommunityMiniCard';
import type { ProfileOrganization } from '@/hooks/useProfileOrganizations';
import { cn } from '@/lib/utils';
interface OrganizationsAllDialogProps {
/** Full list of organizations to surface in the overflow dialog. */
orgs: ProfileOrganization[];
/** Trigger element — typically a "See all N →" button. */
children: ReactNode;
}
/**
* "See all organizations" dialog used by the profile identity rail.
*
* Renders every org the profile is associated with in a scrollable
* 2-column grid of CommunityMiniCards with role badges. Lifted out of
* the (now-deleted) ProfileOrganizationsStrip so the rail can use it
* directly without pulling in the strip's full file.
*/
export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
All organizations
<span className="text-sm font-normal text-muted-foreground">({orgs.length})</span>
</DialogTitle>
</DialogHeader>
<ScrollArea className="px-6 py-4 flex-1">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{orgs.map((entry) => (
<div key={entry.community.aTag} className="relative">
<CommunityMiniCard community={entry.community} className="w-full" />
<Badge
variant="secondary"
className={cn(
'absolute top-2 left-2 backdrop-blur bg-background/90 border-border/40 text-[10px] font-semibold uppercase tracking-wide',
entry.isFounder ? 'text-primary' : 'text-foreground',
)}
>
{entry.isFounder ? 'Founder' : 'Moderator'}
</Badge>
</div>
))}
</div>
</ScrollArea>
<div className="px-6 pb-6 pt-2 flex justify-end">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2, Sparkles } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteCard } from '@/components/NoteCard';
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
interface ProfileActivityTabProps {
pubkey: string;
displayName: string;
}
/**
* Unified profile feed scoped to one author.
*
* Pipes {@link useAgoraFeed} through with `authors=[pubkey]` AND
* `includeAuthorNotes: true`, so the relay-side filter pulls in:
*
* - Agora-marked content (campaigns, pledges, communities, marked notes,
* Agora-rooted comments, donation receipts), and
* - every kind 1 / 6 note this author has published, regardless of the
* `t:agora` marker.
*
* The two sources merge into a single chronological timeline so the
* profile shows "everything this person has done on the network." Replaces
* the previous separate Activity + Posts tabs.
*
* Single-column inside the tab area because the timeline is mixed-kind
* and benefits from full-width cards.
*/
export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) {
const {
events,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useAgoraFeed(true, { authors: [pubkey], includeAuthorNotes: true });
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading && events.length === 0) {
return (
<div className="px-4 sm:px-6 py-6 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</Card>
))}
</div>
);
}
if (events.length === 0) {
return (
<div className="px-4 sm:px-6 py-12">
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
No activity from {displayName} yet. Posts, campaigns, pledges,
and donations all show up here.
</p>
</div>
</Card>
</div>
);
}
return (
<div>
<div className="divide-y divide-border">
{events.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
{hasNextPage && (
<div ref={scrollRef} className="flex justify-center py-6">
{isFetchingNextPage && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,222 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Megaphone } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueries } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import {
extractOnchainZapTxid,
verifyOnchainZap,
} from '@/hooks/useOnchainZaps';
import type { ParsedCampaign } from '@/lib/campaign';
interface ProfileCampaignsTabProps {
pubkey: string;
displayName: string;
isOwnProfile: boolean;
campaigns: ParsedCampaign[];
isLoading: boolean;
}
type SortMode = 'top' | 'new';
/**
* Full grid of every campaign authored by this profile.
*
* Owner / moderator can toggle "Show hidden" to see campaigns the
* moderation pack has hidden from the home page — visitors only see
* non-hidden campaigns by default. Sort modes mirror
* {@link AllCampaignsPage}: New (newest created_at first, the default
* incoming order) and Top (most sats raised, requires the verified
* donation totals).
*/
export function ProfileCampaignsTab({
pubkey,
displayName,
isOwnProfile,
campaigns,
isLoading,
}: ProfileCampaignsTabProps) {
const { user } = useCurrentUser();
const { data: moderation } = useCampaignModeration();
const { data: moderators } = useCampaignModerators();
const isModerator = !!user && (moderators ?? []).includes(user.pubkey);
const [sortMode, setSortMode] = useState<SortMode>('new');
const [showHidden, setShowHidden] = useState(false);
const canShowHidden = isOwnProfile || isModerator;
const filtered = useMemo(() => {
if (canShowHidden && showHidden) return campaigns;
return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
}, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]);
if (isLoading && filtered.length === 0) {
return (
<div className="px-4 sm:px-6 py-6">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<CampaignCardSkeleton key={i} />
))}
</div>
</div>
);
}
if (filtered.length === 0) {
return (
<div className="px-4 sm:px-6 py-12" data-pubkey={pubkey}>
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? "You haven't launched a campaign yet."
: `${displayName} hasn't launched a campaign yet.`}
</p>
{isOwnProfile && (
<Link
to="/campaigns/new"
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
Start a campaign
</Link>
)}
</div>
</Card>
</div>
);
}
return (
<div className="px-4 sm:px-6 py-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'}
</p>
<div className="flex items-center gap-2">
<Button
variant={sortMode === 'new' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setSortMode('new')}
>
New
</Button>
<Button
variant={sortMode === 'top' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setSortMode('top')}
>
Top
</Button>
{canShowHidden && (
<Button
variant={showHidden ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setShowHidden((v) => !v)}
>
{showHidden ? 'Hide hidden' : 'Show hidden'}
</Button>
)}
</div>
</div>
{sortMode === 'top' ? (
<SortedByTopGrid campaigns={filtered} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{filtered.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
)}
</div>
);
}
/**
* Sorts the visible campaigns by verified sats raised (descending) by
* fanning out one receipts query + per-receipt verification across all
* campaigns at once. Uses `useQueries`, so the hook call count is
* deterministic per render (one queries-tuple, not one hook per campaign)
* and the rules of hooks hold.
*
* Caches share keys with `useCampaignDonations` so the verifier results
* are reused across the profile and any other view of the same campaign.
*/
function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraApis } = config;
// Only on-chain campaigns can have verifiable totals. SP campaigns sort to 0.
const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
// Step 1: one receipts query per on-chain campaign.
const receiptsQueries = useQueries({
queries: onchain.map((campaign) => ({
queryKey: ['campaign-donations', 'events', campaign.aTag],
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
return nostr.query(
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
{ signal },
);
},
staleTime: 15_000,
})),
});
// Step 2: dedupe receipts by txid (earliest wins, matching useCampaignDonations).
const verificationInputs: Array<{ aTag: string; wallet: string; event: NostrEvent }> = [];
for (let i = 0; i < onchain.length; i++) {
const campaign = onchain[i];
const wallet = campaign.wallet?.value;
if (!wallet) continue;
const receipts = receiptsQueries[i]?.data ?? [];
const ascending = [...receipts].sort((a, b) => a.created_at - b.created_at);
const seenTxids = new Set<string>();
for (const event of ascending) {
const txid = extractOnchainZapTxid(event);
if (!txid || seenTxids.has(txid)) continue;
seenTxids.add(txid);
verificationInputs.push({ aTag: campaign.aTag, wallet, event });
}
}
const verifications = useQueries({
queries: verificationInputs.map(({ wallet, event }) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, wallet],
queryFn: () => verifyOnchainZap(event, esploraApis, wallet),
staleTime: 60_000,
})),
});
// Step 3: sum verified sats per campaign aTag.
const totalsByCoord = new Map<string, number>();
for (let i = 0; i < verifications.length; i++) {
const { aTag } = verificationInputs[i];
const sats = verifications[i].data?.amountSats ?? 0;
totalsByCoord.set(aTag, (totalsByCoord.get(aTag) ?? 0) + sats);
}
const sorted = [...campaigns].sort(
(a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0),
);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{sorted.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
</div>
);
}
@@ -0,0 +1,826 @@
import type { ReactNode } from 'react';
import { Link, Link as RouterLink } from 'react-router-dom';
import {
Bitcoin,
CalendarClock,
Globe,
HandHeart,
Megaphone,
MoreHorizontal,
QrCode,
Users,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { BioContent } from '@/components/BioContent';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { EmojifiedText } from '@/components/CustomEmoji';
import { FollowToggleButton } from '@/components/FollowButton';
import { Nip05Badge } from '@/components/Nip05Badge';
import { ProfileReactionButton } from '@/components/ProfileReactionButton';
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
import type { ParsedCampaign } from '@/lib/campaign';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { formatNumber } from '@/lib/formatNumber';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import type { Action } from '@/hooks/useActions';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
export interface ProfileIdentityRailProps {
pubkey: string;
/** Whether the logged-in user is viewing their own profile. */
isOwnProfile: boolean;
/** Resolved kind 0 metadata, if any. */
metadata: NostrMetadata | undefined;
/** Raw kind 0 event — needed for emoji tag rendering on display name. */
metadataEvent: NostrEvent | undefined;
/** Pre-resolved display name (with `genUserName` fallback applied upstream). */
displayName: string;
/** True while the kind-0 author query is still in flight; renders skeletons. */
isAuthorLoading: boolean;
/** Banner image URL — used to wire the avatar lightbox to the same url. */
bannerUrl: string | undefined;
/** Optional NIP-38 status (renders as a thought bubble next to the avatar). */
status?: { text: string | undefined; url: string | undefined };
/** Custom kind-0 profile fields, already parsed. */
fields: { label: string; value: string }[];
/** Pre-rendered list of <ProfileFieldInline /> nodes — keeps that helper inside ProfilePage. */
fieldsContent: ReactNode;
/** Campaigns authored by this profile (newest-first). */
campaigns: ParsedCampaign[];
/** Aggregated campaign + raised stats for the stat block. */
campaignStats: ProfileCampaignStats;
/** Pledges (kind 36639) created by this profile. */
pledgesCount: number;
/**
* The profile's pledges (kind 36639) — used to surface the latest one
* in the rail when the profile has no campaigns. The rail picks the
* newest by `createdAt` itself, so callers can pass the unsorted list.
*/
pledges: Action[];
/** Spot BTC price for the Raised stat row. */
btcPrice: number | undefined;
followersCount: number;
followingCount: number;
isFollowing: boolean;
followPending: boolean;
onLightbox: (url: string) => void;
onFollowersOpen: () => void;
onFollowingOpen: () => void;
onMoreMenuOpen: () => void;
onFollowQROpen: () => void;
onToggleFollow: () => void;
onTabChange: (tabId: string) => void;
onDonate: (campaign: ParsedCampaign) => void;
/** Whether the viewer can take any action (logged in). Disables follow when null. */
canFollow: boolean;
/** Latest kind-0 event used by ProfileReactionButton; falls back to metadataEvent. */
authorEvent: NostrEvent | undefined;
}
const RAIL_CAMPAIGN_LIMIT = 2;
const RAIL_ORG_LIMIT = 4;
/**
* ProfileIdentityRail — the left rail of the two-column profile.
*
* Holds everything that's a *standing fact* about the profile: who they
* are (avatar, name, bio), what they're raising for (active campaigns),
* who they organize with (orgs), key counts (followers / following /
* campaigns / pledges / raised), and the freeform profile fields.
*
* Sticky on `lg+` so it stays visible while the right tab column scrolls.
* Below `lg` the rail just stacks above the content — its avatar still
* overlaps the banner via `-mt-16` because the rail is the first child
* below the banner element.
*
* The rail does NOT own the tab bar or the tab content — those live in
* the right column. Click handlers like `onTabChange` exist so rail rows
* can switch tabs (e.g. "See all campaigns →" jumps to the Campaigns tab).
*/
export function ProfileIdentityRail({
pubkey,
isOwnProfile,
metadata,
metadataEvent,
displayName,
isAuthorLoading,
bannerUrl: _bannerUrl,
status,
fields,
fieldsContent,
campaigns,
campaignStats,
pledgesCount,
pledges,
btcPrice,
followersCount,
followingCount,
isFollowing,
followPending,
onLightbox,
onFollowersOpen,
onFollowingOpen,
onMoreMenuOpen,
onFollowQROpen,
onToggleFollow,
onTabChange,
onDonate,
canFollow,
authorEvent,
}: ProfileIdentityRailProps) {
if (isAuthorLoading) {
return (
<RailSkeleton />
);
}
const websiteHref = (() => {
if (!metadata?.website) return undefined;
const candidate = metadata.website.startsWith('http')
? metadata.website
: `https://${metadata.website}`;
return sanitizeUrl(candidate);
})();
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
return (
// Two-layer structure so the rail can scroll independently on lg+
// without clipping the avatar that pokes above the rail's top edge:
// - Outer flex column owns the avatar (which uses -mt-16 to overlap
// the banner). It must NOT clip overflow.
// - Inner div carries the rest of the rail and is the scroll
// container: `lg:flex-1 lg:min-h-0 lg:overflow-y-auto` makes it
// fill the remaining height of the sticky aside and scroll
// internally so the page's main scroll only drives the feed.
<div className="flex flex-col h-full">
{/* Avatar — overlaps the banner from inside the rail. Sits OUTSIDE
the scroll container so its negative-margin overhang is never
clipped by `overflow-y-auto`. */}
<AvatarBlock
metadata={metadata}
displayName={displayName}
status={status}
onLightbox={onLightbox}
/>
<div className="flex flex-col gap-5 mt-5 lg:flex-1 lg:min-h-0 lg:overflow-y-auto pb-4">
{/* Identity: name + NIP-05 + website + bio */}
<div className="space-y-1.5">
<h1 className="text-xl font-bold leading-tight break-words">
{metadataEvent ? (
<EmojifiedText tags={metadataEvent.tags}>{displayName}</EmojifiedText>
) : displayName}
</h1>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="text-sm text-muted-foreground" />
)}
{websiteHref && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Globe className="size-3.5 shrink-0" />
<a
href={websiteHref}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
>
{metadata!.website!.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
</div>
)}
{metadata?.about && (
<p className="pt-1 text-sm whitespace-pre-wrap break-words text-foreground/90">
<BioContent tags={metadataEvent?.tags}>{metadata.about}</BioContent>
</p>
)}
</div>
{/* Action bar — wraps onto multiple rows in a 340px-wide rail. */}
<ActionBar
isOwnProfile={isOwnProfile}
isFollowing={isFollowing}
followPending={followPending}
canFollow={canFollow}
onToggleFollow={onToggleFollow}
onMoreMenuOpen={onMoreMenuOpen}
onFollowQROpen={onFollowQROpen}
authorEvent={authorEvent}
onchainCampaigns={onchainCampaigns}
onDonate={onDonate}
/>
{/* Stats: Followers + Following inline; Pledges + Raised below. */}
<StatList
followersCount={followersCount}
followingCount={followingCount}
pledgesCount={pledgesCount}
totalRaisedSats={campaignStats.totalRaisedSats}
btcPrice={btcPrice}
onFollowersOpen={onFollowersOpen}
onFollowingOpen={onFollowingOpen}
onTabChange={onTabChange}
/>
{/* Active campaigns */}
<RailCampaignsSection
campaigns={campaigns}
isOwnProfile={isOwnProfile}
isLoading={campaignStats.isVerifying && campaigns.length === 0}
onSeeAll={() => onTabChange('campaigns')}
/>
{/* Latest pledge — surfaced as a fallback when this profile has
nothing in the Campaigns slot, so the rail still has a piece of
first-class Agora content above the orgs/fields sections. */}
{campaigns.length === 0 && pledges.length > 0 && (
<RailLatestPledgeSection
pledges={pledges}
btcPrice={btcPrice}
showSeeAll={pledges.length > 1}
onSeeAll={() => onTabChange('pledges')}
/>
)}
{/* Organizations */}
<RailOrganizationsSection pubkey={pubkey} />
{/* Profile fields (rendered upstream) */}
{fields.length > 0 && (
<section className="space-y-3">
<RailSectionHeader icon={null} title="Profile" />
<div className="space-y-3">{fieldsContent}</div>
</section>
)}
</div>
</div>
);
}
// ─── Avatar block ────────────────────────────────────────────────────────────
function AvatarBlock({
metadata,
displayName,
status,
onLightbox,
}: {
metadata: NostrMetadata | undefined;
displayName: string;
status: { text: string | undefined; url: string | undefined } | undefined;
onLightbox: (url: string) => void;
}) {
const picture = metadata?.picture;
return (
<div className="relative">
<button
className="relative z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-full -mt-16 md:-mt-20 block"
onClick={() => picture && onLightbox(picture)}
disabled={!picture}
>
<Avatar className={cn(
'size-28 md:size-32 border-4 border-background shadow-lg',
picture && 'cursor-pointer',
)}>
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-3xl">
{displayName[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</button>
{/* NIP-38 thought bubble — floats to the right of the avatar over the banner area. */}
{status?.text && (
<div className="absolute top-2 left-[calc(7rem+8px)] md:left-[calc(8rem+8px)] z-10 max-w-[200px] animate-in fade-in slide-in-from-left-1 duration-300">
<div className="relative bg-background/90 backdrop-blur-sm border border-border rounded-xl px-3 py-1.5 shadow-lg">
<p className="text-xs text-foreground italic truncate">
{status.url ? (
<a href={status.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
{status.text}
</a>
) : (
status.text
)}
</p>
{/* Speech bubble triangle tail */}
<div className="absolute -bottom-[7px] left-1 size-0 border-t-[8px] border-t-border border-r-[8px] border-r-transparent" />
<div className="absolute -bottom-[5.5px] left-1 size-0 border-t-[7px] border-t-background border-r-[7px] border-r-transparent" />
</div>
</div>
)}
</div>
);
}
// ─── Action bar ──────────────────────────────────────────────────────────────
function ActionBar({
isOwnProfile,
isFollowing,
followPending,
canFollow,
onToggleFollow,
onMoreMenuOpen,
onFollowQROpen,
authorEvent,
onchainCampaigns,
onDonate,
}: {
isOwnProfile: boolean;
isFollowing: boolean;
followPending: boolean;
canFollow: boolean;
onToggleFollow: () => void;
onMoreMenuOpen: () => void;
onFollowQROpen: () => void;
authorEvent: NostrEvent | undefined;
onchainCampaigns: ParsedCampaign[];
onDonate: (campaign: ParsedCampaign) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
{isOwnProfile ? (
<>
<Link to="/settings/profile" className="flex-1 min-w-[140px]">
<Button variant="outline" className="rounded-full font-bold w-full">
Edit profile
</Button>
</Link>
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
title="Share follow link"
onClick={onFollowQROpen}
>
<QrCode className="size-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title="More options"
>
<MoreHorizontal className="size-5" />
</Button>
</>
) : (
<>
<FollowToggleButton
isFollowing={isFollowing}
isPending={followPending}
onClick={onToggleFollow}
disabled={!canFollow}
/>
{onchainCampaigns.length === 1 ? (
<Button
onClick={() => onDonate(onchainCampaigns[0])}
className="rounded-full font-bold gap-1.5"
>
<HandHeart className="size-4" />
Donate
</Button>
) : onchainCampaigns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-full font-bold gap-1.5">
<HandHeart className="size-4" />
Donate
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{onchainCampaigns.map((c) => (
<DropdownMenuItem
key={c.aTag}
onClick={() => onDonate(c)}
className="flex flex-col items-start gap-0.5"
>
<span className="font-medium truncate w-full">{c.title}</span>
{c.goalUsd ? (
<span className="text-xs text-muted-foreground">
Goal ${c.goalUsd.toLocaleString()}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{authorEvent && <ProfileReactionButton profileEvent={authorEvent} />}
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
onClick={onMoreMenuOpen}
title="More options"
>
<MoreHorizontal className="size-5" />
</Button>
</>
)}
</div>
);
}
// ─── Stat list ──────────────────────────────────────────────────────────────
function StatList({
followersCount,
followingCount,
pledgesCount,
totalRaisedSats,
btcPrice,
onFollowersOpen,
onFollowingOpen,
onTabChange,
}: {
followersCount: number;
followingCount: number;
pledgesCount: number;
totalRaisedSats: number;
btcPrice: number | undefined;
onFollowersOpen: () => void;
onFollowingOpen: () => void;
onTabChange: (id: string) => void;
}) {
// Secondary stat rows (one per row). Followers / Following live inline at
// the top; the campaign count was dropped because the rail's Campaigns
// section already displays the user's campaigns directly below.
const rows: Array<{
icon?: ReactNode;
label: string;
value: string;
onClick?: () => void;
show: boolean;
}> = [
{
icon: <HandHeart className="size-3.5 text-primary" />,
label: pledgesCount === 1 ? 'Pledge' : 'Pledges',
value: formatNumber(pledgesCount),
onClick: () => onTabChange('pledges'),
show: pledgesCount > 0,
},
{
icon: <Bitcoin className="size-3.5 text-primary" />,
label: 'Raised',
value: formatCampaignAmount(totalRaisedSats, btcPrice),
onClick: () => onTabChange('campaigns'),
show: totalRaisedSats > 0,
},
].filter((r) => r.show);
const hasFollowRow = followersCount > 0 || followingCount > 0;
if (!hasFollowRow && rows.length === 0) return null;
return (
<div className="space-y-2">
{/* Followers + Following on a single horizontal row. */}
{hasFollowRow && (
<div className="flex items-center gap-5 text-sm">
{followersCount > 0 && (
<button
onClick={onFollowersOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={`${followersCount} followers`}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followersCount)}</span>
<span className="text-muted-foreground">Followers</span>
</button>
)}
{followingCount > 0 && (
<button
onClick={onFollowingOpen}
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
title={`${followingCount} following`}
>
<span className="font-bold tabular-nums text-foreground">{formatNumber(followingCount)}</span>
<span className="text-muted-foreground">Following</span>
</button>
)}
</div>
)}
{/* Secondary stat rows. */}
{rows.length > 0 && (
<div className="rounded-xl border border-border/60 bg-card/40 divide-y divide-border/60">
{rows.map((row) => (
<button
key={row.label}
onClick={row.onClick}
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm hover:bg-secondary/40 transition-colors first:rounded-t-xl last:rounded-b-xl"
>
<span className="flex items-center gap-2 text-muted-foreground">
{row.icon}
{row.label}
</span>
<span className="font-semibold tabular-nums text-foreground">{row.value}</span>
</button>
))}
</div>
)}
</div>
);
}
// ─── Rail Campaigns Section ─────────────────────────────────────────────────
function RailCampaignsSection({
campaigns,
isOwnProfile,
isLoading,
onSeeAll,
}: {
campaigns: ParsedCampaign[];
isOwnProfile: boolean;
isLoading: boolean;
onSeeAll: () => void;
}) {
const { data: moderation } = useCampaignModeration();
const visible = isOwnProfile
? campaigns
: campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
if (isLoading && visible.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Megaphone className="size-4 text-primary" />} title="Campaigns" />
<CampaignCardSkeleton />
</section>
);
}
if (visible.length === 0) return null;
const shown = visible.slice(0, RAIL_CAMPAIGN_LIMIT);
const more = visible.length - shown.length;
return (
<section className="space-y-3">
<RailSectionHeader
icon={<Megaphone className="size-4 text-primary" />}
title="Campaigns"
count={visible.length}
/>
<div className="space-y-3">
{shown.map((c) => (
<CampaignCard key={c.aTag} campaign={c} />
))}
</div>
{(more > 0 || visible.length > 1) && (
<button
type="button"
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
{more > 0 ? `See all ${visible.length} campaigns →` : 'View campaigns tab →'}
</button>
)}
</section>
);
}
// ─── Rail Latest Pledge Section ─────────────────────────────────────────────
/**
* Compact "latest pledge" card shown in the rail when the profile has
* no campaigns. Picks the newest pledge from the supplied list (sorted
* by `createdAt` descending) and renders it as a single small card with
* cover, title, pledged amount, country, and deadline.
*/
function RailLatestPledgeSection({
pledges,
btcPrice,
showSeeAll,
onSeeAll,
}: {
pledges: Action[];
btcPrice: number | undefined;
showSeeAll: boolean;
onSeeAll: () => void;
}) {
// Pick the newest pledge by created_at. The page query is roughly
// newest-first already, but sorting here keeps the rail correct
// regardless of upstream order.
const latest = [...pledges].sort((a, b) => b.createdAt - a.createdAt)[0];
if (!latest) return null;
return (
<section className="space-y-3">
<RailSectionHeader
icon={<HandHeart className="size-4 text-primary" />}
title="Latest pledge"
/>
<RailPledgeCard action={latest} btcPrice={btcPrice} />
{showSeeAll && (
<button
type="button"
onClick={onSeeAll}
className="text-sm text-primary hover:underline font-medium"
>
See all {pledges.length} pledges
</button>
)}
</section>
);
}
/**
* Compact pledge card sized for the rail's narrow column. Smaller cover
* aspect, tighter padding, and a single-line pledged amount that doesn't
* dominate the rail.
*/
function RailPledgeCard({
action,
btcPrice,
}: {
action: Action;
btcPrice: number | undefined;
}) {
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const cover = sanitizeUrl(action.image) ?? DEFAULT_COVER_IMAGE;
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
const isExpired = !!deadline?.isPast;
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
return (
<RouterLink
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow group-hover:shadow-md">
<div className="relative aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={cover}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
{isExpired && (
<Badge
variant="secondary"
className="absolute top-2 right-2 backdrop-blur bg-background/85 border-border/40 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Ended
</Badge>
)}
</div>
<div className="p-3 space-y-1.5">
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{action.title}</h3>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="text-muted-foreground uppercase tracking-wide font-semibold">Pledged</span>
<span className="text-foreground font-bold tabular-nums">
{formatPledgeAmount(action.bounty, btcPrice)}
</span>
</div>
{(countryLabel || deadline) && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground pt-0.5">
{deadline && (
<span className={cn(
'inline-flex items-center gap-1',
deadline.isPast && 'text-destructive',
)}>
<CalendarClock className="size-3" />
{deadline.label}
</span>
)}
{countryLabel && <span className="truncate">{countryLabel}</span>}
</div>
)}
</div>
</Card>
</RouterLink>
);
}
// ─── Rail Organizations Section ─────────────────────────────────────────────
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
const { data: orgs, isLoading } = useProfileOrganizations(pubkey);
if (isLoading && orgs.length === 0) {
return (
<section className="space-y-3">
<RailSectionHeader icon={<Users className="size-4 text-primary" />} title="Organizations" />
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<CommunityMiniCardSkeleton key={i} className="w-full" />
))}
</div>
</section>
);
}
if (orgs.length === 0) return null;
const shown = orgs.slice(0, RAIL_ORG_LIMIT);
const overflow = Math.max(0, orgs.length - shown.length);
return (
<section className="space-y-3">
<RailSectionHeader
icon={<Users className="size-4 text-primary" />}
title="Organizations"
count={orgs.length}
/>
<div className="grid grid-cols-2 gap-3">
{shown.map((entry) => (
<RailOrgCell key={entry.community.aTag} entry={entry} />
))}
</div>
{overflow > 0 && (
<OrganizationsAllDialog orgs={orgs}>
<button
type="button"
className="text-sm text-primary hover:underline font-medium"
>
See all {orgs.length}
</button>
</OrganizationsAllDialog>
)}
</section>
);
}
function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
return (
<div className="relative">
<CommunityMiniCard community={entry.community} className="w-full" />
<Badge
variant="secondary"
className={cn(
'absolute top-2 left-2 backdrop-blur bg-background/90 border-border/40 text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5',
entry.isFounder ? 'text-primary' : 'text-foreground',
)}
>
{entry.isFounder ? 'Founder' : 'Mod'}
</Badge>
</div>
);
}
// ─── Section header & skeleton ──────────────────────────────────────────────
function RailSectionHeader({
icon,
title,
count,
}: {
icon: ReactNode;
title: string;
count?: number;
}) {
return (
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{icon}
<span>{title}</span>
{count !== undefined && count > 0 && (
<span className="text-xs font-normal normal-case text-muted-foreground/70">({count})</span>
)}
</h2>
);
}
function RailSkeleton() {
return (
<div className="flex flex-col gap-5">
<Skeleton className="size-28 md:size-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-full mt-2" />
<Skeleton className="h-4 w-3/4" />
</div>
<Skeleton className="h-10 w-full rounded-full" />
<Skeleton className="h-32 w-full rounded-xl" />
</div>
);
}
@@ -0,0 +1,220 @@
import { useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { getGeoDisplayName } from '@/lib/countries';
import { cn } from '@/lib/utils';
import type { Action } from '@/hooks/useActions';
interface ProfilePledgesTabProps {
pubkey: string;
displayName: string;
isOwnProfile: boolean;
/** Pledges authored by this pubkey. Already filtered upstream. */
pledges: Action[];
/** BTC price for sats↔USD conversion in pledge amount labels. */
btcPrice: number | undefined;
/** True while the underlying useActions() query is still in flight. */
isLoading: boolean;
}
/**
* Pledges authored by this profile, rendered as a responsive grid that
* mirrors the `/pledges` (`ActionsPage`) directory styling.
*
* v1 scope per the design plan: pledges *created* by the user.
* "Pledges backed" (zapped submissions on others' pledges) is deferred to v2.
*/
export function ProfilePledgesTab({
pubkey,
displayName,
isOwnProfile,
pledges,
btcPrice,
isLoading,
}: ProfilePledgesTabProps) {
const now = Math.floor(Date.now() / 1000);
// Loading skeleton until the first list resolves.
if (isLoading && pledges.length === 0) {
return (
<div className="px-4 sm:px-6 py-6">
<PledgesGridSkeleton />
</div>
);
}
if (pledges.length === 0) {
return (
<div className="px-4 sm:px-6 py-12">
<Card className="border-dashed">
<div className="py-12 px-8 text-center">
<HandHeart className="size-10 mx-auto mb-3 text-muted-foreground/40" />
<p className="text-muted-foreground max-w-sm mx-auto">
{isOwnProfile
? "You haven't created a pledge yet."
: `${displayName} hasn't created a pledge yet.`}
</p>
{isOwnProfile && (
<RouterLink
to="/pledges/new"
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
>
Create a pledge
</RouterLink>
)}
</div>
</Card>
</div>
);
}
// Split into active vs ended so the active pledges lead the grid.
const active: Action[] = [];
const ended: Action[] = [];
for (const p of pledges) {
if (p.deadline && p.deadline <= now) ended.push(p);
else active.push(p);
}
return (
<div className="px-4 sm:px-6 py-6 space-y-8" data-pubkey={pubkey}>
{active.length > 0 && (
<section>
{ended.length > 0 && (
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Active
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{active.map((pledge) => (
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
))}
</div>
</section>
)}
{ended.length > 0 && (
<section>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Ended
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{ended.map((pledge) => (
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
))}
</div>
</section>
)}
</div>
);
}
function ProfilePledgeCard({
action,
isExpired,
btcPrice,
}: {
action: Action;
isExpired?: boolean;
btcPrice: number | undefined;
}) {
const [imageLoadFailed, setImageLoadFailed] = useState(false);
const naddr = nip19.naddrEncode({
kind: 36639,
pubkey: action.pubkey,
identifier: action.id,
});
const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE;
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
return (
<RouterLink
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
{isExpired && (
<Badge
variant="secondary"
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40 text-muted-foreground"
>
Ended
</Badge>
)}
</div>
<div className="flex flex-col gap-3 p-5 flex-1">
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
{action.title}
</h3>
{action.description.trim() && (
<p className="text-sm text-muted-foreground line-clamp-2">{action.description}</p>
)}
<div className="flex-1" />
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">Pledged</p>
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
{formatPledgeAmount(action.bounty, btcPrice)}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" />
{countryLabel}
</span>
)}
{deadline && (
<span className={cn(
'inline-flex items-center gap-1.5',
deadline.isPast && 'text-destructive',
)}>
<CalendarClock className="size-3.5" />
{deadline.label}
</span>
)}
</div>
</div>
</Card>
</RouterLink>
);
}
function PledgesGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="overflow-hidden border-border/70">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</Card>
))}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useNavHidden } from '@/contexts/LayoutContext';
export interface ProfileTabsProps {
tabs: Array<{ id: string; label: string }>;
activeTab: string;
onChange: (id: string) => void;
}
/**
* Profile-local tab bar.
*
* A focused alternative to the global `SubHeaderBar` — no arc decoration,
* no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
* row with an animated underline marking the active tab. Designed for the
* 4-tab profile case (Activity / Campaigns / Pledges / Posts).
*
* Behavior:
* - Sticks to the top of its containing scroll context. The parent column
* can place it inside any scroll region; the bar uses `position: sticky`.
* - Underline animates between active tabs via a single absolute-positioned
* indicator measured from the active tab's offset/width.
* - Horizontally scrolls when overflowing; auto-scrolls the active tab into
* view on selection (matches the previous TabButton behavior).
*/
export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) {
const trackRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
const navHidden = useNavHidden();
// Measure the active tab and update the underline indicator.
useLayoutEffect(() => {
const btn = tabRefs.current.get(activeTab);
if (!btn) {
setIndicator(null);
return;
}
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
}, [activeTab, tabs]);
// Recompute on resize (label-width changes between breakpoints).
useEffect(() => {
const onResize = () => {
const btn = tabRefs.current.get(activeTab);
if (!btn) return;
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [activeTab]);
// Scroll the active tab into view when activated (overflow scroll case).
useLayoutEffect(() => {
const btn = tabRefs.current.get(activeTab);
const track = trackRef.current;
if (!btn || !track) return;
const left = btn.offsetLeft;
const right = left + btn.offsetWidth;
const viewLeft = track.scrollLeft;
const viewRight = viewLeft + track.clientWidth;
if (left < viewLeft) {
track.scrollTo({ left: left - 8, behavior: 'smooth' });
} else if (right > viewRight) {
track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' });
}
}, [activeTab]);
return (
<div
className={cn(
// Stickiness — sits at the top of the column scroll. `top-mobile-bar`
// matches the existing app convention so it sits flush with the
// mobile top nav. On desktop the chrome shifts and we use top-0.
'sticky top-mobile-bar sidebar:top-0 z-10',
// On mobile, fade + slide fully out of view when the user scrolls
// down — otherwise the tabs sit at `top-mobile-bar` while the top
// bar slides away, leaving a translucent gap above them, and when
// the top bar slides back in it visibly crosses over the top of
// the tab bar (top bar is z-20, tabs z-10).
//
// We can't simply use the shared `.nav-hidden-slide` utility (as
// the global `SubHeaderBar` does) because the profile tab bar is
// notably taller than other sub-headers and visibly gets clipped
// by the top bar mid-transition. Pair the slide with an opacity
// fade so the bar isn't visibly intersecting the top bar as it
// animates.
'max-sidebar:transition-[transform,opacity] max-sidebar:duration-300 max-sidebar:ease-in-out',
navHidden && 'nav-hidden-slide max-sidebar:opacity-0',
// Visual separation — translucent backdrop so feed content doesn't
// bleed through, with a single hairline border below.
'bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60',
'border-b border-border/60',
)}
>
<div
ref={trackRef}
className="relative flex overflow-x-auto scrollbar-none"
>
{tabs.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
onClick={() => {
if (active) {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0 });
onChange(tab.id);
}
}}
className={cn(
'relative shrink-0 px-4 py-3.5 text-sm font-medium whitespace-nowrap',
'transition-colors duration-150',
'focus:outline-none focus-visible:bg-secondary/40 rounded-sm',
active
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
aria-selected={active}
role="tab"
>
{tab.label}
</button>
);
})}
{/* Active-tab underline indicator. Animates between tab positions. */}
{indicator && (
<span
aria-hidden
className="absolute bottom-0 h-0.5 bg-primary rounded-full transition-all duration-200 ease-out"
style={{ left: indicator.left, width: indicator.width }}
/>
)}
</div>
</div>
);
}
+61 -2
View File
@@ -3,8 +3,10 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useContentFilters } from '@/hooks/useContentFilters';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useMuteList } from '@/hooks/useMuteList';
import { CAMPAIGN_KIND } from '@/lib/campaign';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { getPaginationCursor, shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers';
@@ -131,6 +133,22 @@ export interface UseAgoraFeedOptions {
* follows nobody.
*/
authors?: string[];
/**
* When true, also include events authored by `authors` in any of the
* user's enabled "feed kinds" (notes, reposts, articles, photos,
* videos, polls, etc. — see {@link getEnabledFeedKinds}) regardless
* of the `t:agora` marker. Produces a unified "everything this person
* has done on the network" feed.
*
* Only meaningful in combination with `authors`; setting it without
* `authors` would flood the feed with all kind-1 notes on every relay
* and is silently ignored.
*
* Used by the profile page to merge the legacy Posts tab into the
* Activity tab. Off by default so the strict Agora home feed isn't
* affected.
*/
includeAuthorNotes?: boolean;
}
/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
@@ -138,6 +156,7 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { shouldFilterEvent } = useContentFilters();
const { feedSettings } = useFeedSettings();
const authors = options?.authors;
const authorsKey = authors ? [...authors].sort().join(',') : '';
@@ -145,9 +164,22 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
// (e.g. the user follows nobody) — skip the query entirely.
const authorsEmpty = authors !== undefined && authors.length === 0;
const queryEnabled = enabled && !authorsEmpty;
// Author-scoped notes inclusion only makes sense when at least one
// author is set; ignore the option otherwise (see option doc).
const includeAuthorNotes = !!options?.includeAuthorNotes && !!authors && authors.length > 0;
// Pull the user's enabled "feed kinds" — same set the legacy Posts tab
// used. Includes notes (1), reposts (6), articles (30023), photos (20),
// videos (21/22), polls, etc. — every kind the user opted to see in
// mixed feeds. Memoize via stable cache-key so changing settings refetch.
const authorNoteKinds = includeAuthorNotes ? getEnabledFeedKinds(feedSettings) : [];
// Always include kind 1 / 6 even if the user disabled them in feed
// settings — a profile feed without notes is broken.
if (includeAuthorNotes && !authorNoteKinds.includes(1)) authorNoteKinds.push(1);
if (includeAuthorNotes && !authorNoteKinds.includes(6)) authorNoteKinds.push(6);
const authorNoteKindsKey = [...authorNoteKinds].sort((a, b) => a - b).join(',');
const query = useInfiniteQuery<AgoraFeedPage, Error>({
queryKey: ['agora-feed', authorsKey],
queryKey: ['agora-feed', authorsKey, includeAuthorNotes, authorNoteKindsKey],
queryFn: async ({ pageParam, signal: querySignal }) => {
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
const until = pageParam as number | undefined;
@@ -165,8 +197,35 @@ export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
];
// Author-scoped notes — every enabled feed kind from this author,
// no `t:agora` requirement. Powers the unified profile feed where
// the legacy Posts tab has been folded into Activity. The kind set
// mirrors the user's feed settings (notes, reposts, articles,
// photos, videos, polls, etc.) so a profile shows everything the
// person has done across the network.
if (includeAuthorNotes && authorNoteKinds.length > 0) {
filters.push({
kinds: authorNoteKinds,
...authorsFilter,
limit: AGORA_PAGE_SIZE,
...(until && { until }),
});
}
const raw = await nostr.query(filters, { signal });
const filtered = raw.filter(isRelevantAgoraEvent);
// When author-notes are included, accept any event of an enabled
// feed kind authored by one of the requested authors regardless of
// the strict Agora gate. The strong author scope is the trust
// anchor.
const authorSet = new Set(authors ?? []);
const authorKindSet = new Set(authorNoteKinds);
const filtered = raw.filter((event) => {
if (isRelevantAgoraEvent(event)) return true;
if (!includeAuthorNotes) return false;
if (!authorKindSet.has(event.kind)) return false;
if (shouldHideFeedEvent(event)) return false;
return authorSet.has(event.pubkey);
});
const { coordinates, eventIds } = extractDonationTargets(filtered);
// Donation enrichment: pull lightning + onchain zaps that reference
+111
View File
@@ -0,0 +1,111 @@
import { useNostr } from '@nostrify/react';
import { useQueries } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useAppContext } from '@/hooks/useAppContext';
import {
extractOnchainZapTxid,
verifyOnchainZap,
} from '@/hooks/useOnchainZaps';
import type { ParsedCampaign } from '@/lib/campaign';
export interface ProfileCampaignStats {
/** Total number of non-deleted campaigns authored by this pubkey. */
campaignCount: number;
/**
* Sum of verified on-chain donations across all of this user's
* campaigns, in sats. Silent-payment campaigns contribute 0 by design
* (donations are unlinkable, no receipts are published).
*/
totalRaisedSats: number;
/** True while donation verification queries are still resolving. */
isVerifying: boolean;
/** The raw campaigns list, for reuse by the chip click handler. */
campaigns: ParsedCampaign[];
}
/**
* Aggregate campaign and donation stats for a single profile.
*
* Mirrors {@link useCampaignDonations} per campaign — fetches kind 8333
* receipts targeting each `a` coord, dedupes by txid, and verifies each
* one on-chain against the campaign's `w` address before counting it
* toward the total. Silent-payment campaigns are excluded from the
* verification fan-out (their donations are intentionally unlinkable).
*
* Lazy: returns 0 / empty until the campaigns list resolves, then fans
* out receipt fetches in parallel. Suitable for header stat chips where
* an in-flight number is fine.
*/
export function useProfileCampaignStats(pubkey: string | undefined): ProfileCampaignStats {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraApis } = config;
const campaignsQuery = useCampaigns(
pubkey ? { authors: [pubkey], limit: 100 } : { authors: [], limit: 0 },
);
const campaigns = pubkey ? (campaignsQuery.data ?? []) : [];
// Fan out: one receipt fetch per on-chain campaign.
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
const receiptsQueries = useQueries({
queries: onchainCampaigns.map((campaign) => ({
queryKey: ['campaign-donations', 'events', campaign.aTag],
queryFn: async ({ signal }: { signal: AbortSignal }): Promise<NostrEvent[]> => {
return nostr.query(
[{ kinds: [8333], '#a': [campaign.aTag], limit: 500 }],
{ signal },
);
},
staleTime: 15_000,
})),
});
// Flatten the receipts and dedupe by txid (prefer earliest, like
// useCampaignDonations does). Track which campaign each txid belongs to
// so we can verify against the right wallet.
const verificationInputs: Array<{ campaign: ParsedCampaign; event: NostrEvent }> = [];
const seenByCampaign = new Map<string, Set<string>>();
for (let i = 0; i < onchainCampaigns.length; i++) {
const campaign = onchainCampaigns[i];
const receipts = receiptsQueries[i]?.data ?? [];
const sortedAsc = [...receipts].sort((a, b) => a.created_at - b.created_at);
const seenTxids = new Set<string>();
for (const event of sortedAsc) {
const txid = extractOnchainZapTxid(event);
if (!txid) continue;
if (seenTxids.has(txid)) continue;
seenTxids.add(txid);
verificationInputs.push({ campaign, event });
}
seenByCampaign.set(campaign.aTag, seenTxids);
}
const verifications = useQueries({
queries: verificationInputs.map(({ campaign, event }) => ({
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, campaign.wallet?.value ?? ''],
queryFn: () => verifyOnchainZap(event, esploraApis, campaign.wallet?.value),
staleTime: 60_000,
enabled: !!campaign.wallet?.value,
})),
});
const totalRaisedSats = verifications.reduce(
(sum, v) => sum + (v.data?.amountSats ?? 0),
0,
);
const isVerifying =
campaignsQuery.isLoading ||
receiptsQueries.some((q) => q.isLoading) ||
verifications.some((v) => v.isLoading);
return {
campaignCount: campaigns.length,
totalRaisedSats,
isVerifying,
campaigns,
};
}
+92
View File
@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import {
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
export interface ProfileOrganization {
community: ParsedCommunity;
event: NostrEvent;
/** This pubkey is the founder (author of the kind 34550 event). */
isFounder: boolean;
/** This pubkey is listed in a `p` tag with role `moderator`. */
isModerator: boolean;
}
function organizationRank(entry: ProfileOrganization): number {
if (entry.isFounder) return 0;
return 1;
}
/**
* Organizations any given pubkey is publicly associated with — orgs they
* founded and orgs they moderate.
*
* Distinct from {@link useUserOrganizations}, which augments the logged-in
* user's view with their private NIP-51 community bookmarks (kind 10004).
* Bookmarks are personal state that a third party can't see without the
* owner's keys, so for someone else's profile we surface only the public
* founder + moderator signals.
*/
export function useProfileOrganizations(pubkey: string | undefined) {
const { nostr } = useNostr();
const query = useQuery<ProfileOrganization[]>({
queryKey: ['profile-organizations', pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!pubkey) return [];
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const [foundedEvents, pTaggedEvents] = await Promise.all([
nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [pubkey], limit: 50 }],
{ signal: combinedSignal },
),
nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], '#p': [pubkey], limit: 100 }],
{ signal: combinedSignal },
),
]);
const entries: ProfileOrganization[] = [];
for (const event of dedupeAddressableLatest([...foundedEvents, ...pTaggedEvents])) {
const community = parseCommunityEvent(event);
if (!community) continue;
const isFounder = community.founderPubkey === pubkey;
const isModerator = community.moderatorPubkeys.includes(pubkey);
if (!isFounder && !isModerator) continue;
entries.push({ community, event, isFounder, isModerator });
}
entries.sort((a, b) => {
const rankDiff = organizationRank(a) - organizationRank(b);
if (rankDiff !== 0) return rankDiff;
return b.event.created_at - a.event.created_at;
});
return entries;
},
enabled: !!pubkey,
staleTime: 60_000,
});
return useMemo(
() => ({
data: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
}),
[query.data, query.isLoading, query.isError, query.error],
);
}
+31 -36
View File
@@ -704,7 +704,8 @@
}
/* Slow Ken-Burns-style pan for full-bleed hero images. The image is
oversized (scale 1.12) so the translation never reveals the edge. */
oversized (scale 1.12) so the translation never reveals the edge.
Used by HeroBanner on Communities/Actions/Guide pages. */
@keyframes heroPanRight {
0% { transform: scale(1.12) translateX(-1.5%); }
50% { transform: scale(1.12) translateX(1.5%); }
@@ -718,43 +719,37 @@
.hero-pan-right { animation: heroPanRight 28s ease-in-out infinite; }
.hero-pan-left { animation: heroPanLeft 28s ease-in-out infinite; }
/* Slow breathing glow used on the hero globe's outer halo so the beacon
feels alive rather than statically pasted. The radius itself doesn't
change — only opacity — so the layout stays rock-stable. Range is
intentionally narrow + low so the halo is felt, not seen. */
@keyframes heroGlobeHaloBreath {
0% { opacity: 0.32; }
50% { opacity: 0.55; }
100% { opacity: 0.32; }
/* Lightning-arc flow on the home hero. Each path is dashed and the
dash offset animates by exactly one full dash period, so the loop
point is visually identical to the start point — no snap, no
visible restart. Dash sizes are in screen pixels because the arcs
use `vector-effect: non-scaling-stroke`. */
@keyframes heroArcFlow {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -16; }
}
.hero-arc-flow {
stroke-dasharray: 6 10;
animation: heroArcFlow 2.4s linear infinite;
}
/* City-node pulse used on the home hero. The dot itself doesn't move;
we just breathe the halo's opacity so the location reads as a live
beacon. Offsets are staggered per-node from the JSX so the field
pulses organically rather than in lockstep. */
@keyframes heroNodePulse {
0% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 0.95; transform: scale(1.15); }
100% { opacity: 0.35; transform: scale(0.85); }
}
.hero-node-pulse {
transform-origin: center;
transform-box: fill-box;
animation: heroNodePulse 3.2s ease-in-out infinite;
}
.hero-globe-halo-breath { animation: heroGlobeHaloBreath 6.5s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.hero-pan-right, .hero-pan-left { animation: none; }
.hero-globe-halo-breath { animation: none; opacity: 0.42; }
}
/* Dark drop-shadow for hero copy — photo backgrounds are always colorful,
so we want a dark shadow regardless of light/dark theme. */
.hero-text-shadow {
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.8),
0 0 20px rgba(0, 0, 0, 0.45);
}
.hero-text-shadow-soft {
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.65);
}
/* On mobile the globe sits under the headline — soften its left edge. */
.hero-globe-mask {
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 48%, black 100%);
mask-image: linear-gradient(to right, transparent 0%, black 48%, black 100%);
}
@media (min-width: 640px) {
.hero-globe-mask {
-webkit-mask-image: none;
mask-image: none;
}
.hero-arc-flow { animation: none; stroke-dasharray: none; }
.hero-node-pulse { animation: none; opacity: 0.7; }
}
+1
View File
@@ -17,6 +17,7 @@ import './index.css';
import './i18n';
import '@fontsource-variable/inter';
import '@fontsource/bebas-neue/400.css';
// ─── Native status bar theming (Android APK / iOS) ───────────────────────────
// Keeps the OS top chrome in sync with the active app theme.
+2 -2
View File
@@ -184,12 +184,12 @@ function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired
to={`/${naddr}`}
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
>
<Card className={cn('overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col', isExpired && 'opacity-75')}>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
<img
src={coverImage}
alt=""
className={cn('absolute inset-0 size-full object-cover', isExpired && 'grayscale')}
className="absolute inset-0 size-full object-cover"
onError={() => setImageLoadFailed(true)}
loading="lazy"
/>
+57 -196
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ChevronDown, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react';
@@ -11,11 +11,7 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
import { HeroGlobe } from '@/components/HeroGlobe';
import { HeroCampaignSpotlight } from '@/components/HeroCampaignSpotlight';
import { CampaignHeroBackground } from '@/components/CampaignHeroBackground';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { hopeHueFor } from '@/lib/hopePalette';
import { HeroLightningMap } from '@/components/HeroLightningMap';
import { cn } from '@/lib/utils';
import { useCampaigns } from '@/hooks/useCampaigns';
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
@@ -25,8 +21,6 @@ import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { type ParsedCampaign } from '@/lib/campaign';
import { getCoordinates } from '@/lib/coordinates';
/** Cap on how many featured campaigns we render in the home-page row. */
const MAX_FEATURED = 4;
@@ -150,194 +144,70 @@ export function CampaignsPage() {
);
}, [isMod, user, moderation, ownCampaigns]);
// Build the spotlight pool from the featured campaigns only. A featured
// campaign without a resolvable country is silently dropped — the globe
// needs coordinates to pin it, and the banner cycles in lockstep with
// the globe so the two stay in sync.
const spotlightables = useMemo(() => {
type Entry = {
key: string;
campaign: ParsedCampaign;
lat: number;
lng: number;
};
const out: Entry[] = [];
const seenAtag = new Set<string>();
const seenCountry = new Set<string>();
const add = (c: ParsedCampaign) => {
if (seenAtag.has(c.aTag)) return;
const countryCode = c.countryCode;
if (!countryCode) return;
const coords = getCoordinates(countryCode);
if (!coords) return;
// Deduplicate by country so two featured campaigns in the same
// country don't pile overlapping markers on the globe. Featured
// order is preserved — the first one wins.
if (seenCountry.has(countryCode)) return;
seenAtag.add(c.aTag);
seenCountry.add(countryCode);
out.push({ key: c.aTag, campaign: c, lat: coords.latitude, lng: coords.longitude });
};
for (const c of orderedFeatured) {
add(c);
}
return out;
}, [orderedFeatured]);
const globeMarkers = useMemo(
() =>
spotlightables.map((s) => ({
key: s.key,
lat: s.lat,
lng: s.lng,
label: s.campaign.title,
})),
[spotlightables],
);
// Selection lives here so the globe and the spotlight card stay in sync.
// `null` means "auto-cycle through the spotlightables"; clicking a marker
// pins the selection until the user clicks a different one.
const [selectedKey, setSelectedKey] = useState<string | null>(null);
// A separate cursor advances when no marker is selected, so cycling
// continues to drive the spotlight even while the user is reading.
const [cycleIndex, setCycleIndex] = useState(0);
useEffect(() => {
if (selectedKey !== null) return;
if (spotlightables.length <= 1) return;
const id = window.setInterval(() => {
setCycleIndex((i) => (i + 1) % spotlightables.length);
}, 6_000);
return () => window.clearInterval(id);
}, [selectedKey, spotlightables.length]);
// Resolve the spotlight to actually display.
const spotlightCampaign = useMemo(() => {
if (selectedKey) {
return spotlightables.find((s) => s.key === selectedKey)?.campaign ?? null;
}
if (spotlightables.length === 0) return null;
return spotlightables[cycleIndex % spotlightables.length].campaign;
}, [selectedKey, cycleIndex, spotlightables]);
// The key the globe should highlight matches whatever the card shows.
const highlightedMarkerKey = useMemo(() => {
if (selectedKey) return selectedKey;
if (spotlightables.length === 0) return null;
return spotlightables[cycleIndex % spotlightables.length].key;
}, [selectedKey, cycleIndex, spotlightables]);
// Active "hopeful" hue keyed to the spotlit campaign. Both the
// surrounding atmosphere and the globe consume this so the entire
// hero shifts color together as campaigns cycle.
const activeHue = useMemo(
() => hopeHueFor(spotlightCampaign?.aTag),
[spotlightCampaign?.aTag],
);
return (
<main className="min-h-screen pb-16">
{/* Hero.
Layered, back-to-front:
1. CampaignHeroBackground — full-bleed crossfading banner image
from the currently-spotlit campaign, with a warm tint + film
grain so headlines stay legible.
2. HeroGlobe — large slow-spinning globe anchored to the right,
heart markers click-select campaigns.
3. Headline column — title + paragraph + CTAs, top-left.
4. HeroCampaignSpotlight — title + summary + "View campaign"
button for the active campaign, bottom-right.
Dark, brand-driven, type-led. Three layers:
1. Near-black backdrop (`bg-[hsl(220_25%_6%)]`) — the canvas
every other element sits on. No campaign photo, no random
hue cycling: the hero looks the same on every visit, so
quality doesn't depend on which campaign is featured.
2. HeroLightningMap — decorative dark world map with curated
glowing brand-orange arcs and pulsing city nodes. Pure SVG,
negligible render cost, animations honor reduced-motion.
3. Headline column on the left, lifted by a left-edge gradient
inside HeroLightningMap so type stays readable without any
text-shadow at all. */}
<section className="relative overflow-hidden border-b border-border bg-[hsl(220_25%_6%)] text-white">
<HeroLightningMap />
Inspired by the Treasures HeroGallery pattern: the photo IS the
background, everything else floats over it. */}
<section className="relative overflow-hidden border-b border-border bg-secondary/40">
<CampaignHeroBackground imageUrl={spotlightCampaign?.banner} />
{/* Per-campaign hopeful color atmosphere. Sits on top of the
photo and below the globe so its mix-blend-mode: screen layers
can warm the darks back up — the photo ends up tinted toward
the active hue without losing headline contrast. */}
<HeroAtmosphere seed={spotlightCampaign?.aTag} />
{/* Globe — center sits a little to the left of the TopNav account
switcher anchor so a larger slice of the sphere reads inside
the hero rather than off the right edge. */}
<div className="absolute inset-0 pointer-events-none">
<div className="relative max-w-7xl mx-auto h-full px-4 sm:px-6">
<div className="absolute inset-y-0 right-4 sm:right-6 flex items-center">
<div className="pointer-events-auto hero-globe-mask translate-x-[40%] sm:translate-x-[38%] lg:translate-x-[38%] opacity-90">
<HeroGlobe
markers={globeMarkers}
selectedKey={highlightedMarkerKey}
onMarkerClick={(key) =>
// Toggle off when the user re-clicks the active
// marker, restoring the auto-cycle.
setSelectedKey((prev) => (prev === key ? null : key))
}
hue={activeHue}
// Fluid sizing scaled to dynamic viewport width (dvw),
// clamped so it never shrinks below phone-comfortable
// nor balloons on ultra-wide monitors.
className="aspect-square max-w-none drop-shadow-2xl"
style={{ width: 'clamp(620px, 58dvw, 820px)' }}
/>
</div>
</div>
</div>
</div>
{/* Readability scrim. Layered *above* the globe so the headline
area stays legible even when a slice of the sphere sits behind
it. Only shown on tablet and below — at lg+ the globe sits
outside the headline column, so the scrim would just mute the
photo for no benefit. */}
<div
className="absolute inset-0 pointer-events-none bg-gradient-to-r from-black/55 via-black/30 to-transparent lg:hidden"
aria-hidden="true"
/>
{/* Foreground content — headline + CTAs at the top, spotlight info
at the bottom. Shares the `max-w-7xl mx-auto` container with the
globe so everything aligns to the same left/right axis. */}
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-14 lg:py-20 min-h-[560px] sm:min-h-[600px] lg:min-h-[640px] flex flex-col text-white">
<div className="relative space-y-5 max-w-2xl">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1] hero-text-shadow">
Connecting activists to unstoppable funding.
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 py-16 lg:py-24 min-h-[440px] sm:min-h-[480px] lg:min-h-[520px] flex flex-col justify-center">
<div className="space-y-6 max-w-2xl">
<h1
className="font-display italic text-6xl sm:text-7xl lg:text-8xl font-normal tracking-wide leading-none uppercase"
style={{
// Bebas Neue only ships at weight 400. Paint a stroke the
// same color as the fill to fatten the letterforms without
// the fuzz a synthetic-bold transform would produce.
WebkitTextStroke: '0.022em currentColor',
}}
>
Connecting activists to
{/* "unstoppable" gets a solid brand-orange highlighter
block on its own line. The negative left margin
(`-ml-1.5`) pulls the box's left edge back by exactly
the box's own horizontal padding so the U sits flush
with the column's left edge instead of being inset by
the highlighter's padding. */}
<br />
{/* Asymmetric padding: zero on the left so "unstoppable"'s
U sits flush with the column edge (matching the row
above), but extra padding on the right so the orange
box extends past the word's trailing edge as a
deliberate visual flourish. The inner text is then
nudged slightly leftward (negative left margin on the
inner element) so the U optically aligns with the
"C" in "Connecting" — Bebas Neue's italic skew shifts
the visual left edge of the U rightward of its
geometric box. */}
<span className="inline-block w-fit pl-0 pr-3 pt-1 pb-0 -mt-1 -mb-3 bg-primary text-white leading-[0.8] align-baseline">
<span className="-ml-1 inline-block">unstoppable</span>
</span>{' '}
funding.
</h1>
<p className="text-base sm:text-lg text-white/85 max-w-2xl hero-text-shadow-soft">
Raise Bitcoin directly from supporters around the world. Every donation settles
straight to your campaign's beneficiaries, with no middlemen, no chargebacks, and no
platform holding your funds.
<p className="text-base sm:text-lg text-white/80 max-w-xl">
Raise Bitcoin directly from supporters around the world. Every donation
settles straight to your campaign's beneficiaries, with no middlemen, no
chargebacks, and no platform holding your funds.
</p>
<div className="flex flex-wrap gap-3 pt-2">
{/* Primary CTA — clean translucent glass pill with a hint
of warmth bleeding through. The hopefulness comes from
the photo + atmosphere underneath plus a soft warm
shadow, not from added gloss. */}
<div className="flex flex-wrap gap-3 pt-1">
{/* Primary CTA — solid brand-orange pill. The dark hero gives
the brand color the spotlight without competing with it. */}
<Button
size="lg"
asChild
className={cn(
// A touch larger than the default lg button — enough
// weight to read as primary, not enough to feel chunky.
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
// Subtle warm-tinted glass body, kept more transparent.
// Hover lifts the tint a hair without changing the pill
// character — no shadow bloom, no halo.
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
'backdrop-blur-xl backdrop-saturate-150',
'border border-white/25 hover:border-white/35',
// Single hair-thin inner highlight + a warm-tinted
// realistic drop shadow that ties the button to the
// hopeful palette. Hover only nudges the shadow.
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
'motion-safe:transition-colors motion-safe:duration-200',
)}
className="rounded-full text-primary-foreground font-semibold text-base h-12 px-7 [&_svg]:size-[18px] motion-safe:transition-colors"
>
<Link to="/campaigns/new">
<PlusCircle className="mr-2" />
@@ -349,22 +219,13 @@ export function CampaignsPage() {
variant="outline"
size="lg"
asChild
className="rounded-full bg-background/70 backdrop-blur h-12 px-6 text-base text-foreground"
className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50"
>
<a href="#campaigns">Explore campaigns</a>
</Button>
)}
</div>
</div>
{(spotlightCampaign || (featuredLoading && spotlightables.length === 0)) && (
<div className="relative mt-auto pt-10 max-w-sm">
<HeroCampaignSpotlight
campaign={spotlightCampaign}
isLoading={featuredLoading && spotlightables.length === 0}
/>
</div>
)}
</div>
</section>
+181 -1425
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -714,6 +714,7 @@ export function ProfileSettings() {
onChange={handleCardChange}
onPickImage={handlePickImage}
onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })}
showBadges={false}
/>
{isUploading && (
+1
View File
@@ -32,6 +32,7 @@ export default {
},
fontFamily: {
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
display: ['Bebas Neue', 'Impact', 'Inter Variable', 'system-ui', 'sans-serif'],
emoji: ['Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'EmojiSymbols', 'sans-serif'],
},
colors: {