fix: pre-launch UI hardening + KOL reduce-action type, proxy IP relay, settings redesign
Frontend half of the pre-launch audit campaign: - types/index.ts + kol/KolPageClient.tsx: add missing 'reduce' KolAction (backend emits it; frontend lacked the type + color/label maps → undefined styling). Adds ACTION_COLOR/actionLabel/postActionLabel entries. - proxy/[...path]/route.ts: relay real client IP (x-forwarded-for / x-real-ip) so the backend rate limiter buckets per-user instead of per-Next-server (BUG-02). - Settings/BotConfigPanel redesign, paper-mode clarity, copy cleanup. - Assorted page/display fixes, loading states, Pagination component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import type { TrumpPost } from '@/types'
|
||||
|
||||
function fmtPct(n: number | null | undefined) {
|
||||
@@ -93,7 +93,7 @@ interface PostRowProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const impact = post.price_impact
|
||||
|
||||
@@ -260,6 +260,7 @@ export default function PostRow({ post, selected, onClick }: PostRowProps) {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default PostRow
|
||||
export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function Navbar() {
|
||||
key={tab.key}
|
||||
href={`/${locale}${tab.key === '/' ? '' : tab.key}`}
|
||||
className={`nav-tab ${isActive(tab.key) ? 'active' : ''}`}
|
||||
prefetch
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
|
||||
@@ -179,11 +179,20 @@ export default function OpenPositions() {
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
const [closeErr, setCloseErr] = useState('')
|
||||
const [growId, setGrowId] = useState<number | null>(null)
|
||||
// Separate error slot for the Grow toggle so a transient toggle failure
|
||||
// doesn't squat in the panel-header `err` banner (which is otherwise
|
||||
// owned by the 15s poll). Auto-clears after 4s.
|
||||
const [growErr, setGrowErr] = useState('')
|
||||
useEffect(() => {
|
||||
if (!growErr) return
|
||||
const t = setTimeout(() => setGrowErr(''), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [growErr])
|
||||
|
||||
async function toggleGrow(p: OpenPosition) {
|
||||
if (!address || growId !== null) return
|
||||
const next = !p.grow_mode
|
||||
setGrowId(p.trade_id)
|
||||
setGrowId(p.trade_id); setGrowErr('')
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'set_trade_grow', wallet: address,
|
||||
@@ -194,9 +203,9 @@ export default function OpenPositions() {
|
||||
x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x))
|
||||
} catch (e) {
|
||||
if (isUserRejection(e)) {
|
||||
setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
|
||||
setGrowErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
|
||||
} else {
|
||||
setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|
||||
setGrowErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90)
|
||||
|| (isZh ? 'Grow 切换失败' : 'grow toggle failed'))
|
||||
}
|
||||
} finally { setGrowId(null) }
|
||||
@@ -237,7 +246,12 @@ export default function OpenPositions() {
|
||||
void load()
|
||||
const id = setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [address, isConnected, signMessageAsync, isZh])
|
||||
// signMessageAsync and isZh are intentionally excluded: signMessageAsync is
|
||||
// never called inside this effect (load() uses cached envelopes only), and
|
||||
// isZh is a compile-time constant (always false). Including either would
|
||||
// restart the 15-second poll timer on every wagmi reference change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, isConnected])
|
||||
|
||||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||||
async function confirmCloseTrade() {
|
||||
@@ -257,8 +271,15 @@ export default function OpenPositions() {
|
||||
setClosing(null)
|
||||
setCloseState('idle')
|
||||
} catch (e: unknown) {
|
||||
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||||
setCloseState('err')
|
||||
// Distinguish a user-cancelled signature (benign, close the modal) from
|
||||
// a real failure (keep modal open in 'err' state so user can retry or
|
||||
// read the error).
|
||||
if (isUserRejection(e)) {
|
||||
setClosing(null); setCloseState('idle'); setCloseErr('')
|
||||
} else {
|
||||
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||||
setCloseState('err')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +372,9 @@ export default function OpenPositions() {
|
||||
{err && (
|
||||
<span style={{ fontSize: 11, color: 'var(--down)' }}>● {err}</span>
|
||||
)}
|
||||
{growErr && !err && (
|
||||
<span style={{ fontSize: 11, color: 'var(--down)' }}>● {growErr}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position list (or empty state) */}
|
||||
@@ -396,7 +420,14 @@ export default function OpenPositions() {
|
||||
presses "Close now", reads the impact summary, then confirms. */}
|
||||
{closing && (
|
||||
<div
|
||||
onClick={() => closeState === 'idle' && setClosing(null)}
|
||||
onClick={() => {
|
||||
// Backdrop dismisses in both idle (never started) and err
|
||||
// (failed, user wants out) states. NOT during signing/closing —
|
||||
// the wallet popup or in-flight POST shouldn't be orphaned.
|
||||
if (closeState === 'idle' || closeState === 'err') {
|
||||
setClosing(null); setCloseState('idle'); setCloseErr('')
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
|
||||
@@ -73,6 +73,12 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
|
||||
if (autoOn === on) return
|
||||
|
||||
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
|
||||
// second click on the same pill (or the opposite pill) slips past the
|
||||
// `if (busy) return` guard and queues a second sheet + second signature.
|
||||
// We still clear it in `finally` below.
|
||||
setBusy(true); setErr('')
|
||||
|
||||
// Show pre-signing confirmation sheet before triggering MetaMask
|
||||
const confirmed = await confirmSign(on
|
||||
? {
|
||||
@@ -96,9 +102,8 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
danger: false,
|
||||
}
|
||||
)
|
||||
if (!confirmed) return
|
||||
if (!confirmed) { setBusy(false); return } // release the slot we claimed
|
||||
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const env = await signRequest({
|
||||
action: 'set_auto_trade', wallet: address,
|
||||
|
||||
@@ -389,7 +389,12 @@ export default function BotConfigPanel() {
|
||||
<span style={{ fontSize: 11, color: subPaperChoice === 'paper' ? 'var(--ink-2)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'paper' ? 600 : 400 }}>Paper</span>
|
||||
<Switch
|
||||
on={subPaperChoice === 'live'}
|
||||
onChange={v => setSubPaperChoice(v ? 'live' : 'paper')}
|
||||
onChange={v => {
|
||||
setSubPaperChoice(v ? 'live' : 'paper')
|
||||
// Clear any stale subscribe-error from a previous attempt so
|
||||
// the user sees a clean state for the new paper/live choice.
|
||||
if (subState === 'err') { setSubState('idle'); setSubErr('') }
|
||||
}}
|
||||
tone="amber"
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: subPaperChoice === 'live' ? 'var(--amber-ink)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'live' ? 600 : 400 }}>Live</span>
|
||||
@@ -492,7 +497,15 @@ export default function BotConfigPanel() {
|
||||
)}
|
||||
{/* Live mode: show key input */}
|
||||
{!paperMode && (hlApiKeySet && !apiKey ? (
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }} onClick={() => setApiKey(' ')}>Rotate</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
// Switch to edit mode AND clear any stale error / status from
|
||||
// a previous failed save so the row isn't pre-stained red.
|
||||
setApiKey(' '); setKeyState('idle'); setKeyErr('')
|
||||
}}
|
||||
>Rotate</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
@@ -787,13 +800,13 @@ export default function BotConfigPanel() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="num-field">
|
||||
<span className="prefix">From</span>
|
||||
<input type="datetime-local" value={fromLocal}
|
||||
<input type="datetime-local" lang="en-US" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
|
||||
<div className="num-field">
|
||||
<span className="prefix">Until</span>
|
||||
<input type="datetime-local" value={untilLocal}
|
||||
<input type="datetime-local" lang="en-US" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type { BotTrade, TrumpPost } from '@/types'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
|
||||
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
|
||||
@@ -28,8 +29,11 @@ interface Props {
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const ASSETS = ['all', 'BTC', 'ETH', 'SOL'] as const
|
||||
const SIDES = ['all', 'long', 'short'] as const
|
||||
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
|
||||
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
|
||||
const SIDES = ['all', 'long', 'short'] as const
|
||||
|
||||
const TRADES_PER_PAGE = 25
|
||||
|
||||
export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
const locale = useLocale()
|
||||
@@ -38,6 +42,18 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
const [sideFilter, setSideFilter] = useState('all')
|
||||
const [sourceFilter, setSourceFilter] = useState('all')
|
||||
const [hidePaper, setHidePaper] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Distinct assets present in the loaded trade set — drives the asset filter.
|
||||
// Dynamic so new perps (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear without
|
||||
// a code change; sorted alphabetically with BTC/ETH first for familiarity.
|
||||
const assets: string[] = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const t of trades) if (t.asset) set.add(t.asset)
|
||||
const priority = ['BTC', 'ETH', 'SOL', 'TRUMP']
|
||||
const rest = Array.from(set).filter(a => !priority.includes(a)).sort()
|
||||
return ['all', ...priority.filter(a => set.has(a)), ...rest]
|
||||
}, [trades])
|
||||
|
||||
// Distinct sources present in the loaded trade set — drives the filter UI.
|
||||
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
|
||||
@@ -65,13 +81,17 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
return acc
|
||||
}, [trades])
|
||||
|
||||
const filtered = trades.filter(t => {
|
||||
const filtered = useMemo(() => trades.filter(t => {
|
||||
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
|
||||
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
|
||||
if (sideFilter !== 'all' && t.side !== sideFilter) return false
|
||||
if (hidePaper && t.is_paper) return false
|
||||
return true
|
||||
})
|
||||
}), [trades, sourceFilter, assetFilter, sideFilter, hidePaper])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / TRADES_PER_PAGE))
|
||||
const safePage = Math.min(page, totalPages)
|
||||
const pageRows = filtered.slice((safePage - 1) * TRADES_PER_PAGE, safePage * TRADES_PER_PAGE)
|
||||
|
||||
// Exclude externally-closed trades (pnl_usd null) from aggregates.
|
||||
const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined)
|
||||
@@ -111,7 +131,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => setSourceFilter(sourceFilter === src ? 'all' : src)}
|
||||
onClick={() => { setSourceFilter(sourceFilter === src ? 'all' : src); setPage(1) }}
|
||||
style={{
|
||||
textAlign: 'left', cursor: 'pointer',
|
||||
background: bg, borderRadius: 8, padding: '12px 14px',
|
||||
@@ -146,7 +166,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
|
||||
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'} {sourceFilter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setSourceFilter('all')}
|
||||
onClick={() => { setSourceFilter('all'); setPage(1) }}
|
||||
style={{ marginLeft: 8, padding: '2px 8px', fontSize: 10,
|
||||
border: '1px solid var(--line)', borderRadius: 4,
|
||||
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
|
||||
@@ -182,11 +202,11 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div className="nav-tabs">
|
||||
{ASSETS.map(a => (
|
||||
{assets.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
|
||||
onClick={() => setAssetFilter(a)}
|
||||
onClick={() => { setAssetFilter(a); setPage(1) }}
|
||||
>
|
||||
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
|
||||
</button>
|
||||
@@ -197,7 +217,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<button
|
||||
key={s}
|
||||
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
|
||||
onClick={() => setSideFilter(s)}
|
||||
onClick={() => { setSideFilter(s); setPage(1) }}
|
||||
>
|
||||
{s === 'all' ? (isZh ? '全部方向' : 'All') : s === 'long' ? (isZh ? '做多' : 'Long') : (isZh ? '做空' : 'Short')}
|
||||
</button>
|
||||
@@ -212,7 +232,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePaper}
|
||||
onChange={e => setHidePaper(e.target.checked)}
|
||||
onChange={e => { setHidePaper(e.target.checked); setPage(1) }}
|
||||
/>
|
||||
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
|
||||
</label>
|
||||
@@ -247,7 +267,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map(t => {
|
||||
{pageRows.map(t => {
|
||||
const tp = posts.find(p => p.id === t.trigger_post_id)
|
||||
const roi =
|
||||
t.entry_price && t.exit_price != null
|
||||
@@ -326,6 +346,18 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && (
|
||||
<Pagination
|
||||
page={safePage}
|
||||
total={totalPages}
|
||||
count={filtered.length}
|
||||
pageSize={TRADES_PER_PAGE}
|
||||
onChange={setPage}
|
||||
scrollTop={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
total: number // total pages
|
||||
count: number // total items
|
||||
pageSize: number
|
||||
onChange: (page: number) => void
|
||||
/** Scroll to top on page change. Default true. */
|
||||
scrollTop?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared pagination control used across all list pages.
|
||||
*
|
||||
* Visual design:
|
||||
* ‹ 1 2 … 5 [6] 7 … 12 › Showing 151–180 of 340
|
||||
*
|
||||
* Active page uses brand orange. Ellipsis collapses distant pages.
|
||||
* Prev/Next disabled at boundaries (dimmed, not hidden).
|
||||
*/
|
||||
export default function Pagination({
|
||||
page, total, count, pageSize, onChange, scrollTop = true,
|
||||
}: PaginationProps) {
|
||||
if (total <= 1) return null
|
||||
|
||||
const from = (page - 1) * pageSize + 1
|
||||
const to = Math.min(page * pageSize, count)
|
||||
|
||||
function go(p: number) {
|
||||
if (p < 1 || p > total || p === page) return
|
||||
onChange(p)
|
||||
if (scrollTop) window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
/** Page number list with ellipsis compression. */
|
||||
function pages(): (number | '…')[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
||||
const out: (number | '…')[] = []
|
||||
const push = (n: number | '…') => {
|
||||
if (out[out.length - 1] !== n) out.push(n)
|
||||
}
|
||||
push(1)
|
||||
if (page > 3) push('…')
|
||||
for (let i = Math.max(2, page - 1); i <= Math.min(total - 1, page + 1); i++) push(i)
|
||||
if (page < total - 2) push('…')
|
||||
push(total)
|
||||
return out
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapStyle}>
|
||||
{/* ← Prev */}
|
||||
<NavBtn onClick={() => go(page - 1)} disabled={page === 1} label="Previous page">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M9 11L5 7l4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span>Prev</span>
|
||||
</NavBtn>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div style={numsStyle}>
|
||||
{pages().map((n, i) =>
|
||||
n === '…' ? (
|
||||
<span key={`e${i}`} style={ellipsisStyle}>…</span>
|
||||
) : (
|
||||
<PageBtn key={n} num={n as number} active={n === page} onClick={go} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next → */}
|
||||
<NavBtn onClick={() => go(page + 1)} disabled={page === total} label="Next page">
|
||||
<span>Next</span>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</NavBtn>
|
||||
|
||||
{/* Count */}
|
||||
<span style={countStyle}>
|
||||
{from}–{to} <span style={{ opacity: 0.5 }}>of</span> {count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function PageBtn({ num, active, onClick }: { num: number; active: boolean; onClick: (n: number) => void }) {
|
||||
const [hover, setHover] = React.useState(false)
|
||||
|
||||
const bg = active
|
||||
? 'var(--brand, #f5a524)'
|
||||
: hover
|
||||
? 'var(--bg-sunk)'
|
||||
: 'transparent'
|
||||
|
||||
const color = active
|
||||
? '#fff'
|
||||
: hover
|
||||
? 'var(--ink)'
|
||||
: 'var(--ink-2)'
|
||||
|
||||
const border = active
|
||||
? '1.5px solid var(--brand, #f5a524)'
|
||||
: '1.5px solid var(--line)'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick(num)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
style={{
|
||||
width: 34, height: 34,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
border,
|
||||
background: bg,
|
||||
color,
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 700 : 400,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.12s, color 0.12s, border-color 0.12s',
|
||||
boxShadow: active ? '0 1px 4px rgba(245,165,36,0.25)' : 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBtn({
|
||||
onClick, disabled, label, children,
|
||||
}: {
|
||||
onClick: () => void; disabled: boolean; label: string; children: React.ReactNode
|
||||
}) {
|
||||
const [hover, setHover] = React.useState(false)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
onMouseEnter={() => !disabled && setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
height: 34, padding: '0 12px',
|
||||
borderRadius: 8,
|
||||
border: '1.5px solid var(--line)',
|
||||
background: hover && !disabled ? 'var(--bg-sunk)' : 'transparent',
|
||||
color: disabled ? 'var(--ink-4)' : hover ? 'var(--ink)' : 'var(--ink-2)',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.12s, color 0.12s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const wrapStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
padding: '28px 0 8px',
|
||||
flexWrap: 'wrap',
|
||||
}
|
||||
|
||||
const numsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const ellipsisStyle: React.CSSProperties = {
|
||||
width: 28,
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-4)',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-3)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
marginLeft: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
@@ -27,9 +27,18 @@ export interface SignConfirmOptions {
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
// Track the currently-mounted sheet so a second confirmSign() call can resolve
|
||||
// the first one with `false` instead of leaving its promise dangling forever.
|
||||
let _activeCancel: (() => void) | null = null
|
||||
|
||||
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
|
||||
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// If another sheet is already mounted, cancel it first so its awaiting
|
||||
// caller resolves with `false` rather than hanging indefinitely.
|
||||
if (_activeCancel) {
|
||||
try { _activeCancel() } catch { /* ignore */ }
|
||||
}
|
||||
const existing = document.getElementById('sign-confirm-sheet-root')
|
||||
if (existing?.parentNode) {
|
||||
existing.parentNode.removeChild(existing)
|
||||
@@ -43,6 +52,9 @@ export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
|
||||
function cleanup(result: boolean) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
// Clear the active-cancel slot only if it still points to *this* sheet —
|
||||
// a later confirmSign() may have already replaced it.
|
||||
if (_activeCancel === cancelSelf) _activeCancel = null
|
||||
root.unmount()
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container)
|
||||
@@ -50,7 +62,11 @@ export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={() => cleanup(false)} />)
|
||||
// Stable reference used both as the active-cancel handle and inside cleanup.
|
||||
const cancelSelf = () => cleanup(false)
|
||||
_activeCancel = cancelSelf
|
||||
|
||||
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={cancelSelf} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user