Replace MainLayout with a top-nav-only FundraiserLayout
The previous overhaul left the campaigns content nested inside the
Twitter-style three-column MainLayout (LeftSidebar + 600-px center
column + WidgetSidebar + mobile FAB + mobile bottom nav). It looked
like a Nostr client that happened to render campaign cards instead of
a fundraising site.
This commit takes the chrome down to studs:
- New FundraiserLayout: a sticky GoFundMe-style TopNav with logo,
Discover / Start a campaign / About links, the existing LoginArea
on the right (so the avatar dropdown / Log in & Sign up buttons all
keep working unchanged), and a primary "Start a campaign" pill.
Mobile collapses to a hamburger drawer with the same items plus
quick shortcuts to Wallet / Bitcoin / Notifications / Profile /
Settings for logged-in users.
- One full-width content area below the nav and a slim site footer.
No LeftSidebar, no WidgetSidebar, no FAB, no MobileTopBar/BottomNav.
- The old layout still provides LayoutStoreContext / DrawerContext /
CenterColumnContext / NavHiddenContext so every page that calls
useLayoutOptions(...) keeps mounting cleanly. FAB / sidebar /
scroll-direction options are simply ignored.
Routing changes:
- / now renders CampaignsPage directly (instead of dispatching
through a configurable HomePage). /campaigns redirects to /.
- The orphaned HomePage.tsx is removed.
Campaign pages were calibrated for the old 600-px center column.
Re-flowed them to take advantage of the full canvas:
- Hero copy is recentred under max-w-7xl with GoFundMe-style language
("Where successful fundraisers start.").
- Campaign grid grows to four columns on xl screens.
- CampaignDetailPage drops its local sticky sub-header (redundant
under the global TopNav) and the donation rail re-anchors to the
new nav height.
- CreateCampaignPage drops its sticky sub-header and reads as a
proper landing form.
The legacy MainLayout / LeftSidebar / WidgetSidebar / MobileTopBar /
MobileBottomNav / MobileDrawer / FloatingComposeButton components
remain on disk but are no longer mounted; they tree-shake out of the
production bundle.
This commit is contained in:
+8
-10
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { FundraiserLayout } from "./components/FundraiserLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
@@ -24,11 +24,9 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
// Campaigns: lazy-loaded list + create pages. (Detail page is dispatched from
|
||||
// NIP19Page when an naddr resolves to kind 30223.)
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
|
||||
// the configurable HomePage delegation from the Twitter-era app is gone.
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
|
||||
@@ -159,11 +157,11 @@ export function AppRouter() {
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
CenterColumnContext,
|
||||
DrawerContext,
|
||||
LayoutStore,
|
||||
LayoutStoreContext,
|
||||
NavHiddenContext,
|
||||
} from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Persistent app shell for the fundraising-platform overhaul.
|
||||
*
|
||||
* Replaces the previous Twitter-style three-column `MainLayout` with a
|
||||
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
|
||||
* content area below the {@link TopNav}.
|
||||
*
|
||||
* Compatibility surface:
|
||||
* - We still provide `LayoutStoreContext`, so pages that call
|
||||
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
|
||||
* mobile arc) are intentionally ignored here because the new shell has
|
||||
* no FAB and no sidebars. The store still drives the
|
||||
* `wrapperClassName` escape hatch for pages that need to widen.
|
||||
* - `CenterColumnContext` exposes the content `<div>` so legacy components
|
||||
* (e.g. nsite preview overlay) can still portal into it.
|
||||
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
|
||||
* pages that read them don't crash.
|
||||
*/
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FundraiserLayoutInner() {
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
|
||||
// become no-ops. Keeping the context shape avoids touching every page that
|
||||
// pulls the hook.
|
||||
const openDrawer = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={false}>
|
||||
<div className="min-h-dvh flex flex-col bg-background">
|
||||
<TopNav />
|
||||
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
centerColumnRef.current = el;
|
||||
setCenterColumnEl(el);
|
||||
}}
|
||||
className={cn('flex-1 min-w-0 w-full')}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</NavHiddenContext.Provider>
|
||||
</DrawerContext.Provider>
|
||||
</CenterColumnContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background mt-auto">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
|
||||
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
|
||||
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function FundraiserLayout() {
|
||||
const store = useMemo(() => new LayoutStore(), []);
|
||||
return (
|
||||
<LayoutStoreContext.Provider value={store}>
|
||||
<FundraiserLayoutInner />
|
||||
</LayoutStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FundraiserLayout;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Menu, PlusCircle, X } from 'lucide-react';
|
||||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
/** If true, this link is treated as active only on an exact match. */
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: 'Discover', to: '/', exact: true },
|
||||
{ label: 'Start a campaign', to: '/campaigns/new' },
|
||||
{ label: 'About', to: '/help' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persistent top navigation bar rendered by {@link FundraiserLayout}. Mirrors
|
||||
* the GoFundMe-style chrome: brand mark on the left, primary nav links in the
|
||||
* middle, "Sign in" / account avatar plus a "Start a campaign" pill on the
|
||||
* right. Collapses to a hamburger menu below the `md` breakpoint.
|
||||
*/
|
||||
export function TopNav() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6">
|
||||
{/* Mobile menu trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="md:hidden -ml-2 p-2 rounded-full hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Brand */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 font-bold text-lg tracking-tight text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
|
||||
aria-label={`${config.appName} home`}
|
||||
>
|
||||
<LogoIcon className="size-6" />
|
||||
<span>{config.appName}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-1 ml-6">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLinkButton key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Primary CTA pill — hidden on small screens to keep the bar uncluttered;
|
||||
the same action lives at the top of the mobile menu and as a FAB-style
|
||||
button in the homepage hero. */}
|
||||
<Button asChild size="sm" className="hidden sm:inline-flex rounded-full">
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-1.5" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* LoginArea handles both logged-in (account avatar dropdown) and
|
||||
logged-out (Log in / Sign up) states. We render it inline-flex
|
||||
and let it style its own children. */}
|
||||
<LoginArea className={cn(user ? 'shrink-0' : 'max-w-[260px]')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-72 p-0 flex flex-col gap-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-2 font-bold text-lg text-primary"
|
||||
>
|
||||
<LogoIcon className="size-6" />
|
||||
<span>{config.appName}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full hover:bg-secondary"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.exact}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center px-3 py-2.5 rounded-lg text-sm font-medium motion-safe:transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="border-t border-border p-4 space-y-3">
|
||||
<Button asChild className="w-full rounded-full" onClick={() => setMobileOpen(false)}>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-1.5" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
<SecondaryMobileLinks onClose={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkButton({ item }: { item: NavItem }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.exact}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary links inside the mobile drawer for the logged-in user — quick
|
||||
* shortcuts to the parts of the app that live outside the fundraising flow
|
||||
* but should still be reachable.
|
||||
*/
|
||||
function SecondaryMobileLinks({ onClose }: { onClose: () => void }) {
|
||||
const { user } = useCurrentUser();
|
||||
if (!user) return null;
|
||||
|
||||
const items: { label: string; to: string }[] = [
|
||||
{ label: 'Wallet', to: '/wallet' },
|
||||
{ label: 'Bitcoin', to: '/bitcoin' },
|
||||
{ label: 'Notifications', to: '/notifications' },
|
||||
{ label: 'Profile', to: '/profile' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
];
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<Link
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="block px-3 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -119,25 +119,22 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
{/* Sticky-ish top bar with back button */}
|
||||
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
|
||||
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
{/* Inline back arrow — the global TopNav handles primary navigation. */}
|
||||
<div className="flex items-center gap-2 mb-4 -ml-2">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-secondary motion-safe:transition-colors"
|
||||
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-base font-semibold truncate flex-1 min-w-0">{campaign.title}</h1>
|
||||
<Button variant="ghost" size="sm" onClick={handleShare} className="shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={handleShare} className="ml-auto">
|
||||
<Share2 className="size-4 sm:mr-1.5" />
|
||||
<span className="hidden sm:inline">Share</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
{/* Hero */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-10">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -239,7 +236,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
|
||||
</div>
|
||||
|
||||
{/* Donation rail */}
|
||||
<aside className="lg:col-span-1 lg:sticky lg:top-20 self-start space-y-4">
|
||||
<aside className="lg:col-span-1 lg:sticky lg:top-[5rem] self-start space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
{statsLoading ? (
|
||||
@@ -357,13 +354,8 @@ function RecipientRow({ pubkey, weight }: { pubkey: string; weight: number }) {
|
||||
function CampaignDetailSkeleton() {
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
|
||||
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<Skeleton className="h-5 w-48 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
<Skeleton className="h-9 w-9 rounded-full mb-4" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-10">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-xl" />
|
||||
|
||||
@@ -112,28 +112,28 @@ export function CampaignsPage() {
|
||||
<main className="min-h-screen pb-16">
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden border-b border-border bg-gradient-to-br from-primary/15 via-background to-secondary/40">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-12 lg:py-16">
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-14 lg:py-20">
|
||||
<div className="max-w-3xl space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
|
||||
<Sparkles className="size-3.5 text-primary" />
|
||||
On-chain fundraising on Nostr
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||
Fund the people and projects that matter to you.
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1]">
|
||||
Where successful fundraisers start.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-muted-foreground">
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
|
||||
Every donation is a single Bitcoin transaction, split directly to each campaign's
|
||||
beneficiaries. No middlemen, no chargebacks, no held funds.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
<Button size="lg" asChild>
|
||||
<Button size="lg" asChild className="rounded-full">
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="size-4 mr-2" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
{!user && (
|
||||
<Button variant="outline" size="lg" asChild>
|
||||
<Button variant="outline" size="lg" asChild className="rounded-full">
|
||||
<a href="#campaigns">Explore campaigns</a>
|
||||
</Button>
|
||||
)}
|
||||
@@ -142,7 +142,7 @@ export function CampaignsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
|
||||
{/* Featured */}
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
@@ -179,7 +179,7 @@ export function CampaignsPage() {
|
||||
) : userCampaigns.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{userCampaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
|
||||
@@ -277,19 +277,6 @@ export function CreateCampaignPage() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
|
||||
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-secondary motion-safe:transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-base font-semibold flex-1 min-w-0">Start a campaign</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-8"
|
||||
onSubmit={(e) => {
|
||||
@@ -298,6 +285,17 @@ export function CreateCampaignPage() {
|
||||
submitMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 -ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">Start a campaign</h1>
|
||||
</div>
|
||||
{/* Organizer banner */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Avatar className="size-8">
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { lazy } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getExtraKindDef } from '@/lib/extraKinds';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
|
||||
import Index from './Index';
|
||||
|
||||
// All other pages are lazy-loaded so they don't bloat the index chunk.
|
||||
// HomePage renders exactly ONE page at a time, so only that page's chunk is loaded.
|
||||
const PAGE_LOADERS: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||
'campaigns': lazy(() => import('./CampaignsPage').then(m => ({ default: m.CampaignsPage }))),
|
||||
'notifications': lazy(() => import('./NotificationsPage').then(m => ({ default: m.NotificationsPage }))),
|
||||
'search': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
|
||||
'bookmarks': lazy(() => import('./BookmarksPage').then(m => ({ default: m.BookmarksPage }))),
|
||||
'changelog': lazy(() => import('./ChangelogPage').then(m => ({ default: m.ChangelogPage }))),
|
||||
'settings': lazy(() => import('./SettingsPage').then(m => ({ default: m.SettingsPage }))),
|
||||
'badges': lazy(() => import('./BadgesPage').then(m => ({ default: m.BadgesPage }))),
|
||||
'events': lazy(() => import('./EventsFeedPage').then(m => ({ default: m.EventsFeedPage }))),
|
||||
'photos': lazy(() => import('./PhotosFeedPage').then(m => ({ default: m.PhotosFeedPage }))),
|
||||
'world': lazy(() => import('./WorldPage').then(m => ({ default: m.WorldPage }))),
|
||||
'lists': lazy(() => import('./UserListsPage').then(m => ({ default: m.UserListsPage }))),
|
||||
'letters': lazy(() => import('./LettersPage').then(m => ({ default: m.LettersPage }))),
|
||||
'help': lazy(() => import('./HelpPage').then(m => ({ default: m.HelpPage }))),
|
||||
};
|
||||
|
||||
/** Sidebar items that use KindFeedPage and need extra kind definitions. */
|
||||
const KIND_FEED_ITEMS = ['polls', 'articles'] as const;
|
||||
|
||||
// KindFeedPage is lazy too
|
||||
const LazyKindFeedPage = lazy(() => import('./KindFeedPage').then(m => ({ default: m.KindFeedPage })));
|
||||
|
||||
function KindFeedWrapper({ itemId }: { itemId: string }) {
|
||||
const def = getExtraKindDef(itemId);
|
||||
if (!def) return <Index />;
|
||||
return <LazyKindFeedPage kind={def.kind} title={def.label} icon={sidebarItemIcon(itemId, 'size-5')} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the page component configured as the homepage.
|
||||
* Falls back to the Feed if the configured homePage is invalid.
|
||||
*
|
||||
* This component is rendered inside MainLayout's Suspense boundary,
|
||||
* so lazy components will show the page skeleton while loading.
|
||||
*/
|
||||
export function HomePage() {
|
||||
const { config } = useAppContext();
|
||||
const homePage = config.homePage;
|
||||
|
||||
// Check if it's a kind feed item
|
||||
if ((KIND_FEED_ITEMS as readonly string[]).includes(homePage)) {
|
||||
return <KindFeedWrapper itemId={homePage} />;
|
||||
}
|
||||
|
||||
// Check the lazy component map
|
||||
const PageComponent = PAGE_LOADERS[homePage];
|
||||
if (PageComponent) {
|
||||
return <PageComponent />;
|
||||
}
|
||||
|
||||
// Default fallback: Feed (eagerly loaded)
|
||||
return <Index />;
|
||||
}
|
||||
Reference in New Issue
Block a user