d50c05b120
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>
205 lines
5.8 KiB
TypeScript
205 lines
5.8 KiB
TypeScript
'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',
|
||
}
|