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
30 changes: 30 additions & 0 deletions app/package-lock.json

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

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@aws-sdk/client-s3": "^3.1127.0",
"@base-ui/react": "^1.8.0",
"@coinbase/wallet-sdk": "^4.3.7",
"@number-flow/react": "^0.6.2",
"@tanstack/react-query": "^5.91.3",
"clsx": "^2.1.1",
"lightweight-charts": "^5.2.1",
Expand Down
8 changes: 6 additions & 2 deletions app/src/app/api/launch/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ import { ethUsd } from "@/lib/launchpad/ethPrice";

export const dynamic = "force-dynamic";

/** GET /api/launch/search?q=&chain= → { q, launches } (name / symbol substring, or exact address). */
/**
* GET /api/launch/search?q=&chain=&order= → { q, launches } (name / symbol substring, or exact address).
* Relevance first; ties go newest-first, or to the most-held token with `order=holders` (the ⌘K search).
*/
export async function GET(req: Request) {
const u = new URL(req.url);
const q = (u.searchParams.get("q") ?? "").slice(0, 80);
const c = u.searchParams.get("chain");
const order = u.searchParams.get("order") === "holders" ? "holders" : "newest";
try {
const usd = await ethUsd();
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd });
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd, order });
return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] search failed:", err instanceof Error ? err.message : err);
Expand Down
24 changes: 24 additions & 0 deletions app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
/* surfaces */
--color-paper: #fafaf8;
--color-card: #ffffff;
--color-raised: #ffffff; /* floating layers: search, menus, sheets, dialogs */
--color-line: #e7e5e4;
--color-line-strong: #d6d3d1;

Expand Down Expand Up @@ -68,6 +69,7 @@
color-scheme: dark;
--color-paper: #000000;
--color-card: #0a0a0a;
--color-raised: #141414; /* one step up from card, so a floating layer reads without a shadow */
--color-line: #1f1f1f;
--color-line-strong: #333333;
--color-ink: #ffffff;
Expand Down Expand Up @@ -106,6 +108,7 @@
color-scheme: light;
--color-paper: #ffffff;
--color-card: #ffffff;
--color-raised: #ffffff;
--color-line: #e7e5e4;
--color-line-strong: #d6d3d1;
--color-ink: #0f172a;
Expand Down Expand Up @@ -333,3 +336,24 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
animation: none !important;
}
}

/* ── shared-element morphs between pages (React <ViewTransition share="morph">) ──
A token's mark and name travel from the market row into the token page header.
The page root does not cross-fade (see above), so only the token moves. A short
blur mid-flight hides the scaling of text snapshots. */
::view-transition-group(.morph) {
animation-duration: 360ms;
animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
}
::view-transition-image-pair(.morph) {
animation-name: bb-via-blur;
}
@keyframes bb-via-blur { 40% { filter: blur(2px); } }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(.morph),
::view-transition-image-pair(.morph),
::view-transition-old(.morph),
::view-transition-new(.morph) {
animation: none !important;
}
}
11 changes: 7 additions & 4 deletions app/src/app/t/[chain]/[token]/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Sk } from "@/components/Skeleton";
import PendingTokenHeader from "@/components/launchpad/PendingTokenHeader";

/* Same frame as page.tsx (main padding, breadcrumb row, header): the token header that the market-row
morph lands on must sit exactly where the real one renders, or the second hop would jump. */
export default function Loading() {
return <main className="mx-auto max-w-6xl space-y-6 px-4 pt-7 pb-28" aria-busy="true" aria-label="Loading token market">
<Sk className="h-5 w-24" />
<header className="flex items-center gap-4"><Sk className="size-14 shrink-0 rounded-2xl" /><div className="space-y-2"><Sk className="h-8 w-48" /><Sk className="h-3 w-60 max-w-full" /></div></header>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
return <main className="mx-auto max-w-6xl px-4 pt-5 pb-28 sm:pt-7 lg:pb-16" aria-busy="true" aria-label="Loading token market">
<div className="mb-5 flex min-h-8 items-center"><Sk className="h-4 w-24" /></div>
<PendingTokenHeader />
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem] lg:gap-6">
<div className="min-w-0 space-y-4">
<div className="overflow-hidden rounded-2xl border border-line p-5"><Sk className="h-3 w-24" /><Sk className="mt-3 h-10 w-44" /><Sk className="mt-6 h-10 w-full" /><Sk className="mt-3 h-72 w-full sm:h-[360px] xl:h-[390px]" /></div>
<div className="grid grid-cols-2 gap-4 rounded-xl border border-line bg-card p-4 sm:grid-cols-4">{Array.from({ length: 4 }, (_, i) => <div key={i} className="space-y-2"><Sk className="h-3 w-16" /><Sk className="h-5 w-full" /></div>)}</div>
Expand Down
10 changes: 8 additions & 2 deletions app/src/app/t/[chain]/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { isAddress, type Address } from "viem";
import { ArrowLeft, ArrowUpRight, Globe, Share2 } from "lucide-react";
import TokenAvatar from "@/components/launchpad/TokenAvatar";
import { MorphAvatar, MorphName } from "@/components/launchpad/TokenMorph";
import { feeModeOf } from "@/components/launchpad/FeeChip";
import TradePanel from "@/components/launchpad/TradePanel";
import CollectPanel from "@/components/launchpad/CollectPanel";
Expand Down Expand Up @@ -122,9 +123,14 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s

<header className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="shrink-0 rounded-2xl" />
{/* the mark and name the market row morphs into (see TokenMorph); PendingTokenHeader holds the same frame while loading */}
<MorphAvatar chain={l.chain} token={l.token}>
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="shrink-0 rounded-2xl" />
</MorphAvatar>
<div className="min-w-0">
<h1 className="break-words font-display text-2xl font-bold tracking-[-0.03em] text-ink sm:text-3xl">{l.name}</h1>
<MorphName chain={l.chain} token={l.token}>
<h1 className="break-words font-display text-2xl font-bold tracking-[-0.03em] text-ink sm:text-3xl">{l.name}</h1>
</MorphName>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted"><span className="font-mono text-body">${l.symbol}</span><span aria-hidden>·</span>{quote.key === "twig" ? <TwigBadge label="Paired with TWIG" /> : quote.key === "gitlawb" ? <GitlawbBadge label="Paired with GITLAWB" /> : quote.key === "museworld" ? <MuseworldBadge label="Paired with MUSEWORLD" /> : <span>Paired with {quote.symbol}</span>}{unlisted ? <UnlistedPairBadge symbol={quote.symbol} /> : null}<span aria-hidden>·</span><span title={new Date(l.block_time).toUTCString()}>Launched {ago(l.block_time, now)} ago</span></div>
</div>
</div>
Expand Down
195 changes: 195 additions & 0 deletions app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"use client";

import { useEffect, useId, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { Dialog } from "@base-ui/react/dialog";
import { Autocomplete } from "@base-ui/react/autocomplete";
import { ArrowLeftRight, CornerDownLeft, FileText, Moon, Search, Sun } from "lucide-react";
import TokenAvatar from "@/components/launchpad/TokenAvatar";
import { CHAIN_SHORT } from "@/lib/chainPublic";
import { marketChange, marketUsd } from "@/lib/launchpad/market-format";
import { setPendingToken } from "@/lib/launchpad/token-transition";
import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette";
import { useOpenBridge } from "./bridge/BridgeProvider";

type LaunchesResponse = { launches?: Parameters<typeof paletteTokenFrom>[0][] };

/**
* ⌘K search: tokens (by name, symbol or address), pages and a few actions, from any screen.
* Base UI's Dialog + inline Autocomplete own focus, keyboard navigation and the listbox semantics.
*/
export default function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useRouter();
const { resolvedTheme, setTheme } = useTheme();
const openBridge = useOpenBridge();
const hintId = useId();
const [query, setQuery] = useState("");
const [trending, setTrending] = useState<PaletteToken[] | null>(null);
const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null);

// What is trending fills the list before anything is typed. Fetched once, on the first open.
useEffect(() => {
if (!open || trending !== null) return;
const ac = new AbortController();
fetch("/api/launch/list?sort=trending&limit=6", { signal: ac.signal, cache: "no-store" })
.then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : null))
.then((j) => setTrending((j?.launches ?? []).map(paletteTokenFrom)))
.catch(() => {
if (!ac.signal.aborted) setTrending([]);
});
return () => ac.abort();
}, [open, trending]);

// Token search, debounced; a late answer for an older query is dropped.
const q = query.trim();
useEffect(() => {
if (!open || !isSearchableQuery(q)) return;
const ac = new AbortController();
const timer = setTimeout(() => {
// order=holders: among equally relevant names, the token people actually hold comes first
fetch(`/api/launch/search?q=${encodeURIComponent(q)}&order=holders`, { signal: ac.signal, cache: "no-store" })
.then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : Promise.reject(new Error(String(r.status)))))
.then((j) => setResults({ q, tokens: (j.launches ?? []).slice(0, 8).map(paletteTokenFrom), failed: false }))
.catch(() => {
if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true });
});
}, 160);
return () => {
clearTimeout(timer);
ac.abort();
};
}, [open, q]);

const current = results && results.q === q ? results : null;
const searching = isSearchableQuery(q) && !current;
const theme = resolvedTheme === "dark" ? "dark" : "light";
const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme }), [query, current, trending, theme]);
const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : "";

const close = () => {
onOpenChange(false);
setQuery("");
};

const run = (item: PaletteItem) => {
close();
if (item.kind === "token") {
const t = item.token;
setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image });
router.push(`/t/${t.chain}/${t.token}`);
} else if (item.kind === "page") {
router.push(item.href);
} else if (item.action === "bridge") {
// let this dialog finish closing first, so focus lands in the bridge panel
requestAnimationFrame(() => openBridge?.());
} else {
setTheme(theme === "dark" ? "light" : "dark");
}
};

return (
<Dialog.Root open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 z-[90] bg-scrim/50 transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none" />
<Dialog.Viewport className="fixed inset-0 z-[91] flex items-start justify-center overflow-hidden px-3 pt-[min(14vh,7rem)] pb-3">
<Dialog.Popup
aria-label="Search openlaunch"
className="relative flex max-h-[min(36rem,calc(100dvh-6rem))] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-line-strong bg-raised text-ink shadow-dialog transition-[opacity,translate,scale] duration-150 ease-out data-[ending-style]:-translate-y-2 data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:-translate-y-2 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 motion-reduce:transition-none"
>
<Autocomplete.Root
open
inline
items={groups}
filter={null}
value={query}
onValueChange={(next, details) => {
// choosing an item writes its label into the field; the dialog is closing, so keep the query as typed
if (details.reason !== "item-press") setQuery(next);
}}
itemToStringValue={(item: PaletteItem) => item.label}
autoHighlight="always"
keepHighlight
>
{/* focus shows as the field's underline turning blue (the global ring would box the whole header row) */}
<Autocomplete.InputGroup className="flex items-center gap-3 border-b border-line px-4 transition-colors focus-within:border-brand">
<Search size={18} className="shrink-0 text-muted" aria-hidden />
<Autocomplete.Input
aria-label="Search tokens, pages and actions"
aria-describedby={hintId}
placeholder="Search tokens by name, symbol or address"
className="h-14 w-full border-0 bg-transparent text-base text-ink outline-none! placeholder:text-muted"
/>
<Dialog.Close className="shrink-0 rounded-md border border-line-strong px-1.5 py-0.5 font-mono text-[11px] text-muted hover:text-ink">Esc</Dialog.Close>
</Autocomplete.InputGroup>

<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2 [scroll-padding-block:0.5rem]">
<Autocomplete.Status className="px-4 pt-1 pb-2 text-xs text-muted empty:hidden">{status}</Autocomplete.Status>
<Autocomplete.Empty>
<div className="px-4 py-8 text-center text-sm text-muted">
{searching ? "Searching tokens…" : <>Nothing matches “{q}”. Try a token symbol or paste its contract address.</>}
</div>
</Autocomplete.Empty>
<Autocomplete.List>
{(group: PaletteGroup) => (
<Autocomplete.Group key={group.value} items={group.items} className="px-2 pb-1">
<Autocomplete.GroupLabel className="px-2 pt-2 pb-1.5 text-xs font-medium text-muted select-none">{group.value}</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{(item: PaletteItem) => (
<Autocomplete.Item
key={item.id}
value={item}
onClick={() => run(item)}
className="group flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-2 text-sm text-ink outline-none select-none data-[highlighted]:bg-line"
>
<PaletteRow item={item} />
</Autocomplete.Item>
)}
</Autocomplete.Collection>
</Autocomplete.Group>
)}
</Autocomplete.List>
</div>

<div className="flex items-center gap-4 border-t border-line bg-card px-4 py-2.5 text-[11px] text-muted">
<span id={hintId}>Use the arrow keys to move and Enter to open.</span>
<span className="ml-auto inline-flex items-center gap-1"><CornerDownLeft size={12} aria-hidden /> Open</span>
</div>
</Autocomplete.Root>
</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
);
}

function PaletteRow({ item }: { item: PaletteItem }) {
if (item.kind === "token") {
const t = item.token;
const change = marketChange(t.change);
return (
<>
<TokenAvatar chain={t.chain} token={t.token} symbol={t.symbol} image={t.image} size={28} className="shrink-0 rounded-lg" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{t.name}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted">
<span className="font-mono text-body">{t.symbol}</span>
<span className="rounded-md border border-line px-1.5 text-[10px] leading-4">{CHAIN_SHORT[t.chain as keyof typeof CHAIN_SHORT] ?? t.chain}</span>
</span>
</span>
<span className="shrink-0 text-right font-mono text-xs tnum">
<span className="block text-ink">{t.capUsd !== null ? marketUsd(t.capUsd) : "—"}</span>
<span className={change.direction === "up" ? "text-up" : change.direction === "down" ? "text-down-ink" : "text-muted"}>{change.label}</span>
</span>
</>
);
}
const Icon = item.kind === "page" ? FileText : item.action === "bridge" ? ArrowLeftRight : item.label.includes("light") ? Sun : Moon;
return (
<>
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-card text-body group-data-[highlighted]:text-ink"><Icon size={15} aria-hidden /></span>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="shrink-0 text-xs text-muted">{item.kind === "page" ? "Page" : "Action"}</span>
</>
);
}
Loading
Loading