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:
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user