Skip to content

Commit 4e2d8bc

Browse files
authored
Merge pull request #60 from Twigpine/feat/ui-overhaul
UI overhaul, phase 1: search everywhere, row-to-page morph, rolling live numbers
2 parents e6e91dc + 0789d64 commit 4e2d8bc

32 files changed

Lines changed: 815 additions & 57 deletions

‎app/package-lock.json‎

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@aws-sdk/client-s3": "^3.1127.0",
1616
"@base-ui/react": "^1.8.0",
1717
"@coinbase/wallet-sdk": "^4.3.7",
18+
"@number-flow/react": "^0.6.2",
1819
"@tanstack/react-query": "^5.91.3",
1920
"clsx": "^2.1.1",
2021
"lightweight-charts": "^5.2.1",

‎app/src/app/api/launch/search/route.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import { ethUsd } from "@/lib/launchpad/ethPrice";
55

66
export const dynamic = "force-dynamic";
77

8-
/** GET /api/launch/search?q=&chain= → { q, launches } (name / symbol substring, or exact address). */
8+
/**
9+
* GET /api/launch/search?q=&chain=&order= → { q, launches } (name / symbol substring, or exact address).
10+
* Relevance first; ties go newest-first, or to the most-held token with `order=holders` (the ⌘K search).
11+
*/
912
export async function GET(req: Request) {
1013
const u = new URL(req.url);
1114
const q = (u.searchParams.get("q") ?? "").slice(0, 80);
1215
const c = u.searchParams.get("chain");
16+
const order = u.searchParams.get("order") === "holders" ? "holders" : "newest";
1317
try {
1418
const usd = await ethUsd();
15-
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd });
19+
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd, order });
1620
return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } });
1721
} catch (err) {
1822
console.error("[launch] search failed:", err instanceof Error ? err.message : err);

‎app/src/app/globals.css‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
/* surfaces */
1818
--color-paper: #fafaf8;
1919
--color-card: #ffffff;
20+
--color-raised: #ffffff; /* floating layers: search, menus, sheets, dialogs */
2021
--color-line: #e7e5e4;
2122
--color-line-strong: #d6d3d1;
2223

@@ -68,6 +69,7 @@
6869
color-scheme: dark;
6970
--color-paper: #000000;
7071
--color-card: #0a0a0a;
72+
--color-raised: #141414; /* one step up from card, so a floating layer reads without a shadow */
7173
--color-line: #1f1f1f;
7274
--color-line-strong: #333333;
7375
--color-ink: #ffffff;
@@ -106,6 +108,7 @@
106108
color-scheme: light;
107109
--color-paper: #ffffff;
108110
--color-card: #ffffff;
111+
--color-raised: #ffffff;
109112
--color-line: #e7e5e4;
110113
--color-line-strong: #d6d3d1;
111114
--color-ink: #0f172a;
@@ -333,3 +336,24 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
333336
animation: none !important;
334337
}
335338
}
339+
340+
/* ── shared-element morphs between pages (React <ViewTransition share="morph">) ──
341+
A token's mark and name travel from the market row into the token page header.
342+
The page root does not cross-fade (see above), so only the token moves. A short
343+
blur mid-flight hides the scaling of text snapshots. */
344+
::view-transition-group(.morph) {
345+
animation-duration: 360ms;
346+
animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
347+
}
348+
::view-transition-image-pair(.morph) {
349+
animation-name: bb-via-blur;
350+
}
351+
@keyframes bb-via-blur { 40% { filter: blur(2px); } }
352+
@media (prefers-reduced-motion: reduce) {
353+
::view-transition-group(.morph),
354+
::view-transition-image-pair(.morph),
355+
::view-transition-old(.morph),
356+
::view-transition-new(.morph) {
357+
animation: none !important;
358+
}
359+
}

‎app/src/app/t/[chain]/[token]/loading.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Sk } from "@/components/Skeleton";
2+
import PendingTokenHeader from "@/components/launchpad/PendingTokenHeader";
23

4+
/* Same frame as page.tsx (main padding, breadcrumb row, header): the token header that the market-row
5+
morph lands on must sit exactly where the real one renders, or the second hop would jump. */
36
export default function Loading() {
4-
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">
5-
<Sk className="h-5 w-24" />
6-
<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>
7-
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
7+
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">
8+
<div className="mb-5 flex min-h-8 items-center"><Sk className="h-4 w-24" /></div>
9+
<PendingTokenHeader />
10+
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_21rem] lg:gap-6">
811
<div className="min-w-0 space-y-4">
912
<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>
1013
<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>

‎app/src/app/t/[chain]/[token]/page.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
44
import { isAddress, type Address } from "viem";
55
import { ArrowLeft, ArrowUpRight, Globe, Share2 } from "lucide-react";
66
import TokenAvatar from "@/components/launchpad/TokenAvatar";
7+
import { MorphAvatar, MorphName } from "@/components/launchpad/TokenMorph";
78
import { feeModeOf } from "@/components/launchpad/FeeChip";
89
import TradePanel from "@/components/launchpad/TradePanel";
910
import CollectPanel from "@/components/launchpad/CollectPanel";
@@ -122,9 +123,14 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
122123

123124
<header className="mb-6 flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
124125
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
125-
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="shrink-0 rounded-2xl" />
126+
{/* the mark and name the market row morphs into (see TokenMorph); PendingTokenHeader holds the same frame while loading */}
127+
<MorphAvatar chain={l.chain} token={l.token}>
128+
<TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="shrink-0 rounded-2xl" />
129+
</MorphAvatar>
126130
<div className="min-w-0">
127-
<h1 className="break-words font-display text-2xl font-bold tracking-[-0.03em] text-ink sm:text-3xl">{l.name}</h1>
131+
<MorphName chain={l.chain} token={l.token}>
132+
<h1 className="break-words font-display text-2xl font-bold tracking-[-0.03em] text-ink sm:text-3xl">{l.name}</h1>
133+
</MorphName>
128134
<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>
129135
</div>
130136
</div>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"use client";
2+
3+
import { useEffect, useId, useMemo, useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { useTheme } from "next-themes";
6+
import { Dialog } from "@base-ui/react/dialog";
7+
import { Autocomplete } from "@base-ui/react/autocomplete";
8+
import { ArrowLeftRight, CornerDownLeft, FileText, Moon, Search, Sun } from "lucide-react";
9+
import TokenAvatar from "@/components/launchpad/TokenAvatar";
10+
import { CHAIN_SHORT } from "@/lib/chainPublic";
11+
import { marketChange, marketUsd } from "@/lib/launchpad/market-format";
12+
import { setPendingToken } from "@/lib/launchpad/token-transition";
13+
import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette";
14+
import { useOpenBridge } from "./bridge/BridgeProvider";
15+
16+
type LaunchesResponse = { launches?: Parameters<typeof paletteTokenFrom>[0][] };
17+
18+
/**
19+
* ⌘K search: tokens (by name, symbol or address), pages and a few actions, from any screen.
20+
* Base UI's Dialog + inline Autocomplete own focus, keyboard navigation and the listbox semantics.
21+
*/
22+
export default function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
23+
const router = useRouter();
24+
const { resolvedTheme, setTheme } = useTheme();
25+
const openBridge = useOpenBridge();
26+
const hintId = useId();
27+
const [query, setQuery] = useState("");
28+
const [trending, setTrending] = useState<PaletteToken[] | null>(null);
29+
const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null);
30+
31+
// What is trending fills the list before anything is typed. Fetched once, on the first open.
32+
useEffect(() => {
33+
if (!open || trending !== null) return;
34+
const ac = new AbortController();
35+
fetch("/api/launch/list?sort=trending&limit=6", { signal: ac.signal, cache: "no-store" })
36+
.then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : null))
37+
.then((j) => setTrending((j?.launches ?? []).map(paletteTokenFrom)))
38+
.catch(() => {
39+
if (!ac.signal.aborted) setTrending([]);
40+
});
41+
return () => ac.abort();
42+
}, [open, trending]);
43+
44+
// Token search, debounced; a late answer for an older query is dropped.
45+
const q = query.trim();
46+
useEffect(() => {
47+
if (!open || !isSearchableQuery(q)) return;
48+
const ac = new AbortController();
49+
const timer = setTimeout(() => {
50+
// order=holders: among equally relevant names, the token people actually hold comes first
51+
fetch(`/api/launch/search?q=${encodeURIComponent(q)}&order=holders`, { signal: ac.signal, cache: "no-store" })
52+
.then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : Promise.reject(new Error(String(r.status)))))
53+
.then((j) => setResults({ q, tokens: (j.launches ?? []).slice(0, 8).map(paletteTokenFrom), failed: false }))
54+
.catch(() => {
55+
if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true });
56+
});
57+
}, 160);
58+
return () => {
59+
clearTimeout(timer);
60+
ac.abort();
61+
};
62+
}, [open, q]);
63+
64+
const current = results && results.q === q ? results : null;
65+
const searching = isSearchableQuery(q) && !current;
66+
const theme = resolvedTheme === "dark" ? "dark" : "light";
67+
const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme }), [query, current, trending, theme]);
68+
const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : "";
69+
70+
const close = () => {
71+
onOpenChange(false);
72+
setQuery("");
73+
};
74+
75+
const run = (item: PaletteItem) => {
76+
close();
77+
if (item.kind === "token") {
78+
const t = item.token;
79+
setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image });
80+
router.push(`/t/${t.chain}/${t.token}`);
81+
} else if (item.kind === "page") {
82+
router.push(item.href);
83+
} else if (item.action === "bridge") {
84+
// let this dialog finish closing first, so focus lands in the bridge panel
85+
requestAnimationFrame(() => openBridge?.());
86+
} else {
87+
setTheme(theme === "dark" ? "light" : "dark");
88+
}
89+
};
90+
91+
return (
92+
<Dialog.Root open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
93+
<Dialog.Portal>
94+
<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" />
95+
<Dialog.Viewport className="fixed inset-0 z-[91] flex items-start justify-center overflow-hidden px-3 pt-[min(14vh,7rem)] pb-3">
96+
<Dialog.Popup
97+
aria-label="Search openlaunch"
98+
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"
99+
>
100+
<Autocomplete.Root
101+
open
102+
inline
103+
items={groups}
104+
filter={null}
105+
value={query}
106+
onValueChange={(next, details) => {
107+
// choosing an item writes its label into the field; the dialog is closing, so keep the query as typed
108+
if (details.reason !== "item-press") setQuery(next);
109+
}}
110+
itemToStringValue={(item: PaletteItem) => item.label}
111+
autoHighlight="always"
112+
keepHighlight
113+
>
114+
{/* focus shows as the field's underline turning blue (the global ring would box the whole header row) */}
115+
<Autocomplete.InputGroup className="flex items-center gap-3 border-b border-line px-4 transition-colors focus-within:border-brand">
116+
<Search size={18} className="shrink-0 text-muted" aria-hidden />
117+
<Autocomplete.Input
118+
aria-label="Search tokens, pages and actions"
119+
aria-describedby={hintId}
120+
placeholder="Search tokens by name, symbol or address"
121+
className="h-14 w-full border-0 bg-transparent text-base text-ink outline-none! placeholder:text-muted"
122+
/>
123+
<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>
124+
</Autocomplete.InputGroup>
125+
126+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2 [scroll-padding-block:0.5rem]">
127+
<Autocomplete.Status className="px-4 pt-1 pb-2 text-xs text-muted empty:hidden">{status}</Autocomplete.Status>
128+
<Autocomplete.Empty>
129+
<div className="px-4 py-8 text-center text-sm text-muted">
130+
{searching ? "Searching tokens…" : <>Nothing matches “{q}”. Try a token symbol or paste its contract address.</>}
131+
</div>
132+
</Autocomplete.Empty>
133+
<Autocomplete.List>
134+
{(group: PaletteGroup) => (
135+
<Autocomplete.Group key={group.value} items={group.items} className="px-2 pb-1">
136+
<Autocomplete.GroupLabel className="px-2 pt-2 pb-1.5 text-xs font-medium text-muted select-none">{group.value}</Autocomplete.GroupLabel>
137+
<Autocomplete.Collection>
138+
{(item: PaletteItem) => (
139+
<Autocomplete.Item
140+
key={item.id}
141+
value={item}
142+
onClick={() => run(item)}
143+
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"
144+
>
145+
<PaletteRow item={item} />
146+
</Autocomplete.Item>
147+
)}
148+
</Autocomplete.Collection>
149+
</Autocomplete.Group>
150+
)}
151+
</Autocomplete.List>
152+
</div>
153+
154+
<div className="flex items-center gap-4 border-t border-line bg-card px-4 py-2.5 text-[11px] text-muted">
155+
<span id={hintId}>Use the arrow keys to move and Enter to open.</span>
156+
<span className="ml-auto inline-flex items-center gap-1"><CornerDownLeft size={12} aria-hidden /> Open</span>
157+
</div>
158+
</Autocomplete.Root>
159+
</Dialog.Popup>
160+
</Dialog.Viewport>
161+
</Dialog.Portal>
162+
</Dialog.Root>
163+
);
164+
}
165+
166+
function PaletteRow({ item }: { item: PaletteItem }) {
167+
if (item.kind === "token") {
168+
const t = item.token;
169+
const change = marketChange(t.change);
170+
return (
171+
<>
172+
<TokenAvatar chain={t.chain} token={t.token} symbol={t.symbol} image={t.image} size={28} className="shrink-0 rounded-lg" />
173+
<span className="min-w-0 flex-1">
174+
<span className="block truncate font-medium">{t.name}</span>
175+
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted">
176+
<span className="font-mono text-body">{t.symbol}</span>
177+
<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>
178+
</span>
179+
</span>
180+
<span className="shrink-0 text-right font-mono text-xs tnum">
181+
<span className="block text-ink">{t.capUsd !== null ? marketUsd(t.capUsd) : "—"}</span>
182+
<span className={change.direction === "up" ? "text-up" : change.direction === "down" ? "text-down-ink" : "text-muted"}>{change.label}</span>
183+
</span>
184+
</>
185+
);
186+
}
187+
const Icon = item.kind === "page" ? FileText : item.action === "bridge" ? ArrowLeftRight : item.label.includes("light") ? Sun : Moon;
188+
return (
189+
<>
190+
<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>
191+
<span className="min-w-0 flex-1 truncate">{item.label}</span>
192+
<span className="shrink-0 text-xs text-muted">{item.kind === "page" ? "Page" : "Action"}</span>
193+
</>
194+
);
195+
}

0 commit comments

Comments
 (0)