Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@number-flow/react": "^0.6.2",
"@tanstack/react-query": "^5.91.3",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"lightweight-charts": "^5.2.1",
"lucide-react": "^1.42.0",
"motion": "^13.2.0",
Expand All @@ -34,6 +35,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/d3-force": "^3.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
2 changes: 1 addition & 1 deletion app/src/app/(home)/loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Sk, SkPost, SkRow } from "@/components/Skeleton";
/** Home skeleton: hero + totals, list header, rows, side column. Same grid as page.tsx so nothing shifts. */
export default function Loading() {
return (
<main className="relative mx-auto max-w-6xl px-4 pb-16 space-y-8" aria-busy="true" aria-label="loading">
<main className="bb-mid bb-page relative pb-16 space-y-8" aria-busy="true" aria-label="loading">
<section className="pt-10 sm:pt-14 pb-2 grid lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] gap-x-8 gap-y-8 xl:gap-x-12 lg:gap-y-0 lg:grid-rows-[min-content_1fr]">
<div className="min-w-0 lg:col-start-1 lg:row-start-1">
<div className="space-y-2">
Expand Down
52 changes: 26 additions & 26 deletions app/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import LaunchHero from "@/components/launchpad/LaunchHero";
import LaunchList from "@/components/launchpad/LaunchList";
Expand All @@ -15,8 +14,11 @@ import { dbConfigured } from "@/lib/db";
import { isFilter } from "@/lib/launchpad/search";
import { nowMs } from "@/lib/launchpad/time";
import { inRiverWindow, riverCoverage } from "@/lib/launchpad/river";
import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";
import TrendingStrip from "@/components/launchpad/TrendingStrip";
import WhyFree from "@/components/launchpad/WhyFree";
import HeroBackdrop from "@/components/launchpad/HeroBackdrop";
import { MagicCard } from "@/components/vendor/magic-card";
import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout";

export const dynamic = "force-dynamic";
// Title and description come from the root layout; ?sort= / ?chain= / ?window= / ?view= are all this one page.
Expand All @@ -32,6 +34,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
const chain = visibleChainOr(sp.chain);
const filter = isFilter(sp.filter) ? sp.filter : null;
const usd = await ethUsd();
const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value);
const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd };
const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([
listLaunchesPage(listOpts),
Expand All @@ -42,32 +45,29 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
]);
const trending = fetchedTrending ?? trendingFrom(page.items); // the default view's first page doubles as the strip's candidates
const now = nowMs();
const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value);

return (
<main className="relative mx-auto max-w-6xl space-y-8 px-4 pb-16">
<LaunchHero configured={LAUNCHPAD_CONFIGURED} />
<LiveRiver initial={feed.filter((item) => inRiverWindow(item, now))} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
<TrendingStrip initial={trending} />
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_17rem]">
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} />
<aside aria-label="New launches and community" className="grid min-w-0 gap-4 md:grid-cols-2 xl:sticky xl:top-24 xl:grid-cols-1">
<JustLaunched initial={fresh.items} serverNow={now} />
<div className="min-w-0 space-y-4">
<PostsFeed initial={posts} compact />
<section aria-labelledby="free-heading" className="rounded-2xl border border-line bg-paper p-4">
<h2 id="free-heading" className="text-sm font-semibold text-ink">Why it&apos;s free</h2>
<p className="mt-2 text-pretty text-xs leading-relaxed text-muted">No fee address in the factory. No platform cut in the locker. On every chain.</p>
<dl className="mt-4 divide-y divide-line border-y border-line text-xs">
<div className="flex items-center justify-between gap-3 py-2.5"><dt className="text-muted">Platform fee</dt><dd className="font-mono font-bold text-up tnum">$0</dd></div>
<div className="flex items-center justify-between gap-3 py-2.5"><dt className="text-muted">Trading fee</dt><dd className="font-mono text-ink tnum">0 / 1 / 3%</dd></div>
</dl>
<p className="mt-3 text-pretty text-[11px] leading-relaxed text-muted">Creators choose the trading fee. It goes in full to their beneficiaries, or is burned.</p>
<a href="https://github.com/Gitlawb/openlaunch/tree/main/contracts/src" target="_blank" rel="noopener noreferrer" className="mt-3 inline-flex min-h-8 items-center text-xs font-medium text-body underline decoration-line-strong underline-offset-4 hover:text-ink">Read the contracts ↗</a>
<div className="mt-3 border-t border-line pt-3"><Link href="/agents" className="inline-flex min-h-8 items-center text-xs font-medium text-brand hover:underline underline-offset-4">Agents can launch too →</Link></div>
</section>
</div>
</aside>
<main className="bb-mid relative pb-16">
{/* the hero and the river share one full-bleed band; it clips its own backdrop so nothing widens the page */}
<div className="relative isolate overflow-hidden">
<HeroBackdrop />
<div className="bb-page space-y-8 pb-4">
<LaunchHero configured={LAUNCHPAD_CONFIGURED} />
<LiveRiver initial={feed.filter((item) => inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
</div>
</div>
<div className="bb-page mt-6 space-y-8">
<TrendingStrip initial={trending} />
<div className="grid items-start gap-x-8 gap-y-6 xl:grid-cols-[minmax(0,1fr)_19rem]">
<MagicCard className="min-w-0 rounded-2xl"><LaunchList frame="rounded-[inherit]" initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} /></MagicCard>
<aside aria-label="New launches and community" className="grid min-w-0 gap-4 md:grid-cols-2 xl:sticky xl:top-24 xl:grid-cols-1">
<JustLaunched initial={fresh.items} serverNow={now} />
<div className="min-w-0 space-y-4">
<PostsFeed initial={posts} compact />
<WhyFree />
</div>
</aside>
</div>
</div>
</main>
);
Expand Down
14 changes: 14 additions & 0 deletions app/src/components/launchpad/HeroBackdrop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { FlickeringGrid } from "@/components/vendor/flickering-grid";

/**
* Behind the home hero and the river: a flickering grid (grey on light, blue on dark) fading out from the top, with no colour wash
* over it. Decorative only; it sits in a clipped band so it never widens the page.
*/
export default function HeroBackdrop() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
<FlickeringGrid className="absolute inset-0 [mask-image:radial-gradient(ellipse_75%_65%_at_50%_0%,#000_35%,transparent_100%)]" squareSize={3} gridGap={8} flickerChance={0.18} maxOpacity={0.24} color="var(--color-glow)" />
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-b from-transparent to-paper" />
</div>
);
}
58 changes: 44 additions & 14 deletions app/src/components/launchpad/JustLaunched.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@

import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { MagicCard } from "@/components/vendor/magic-card";
import TokenAvatar from "./TokenAvatar";
import { useLive } from "./LiveProvider";
import type { LaunchRow as L } from "@/lib/launchpad/queries";
import { ArrowUpRight } from "lucide-react";
import { CHAIN_SHORT } from "@/lib/chainPublic";
import { capDisplay } from "@/lib/launchpad/market-cap";
import { ago } from "@/lib/launchpad/time";
import { launchKey } from "@/lib/launchpad/list-state";
import { feedKey } from "@/lib/launchpad/river";
import { setPendingToken } from "@/lib/launchpad/token-transition";
import { JUST_LAUNCHED_SIZE } from "@/lib/launchpad/paging";
import { ChainCorner } from "./ChainLogo";

/** At most one refetch per this window; a burst of trades on a fresh token costs one request, not one per poll. */
const MIN_GAP_MS = 10_000;
/** A launch younger than this rings its logo: it is the news. */
const FRESH_MS = 10 * 60_000;

/**
* The newest launches and whether each has had its first trade. Rows come from the market read model, so the
Expand All @@ -24,6 +31,7 @@ export default function JustLaunched({ initial, serverNow }: { initial: L[]; ser
const [rows, setRows] = useState(initial);
const [now, setNow] = useState(serverNow);
const shown = useRef(initial);
const reduced = useReducedMotion();

useEffect(() => {
const seen = new Set<string>();
Expand Down Expand Up @@ -68,35 +76,57 @@ export default function JustLaunched({ initial, serverNow }: { initial: L[]; ser
}, [subscribe]);

return (
<section aria-labelledby="fresh-heading" className="overflow-hidden rounded-2xl border border-line bg-paper">
<MagicCard className="rounded-2xl">
<section aria-labelledby="fresh-heading" className="overflow-hidden rounded-[inherit]">
<div className="flex min-h-14 items-center justify-between gap-3 border-b border-line px-4">
<h2 id="fresh-heading" className="text-sm font-semibold text-ink">Just launched</h2>
<span className="text-[11px] text-muted">Newest first</span>
<h2 id="fresh-heading" className="flex items-center gap-2 text-sm font-semibold text-ink">
{/* the list follows the shared live poll */}
<span aria-hidden="true" className="relative flex size-2">
<span className="absolute inset-0 animate-ping rounded-full bg-up opacity-50 motion-reduce:hidden" />
<span className="relative size-2 rounded-full bg-up" />
</span>
Just launched
</h2>
<Link href="/?sort=new#launches" className="inline-flex min-h-8 items-center gap-0.5 text-[11px] font-medium text-muted transition-colors hover:text-ink motion-reduce:transition-none">See all<ArrowUpRight size={12} aria-hidden="true" /></Link>
</div>
{rows.length === 0 ? (
<div className="px-4 py-8"><p className="text-sm text-ink">No launches yet.</p><p className="mt-1 text-xs leading-relaxed text-muted">New tokens show here the moment they launch.</p></div>
) : (
<ol aria-label="Newest launches" className="divide-y divide-line">
<ol aria-label="Newest launches" className="relative py-1.5">
{/* the log's spine, running behind the logos (each logo's ring cuts it) */}
<span aria-hidden="true" className="absolute top-9 bottom-9 left-[35px] w-px bg-line" />
{/* a new launch springs in at the top; rows already on screen just slide down */}
<AnimatePresence initial={false}>
{rows.map((l) => {
const trades = l.buys + l.sells;
const fresh = now - new Date(l.block_time).getTime() < FRESH_MS;
return (
<li key={launchKey(l)}>
<Link href={`/t/${l.chain}/${l.token}`} onClick={() => setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors hover:bg-card motion-reduce:transition-none">
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={32} className="shrink-0 rounded-lg" />
<motion.li key={launchKey(l)} layout={reduced ? false : "position"} initial={reduced ? false : { opacity: 0, scale: 0.96, y: -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={reduced ? undefined : { opacity: 0 }} transition={{ type: "spring", stiffness: 380, damping: 34 }}>
<Link href={`/t/${l.chain}/${l.token}`} onClick={() => setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} title={`${l.name} on ${CHAIN_SHORT[l.chain]}`} className="group flex min-w-0 items-center gap-3 px-4 py-2.5 transition-colors hover:bg-ink/[0.03] motion-reduce:transition-none">
<span className="relative shrink-0 rounded-[13px] bg-card p-0.5">
{/* under ten minutes old: a soft ring says this one is the news */}
{fresh ? <span aria-hidden="true" className="absolute inset-0 animate-ping rounded-[13px] ring-2 ring-up/50 motion-reduce:hidden" /> : null}
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={34} className="shrink-0 rounded-xl" />
<ChainCorner chain={l.chain} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-ink">{l.name}</p>
<p className="mt-0.5 truncate text-[11px] text-muted"><span className="font-mono text-body">{l.symbol}</span> on {CHAIN_SHORT[l.chain]}</p>
</div>
<div className="shrink-0 text-right text-[11px]">
<time dateTime={l.block_time} title={new Date(l.block_time).toUTCString()} className="block font-mono text-muted tnum" suppressHydrationWarning>{ago(l.block_time, now)} ago</time>
<span className={`mt-0.5 block ${trades ? "text-up" : "text-muted"}`}>{trades ? <><span className="font-mono tnum">{trades}</span> {trades === 1 ? "trade" : "trades"}</> : "No trades yet"}</span>
<p className="flex min-w-0 items-baseline justify-between gap-2">
<span className="truncate text-[13px] font-semibold text-ink">{l.name}</span>
<time dateTime={l.block_time} title={new Date(l.block_time).toUTCString()} className={`shrink-0 font-mono text-[11px] tnum ${fresh ? "text-up" : "text-muted"}`} suppressHydrationWarning>{ago(l.block_time, now)} ago</time>
</p>
<p className="mt-0.5 flex min-w-0 items-baseline justify-between gap-2 text-[11px]">
<span className="truncate text-muted"><span className="font-mono text-body">{l.symbol}</span><span aria-hidden="true"> · </span><span className="sr-only">, market cap </span><span className="font-mono tnum">{capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact}</span></span>
<span className={`shrink-0 ${trades ? "text-up" : "text-muted"}`}>{trades ? <><span className="font-mono tnum">{trades}</span> {trades === 1 ? "trade" : "trades"}</> : "No trades yet"}</span>
</p>
</div>
</Link>
</li>
</motion.li>
);
})}
</AnimatePresence>
</ol>
)}
</section>
</MagicCard>
);
}
Loading
Loading