From bfc8d1ab070270debdcf7eb863d8ccd44ce4cf1b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Thu, 21 May 2026 14:57:38 -0500 Subject: [PATCH] Full-bleed campaign hero with overlaid title, creator, summary --- src/pages/CampaignDetailPage.tsx | 330 +++++++++++++++++-------------- 1 file changed, 184 insertions(+), 146 deletions(-) diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index ac9233c1..7486da39 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -68,7 +68,6 @@ import { formatNumber } from '@/lib/formatNumber'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { timeAgo } from '@/lib/timeAgo'; -import { cn } from '@/lib/utils'; import NotFound from './NotFound'; interface CampaignDetailPageProps { @@ -129,7 +128,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const [replyOpen, setReplyOpen] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [interactionsOpen, setInteractionsOpen] = useState(false); - const [storyExpanded, setStoryExpanded] = useState(false); const [interactionsTab, setInteractionsTab] = useState('reposts'); const [archiveConfirmOpen, setArchiveConfirmOpen] = useState(false); @@ -323,12 +321,15 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { return (
- {/* Cover hero stretches edge-to-edge on every breakpoint. */} + {/* Full-bleed cover hero. Title, creator, meta, summary, and the + back/admin buttons all live ON the image — the banner is the + page's emotional entry point. */} 0} - expanded={storyExpanded} - onToggle={() => setStoryExpanded((v) => !v)} /> {/* Engagement: stats counters, action bar, threaded replies - + donation receipts interleaved. */} + + donation receipts interleaved. Rendered flush with the + story (no card chrome) so the page reads as one + continuous flow — story → counters → action chips → + comments — rather than a stack of disconnected boxes. + A subtle top border separates it from the story above. */}
-
+
{hasStats && (
{engagementStats?.reposts ? ( @@ -406,7 +409,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { hideZap onReply={() => setReplyOpen(true)} onMore={() => setMoreMenuOpen(true)} - className={hasStats ? 'pt-3 border-t border-border/60' : undefined} />
@@ -525,7 +527,11 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { } // ───────────────────────────────────────────────────────────────────── -// Hero +// Hero — full-bleed cover with title, creator, meta, summary, and the +// back / admin controls all overlaid on the image. The banner is the +// page's emotional entry point: the photo carries the campaign's story +// at a glance, and the overlay text makes the pitch readable without +// taking the reader off the image. // ───────────────────────────────────────────────────────────────────── interface CampaignHeroProps { @@ -533,6 +539,7 @@ interface CampaignHeroProps { cover: string | undefined; creatorName: string; creatorProfileUrl: string; + creatorPicture: string | undefined; deadline: { label: string; isPast: boolean } | null; countryLabel: string | undefined; tagLabel: string | undefined; @@ -549,6 +556,7 @@ function CampaignHero({ cover, creatorName, creatorProfileUrl, + creatorPicture, deadline, countryLabel, tagLabel, @@ -559,69 +567,102 @@ function CampaignHero({ onArchive, onReopen, }: CampaignHeroProps) { + const initials = creatorName.slice(0, 2).toUpperCase(); + return ( -
-
- {cover ? ( - - ) : ( -
- + // True full-bleed: no max-width wrapper, no horizontal padding, no + // rounded corners — the image touches every edge on every + // breakpoint. Height is generous on mobile so the banner fills the + // viewport for an immersive first impression instead of being a + // strip; on larger screens we cap it so the page content below + // stays visible. +
+ {cover ? ( + + ) : ( +
+ +
+ )} + + {/* Single, smooth dark gradient anchored at the bottom — no + middle band, no top wash. The image stays vibrant at the + top while the overlay text gets a clean reading surface + underneath. */} +
+ {/* Subtle top scrim purely to keep the back/admin buttons + legible against bright skies, beaches, etc. */} +
+ + {/* Top controls — back left, admin right. Use chip-style + backdrops so they read on any image without an opaque pill. */} +
+ + + {isCreator && ( +
+ + {campaign.archived ? ( + + ) : ( + + )}
)} -
+
-
- - {isCreator && ( -
- - {campaign.archived ? ( - - ) : ( - - )} -
- )} -
- -
+ {/* Overlay content — sits at the bottom of the image, contained + to the 6xl column on desktop so it lines up with the body + content below. Generous bottom padding (incl. safe-area) + keeps the title comfortably above the home-indicator on + notched phones. Drop-shadow on text gives extra contrast on + busy photos without darkening the gradient further. */} +
+
{campaign.archived && ( )} -
-

- {campaign.title} -

- e.stopPropagation()} - className="text-xs sm:text-sm text-white/85 hover:text-white motion-safe:transition-colors" - > - by {creatorName} - -
-
- {tagLabel && ( - - - {tagLabel} - - )} - {countryLabel && ( - - - {countryLabel} - - )} - {deadline && ( - - - {deadline.label} - - )} - - - {campaign.recipients.length}{' '} - {campaign.recipients.length === 1 ? 'recipient' : 'recipients'} - -
+ + {tagLabel && ( +
+ + {tagLabel} +
+ )} + +

+ {campaign.title} +

+ {campaign.summary && ( -

+

{campaign.summary}

)} + + e.stopPropagation()} + className="inline-flex items-center gap-2.5 pt-1 text-sm sm:text-base text-white/90 hover:text-white motion-safe:transition-colors group [text-shadow:none]" + > + + {creatorPicture && } + + {initials} + + + + by{' '} + + {creatorName} + + + + + {(countryLabel || deadline || campaign.recipients.length > 0) && ( +
+ {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} + + + {campaign.recipients.length}{' '} + {campaign.recipients.length === 1 ? 'recipient' : 'recipients'} + +
+ )}
-
+
); } @@ -686,54 +743,38 @@ function CampaignHero({ function CampaignStory({ storyEvent, hasContent, - expanded, - onToggle, }: { storyEvent: NostrEvent; hasContent: boolean; - expanded: boolean; - onToggle: () => void; + // expanded/onToggle retained on the call site for backwards-compat + // but no longer used — the story shows in full. A fundraiser pitch + // is the entire point of the page; hiding most of it behind a + // fade-out gradient buries the message. + expanded?: boolean; + onToggle?: () => void; }) { if (!hasContent) { return ( -
+

The organizer hasn't written a story for this campaign yet.

-
+
); } return ( -
-
+

-
- -
- {!expanded && ( -
- )} -
- -

+ The story + +
+ +
+ ); } @@ -1060,13 +1101,10 @@ function CampaignReplySkeleton() { function CampaignDetailSkeleton() { return (
-
- -
+
-
- +