mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 23:56:07 +00:00
Migrate frontend models/api/utils to TypeScript and modernize AntD theming (#4563)
* refactor(frontend): port api/* and reality-targets to TypeScript
Phase 1 of the JS→TS migration: convert three small, isolated files
(axios-init, websocket, reality-targets) to typed sources so future
phases can lean on their interfaces.
- api/axios-init.ts: typed CSRF cache, interceptors, request retry
- api/websocket.ts: typed listener map, message envelope guard,
reconnect timer
- models/reality-targets.ts: RealityTarget interface, readonly list
- env.d.ts: minimal qs module shim (stringify/parse)
- consumers: drop ".js" extension from @/api imports
* refactor(frontend): port utils/index to TypeScript
Phase 2 of the JS→TS migration: convert the 858-line utility module
that 30+ pages and hooks depend on.
- Msg<T = any> generic with success/msg/obj shape preserved
- HttpUtil get/post/postWithModal generic over response shape
- RandomUtil, Wireguard, Base64 fully typed
- SizeFormatter/CPUFormatter/TimeFormatter/NumberFormatter typed
- ColorUtils.usageColor returns 'green'|'orange'|'red'|'purple' union
- LanguageManager.supportedLanguages readonly typed
- IntlUtil.formatDate/formatRelativeTime accept null/undefined
- ObjectUtil.clone/deepClone/cloneProps/equals kept as `any`-shaped
to preserve the prior JS contract used by class-instance callers
(AllSetting.cloneProps(this, data), etc.)
* refactor(frontend): port models/outbound to TypeScript (hybrid typing)
Phase 4 of the JS→TS migration: rename outbound.js to outbound.ts and
make it compile under strict mode with a minimal hybrid type pass.
- Enum-like constants kept as typed objects (Protocols, SSMethods, …)
- Top-level DNS helpers strictly typed
- CommonClass gets [key: string]: any so all subclasses can keep their
loose this.foo = bar assignments without per-field declarations
- Constructor / fromJson / toJson signatures typed as any to preserve
the prior JS contract used by consumers and parsers
- Outbound declares static fields for the dynamically-attached Settings
subclasses (Settings, FreedomSettings, VmessSettings, …)
- urlParams.get() results that feed parseInt now use the non-null
assertion since the surrounding has() check already guards them
- File-level eslint-disable for no-explicit-any/no-var/prefer-const to
keep the JS-derived code building without churn
* refactor(frontend): port models/inbound to TypeScript (hybrid typing)
Phase 5 of the JS→TS migration. Same hybrid approach as outbound.ts:
constants typed strictly, classes get [key: string]: any from
XrayCommonClass, constructor / fromJson / toJson signatures use any.
- XrayCommonClass gains [key: string]: any plus typed static helpers
(toJsonArray, fallbackToJson, toHeaders, toV2Headers)
- TcpStreamSettings/TlsStreamSettings/RealityStreamSettings/Inbound
declare static fields for their dynamically-attached subclasses
(TcpRequest, TcpResponse, Cert, Settings, ClientBase, Vmess/VLESS/
Trojan/Shadowsocks/Hysteria/Tunnel/Mixed/Http/Wireguard/TunSettings)
- All gen*Link, applyXhttpExtra*, applyExternalProxyTLS*, applyFinalMask*
and related helpers explicitly any-typed
- Constructor positional client-args (email, limitIp, totalGB, …) typed
as optional any across Vmess/VLESS/Trojan/Shadowsocks/Hysteria.VMESS|
VLESS|Trojan|Shadowsocks|Hysteria
- File-level eslint-disable for no-explicit-any/prefer-const/
no-case-declarations/no-array-constructor to silence churn without
changing behavior
* refactor(frontend): port models/dbinbound to TypeScript
Phase 6 — final phase of the JS→TS migration. Frontend src/ no
longer contains any *.js files.
- DBInbound declares all fields explicitly (id, userId, up, down,
total, …, nodeId, fallbackParent) with proper types
- _expiryTime getter/setter typed against dayjs.Dayjs
- coerceInboundJsonField takes unknown, returns any
- Private cache fields (_cachedInbound, _clientStatsMap) declared
- Consumers (InboundFormModal, InboundsPage, useInbounds): drop ".js"
extension from @/models/dbinbound imports
* refactor(frontend): drop .js extensions from TS-resolved imports
Cleanup after the JS→TS migration:
- All consumers that imported @/models/{inbound,outbound,dbinbound}.js
now drop the .js extension (TS module resolution lands on the .ts
file automatically)
- eslint.config.js: remove the **/*.js block since the only remaining
JS file under src/ is endpoints.js (build-script consumed only) and
js.configs.recommended already covers it correctly
* refactor(frontend): tighten inbound.ts cleanup wins
Checkpoint before the full any → typed pass:
- Wrap 15 case bodies in braces (no-case-declarations)
- Convert 14 let → const in genLink helpers (prefer-const)
- new Array() → [] for shadowsocks passwords (no-array-constructor)
- XrayCommonClass: HeaderEntry, FallbackEntry, JsonObject interfaces;
fromJson/toV2Headers/toHeaders typed against them; static methods
return JsonObject / HeaderEntry[] instead of any
- Reduce file-level eslint-disable scope from 4 rules to just
no-explicit-any (the only one still needed)
* refactor(frontend): drop eslint-disable from models/dbinbound
Replace `any` with explicit domain types:
- `coerceInboundJsonField` returns `Record<string, unknown>` (settings/streamSettings/sniffing are always objects).
- Add `RawJsonField`, `ClientStats`, `FallbackParentRef`, `DBInboundInit` types.
- `_cachedInbound: Inbound | null`, `toInbound(): Inbound`.
- `getClientStats(email): ClientStats | undefined`.
- `genInboundLinks(): string` (matches actual return from Inbound.genInboundLinks).
- Constructor now accepts `DBInboundInit`.
* refactor(frontend): drop eslint-disable from InboundsPage
Type all callbacks against DBInbound from @/models/dbinbound:
- state setters use DBInbound | null
- helpers (projectChildThroughMaster, checkFallback, findClientIndex,
exportInboundLinks, etc.) take DBInbound
- drop `(dbInbounds as any[])` casts; useInbounds already returns DBInbound[]
- introduce ClientMatchTarget for findClientIndex's `client` param
- tighten DBInbound.clientStats to ClientStats[] (default [])
- single boundary cast at <InboundList onRowAction=> to bridge
InboundList's narrower DBInboundRecord (cleanup belongs with InboundList)
* refactor(frontend): drop file-level eslint-disable from utils/index
- ObjectUtil.clone/deepClone become generic <T>
- cloneProps/delProps accept `object` (cast internally to AnyRecord)
- equals accepts `unknown` with proper narrowing
- ColorUtils.usageColor narrows data/threshold to `number`; total widened
to `number | { valueOf(): number } | null | undefined` so Dayjs works
- Utils.debounce replaces `const self = this` with lexical arrow
closure (no-this-alias clean)
- InboundList._expiryTime narrowed from `unknown` to `{ valueOf(): number } | null`
- Single-line eslint-disable remains on `Msg<T = any>` and HttpUtil
generic defaults (idiomatic API envelope; changing default to unknown
cascades through 34 consumer files)
* refactor(frontend): drop eslint-disable from OutboundFormModal field section
Replace `type OB = any` with `type OB = Outbound`. Body code still
sees protocol fields as `any` via Outbound's inherited [key: string]: any
index signature (CommonClass) — that escape hatch will narrow as
Phase 6 tightens outbound.ts itself.
The intentional `// eslint-disable-next-line` on `useRef<any>(null)`
at line 72 stays — out of scope per plan.
* refactor(frontend): drop file-level eslint-disable from InboundFormModal
Add minimal local interfaces for protocol-specific shapes the form reads:
- StreamLike, TlsCert, VlessClient, ShadowsocksClient, HttpAccount,
WireguardPeer (replace with real exports from inbound.ts as Phase 7
exports them).
- Props typed as DBInbound | null + DBInbound[].
- Drop unnecessary `(Inbound as any).X`, `(RandomUtil as any).X`,
`(Wireguard as any).X`, `(DBInbound as any)(...)` casts — they are
already typed classes; only `Inbound.Settings`/`Inbound.HttpSettings`
remain `any` via static field on Inbound (will tighten in Phase 7).
- inboundRef/dbFormRef retain single-line `// eslint-disable-next-line`
for `useRef<any>(null)` — nullable narrowing across ~30 callsites
exceeds Phase 5 scope.
- payload locals typed Record<string, unknown>; setAdvancedAllValue
parses JSON into a narrowed object instead of `let parsed: any`.
* refactor(frontend): narrow outbound.ts eslint-disable to no-explicit-any only
- Fix all 36 prefer-const violations: convert never-reassigned `let` to
`const`; for mixed-mutability destructuring (fromParamLink,
fromHysteriaLink) split into separate `const`/`let` declarations
by index instead of destructuring.
- Fix both no-var violations: `var stream` / `var settings` → `let`.
- File still carries `/* eslint-disable @typescript-eslint/no-explicit-any */`
because tightening 223 `any` uses requires removing CommonClass's
`[key: string]: any` escape hatch and reshaping ~30 dynamically-attached
subclass patterns into named classes — multi-hour architectural work
tracked as Phase 7's twin for outbound.
* refactor(frontend): align sub page chrome with login + AntD defaults
- Theme + language buttons now both use AntD `<Button shape="circle"
size="large" className="toolbar-btn">` with TranslationOutlined and
the SVG theme icon — identical hover/border behaviour.
- Language popover content switched from hand-rolled `<ul.lang-list>`
to AntD `<Menu mode="vertical" selectable />`; gains native
hover/keyboard nav + active highlight.
- Drop `.info-table` `!important` border overrides (8 selectors) so
Descriptions inherits the AntD theme border colour.
- Drop `.qr-code` padding/background/border-radius overrides; only
`cursor: pointer` remains (QRCode handles padding/bg itself).
- Remove now-unused `.theme-cycle`, `.lang-list`, `.lang-item*`,
`.lang-select`, `.settings-popover` rules.
* refactor(frontend): drop CustomStatistic wrapper, move overrides to theme tokens
- Delete `<CustomStatistic>` (a pass-through wrapper over <Statistic>)
and its unscoped global `.ant-statistic-*` CSS overrides; consumers
(IndexPage, ClientsPage, InboundsPage, NodesPage) now import AntD
`<Statistic>` directly.
- Add Statistic component tokens to ConfigProvider so the title (11px)
and content (17px) font sizes still apply, without `!important`
global selectors.
- Move dark / ultra-dark card border colours from `body.dark .ant-card`
+ `html[data-theme='ultra-dark'] .ant-card` selectors into Card
`colorBorderSecondary` tokens; page-cards.css now only carries the
custom radius/shadow/transition that has no token equivalent.
- Simplify XrayStatusCard badge: remove the custom `xray-pulse` dot
keyframe and per-state ring-colour overrides; AntD `<Badge
status="processing" color={…}>` already pulses the ring in the same
colour, no extra CSS needed.
* refactor(frontend): modernize login page with AntD primitives
- Theme cycle button switched from `<button.theme-cycle>` + custom CSS
to AntD `<Button shape="circle" className="toolbar-btn">` (matches
sub page chrome already established).
- Theme icons switched from hand-rolled inline SVG (sun, moon,
moon+star) to AntD `<SunOutlined />`, `<MoonOutlined />`,
`<MoonFilled />` for the three light / dark / ultra-dark states.
- Language popover content switched from `<ul.lang-list>` +
`<button.lang-item>` to AntD `<Menu mode="vertical" selectable />`
with `selectedKeys=[lang]`; native hover / keyboard nav / active
highlight come for free.
- Drop CSS for `.theme-cycle`, `.lang-list`, `.lang-item*` (now unused).
`.toolbar-btn` retained since it sizes both circular buttons.
* refactor(frontend): switch sub page theme icons to AntD primitives
Replace the three hand-rolled SVG theme icons (sun, moon, moon+star)
with AntD `<SunOutlined />`, `<MoonOutlined />`, `<MoonFilled />`
for the light / dark / ultra-dark states. Switch the theme `<Button>`
to use the `icon` prop instead of children so it renders the same
way as the language button. Drop `.toolbar-btn svg` CSS — no longer
needed once the icon comes from AntD.
* refactor(frontend): drop !important overrides from pages CSS (Clients + Log modals + Settings tabs)
- ClientsPage: pagination size-changer `min-width !important` removed;
the 3-level selector specificity already beats AntD's defaults.
Scope `body.dark .client-card` to `.clients-page.is-dark .client-card`
(avoid leaking into other pages).
- LogModal + XrayLogModal: move the mobile full-screen tweaks
(`top: 0`, `padding-bottom: 0`, `max-width: 100vw`) from `!important`
class rules to the Modal's `style` prop; keep `.ant-modal-content`
/ `.ant-modal-body` overrides as plain CSS via the className.
- SubscriptionFormatsTab: drop `display: block !important` on
`.nested-block` — div is already block by default.
- TwoFactorModal: drop `padding/background/border-radius !important`
on `.qr-code`; AntD QRCode handles those itself.
* refactor(frontend): scope dark overrides and switch list borders to AntD CSS variables
Scope page-level dark overrides:
- inbounds/InboundList: scope `.ant-table` border-radius rules and the
mobile @media `.ant-card-*` tweaks to `.inbounds-page` (were global
and leaked into other pages); scope `.inbound-card` dark variant to
`.inbounds-page.is-dark`.
- nodes/NodeList: scope `.node-card` dark to `.nodes-page.is-dark`.
- xray/RoutingTab, OutboundsTab: scope `.rule-card`, `.criterion-chip`,
`.criterion-more`, `.address-pill` dark to `.xray-page.is-dark`.
Modernize list borders to use AntD CSS vars instead of body.dark forks:
- index/BackupModal, PanelUpdateModal, VersionModal: replace
hard-coded `rgba(5,5,5,0.06)` + `body.dark`/`html[data-theme]`
override pairs with `var(--ant-color-border-secondary)`; replace
custom text colours with `var(--ant-color-text)` /
`var(--ant-color-text-tertiary)`.
- xray/DnsPresetsModal: same border-color treatment.
- xray/NordModal, WarpModal: collapse `.row-odd` light + `body.dark`
pair into a single neutral `rgba(128,128,128,0.06)` that works on
both themes; scope under `.nord-data-table` / `.warp-data-table`.
* refactor(frontend): switch shared components CSS to AntD CSS variables
Replace body.dark / html[data-theme] forks with AntD CSS variables
in shared components (work in both light and dark, scale to ultra):
- SettingListItem: borders + text colours via
`--ant-color-border-secondary`, `--ant-color-text`,
`--ant-color-text-tertiary`.
- InputAddon: bg/border/text via `--ant-color-fill-tertiary`,
`--ant-color-border`, `--ant-color-text`.
- JsonEditor: host border/bg via `--ant-color-border`,
`--ant-color-bg-container`; focus border via `--ant-color-primary`.
- Sparkline (SVG): grid/text colours via `--ant-color-text*`
and `--ant-color-border-secondary`; only the tooltip drop-shadow
retains a body.dark fork (filter opacity needs explicit value).
* refactor(frontend): swap custom Sparkline SVG for Recharts AreaChart
Replace the 368-line hand-rolled SVG sparkline (with manual
ResizeObserver, gradient/shadow/glow filters, grid + ticks + tooltip,
custom Y-axis label thinning) with a thin Recharts `<AreaChart>`
wrapper that keeps the same prop API.
- Preserved props: data, labels, height, stroke, strokeWidth,
maxPoints, showGrid, fillOpacity, showMarker, markerRadius,
showAxes, yTickStep, tickCountX, showTooltip, valueMin, valueMax,
yFormatter, tooltipFormatter.
- Dropped: `vbWidth`, `gridColor`, `paddingLeft/Right/Top/Bottom` —
Recharts' ResponsiveContainer handles width, and margins are wired
to whether axes are visible. Removed the unused `vbWidth` prop from
SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel callsites.
- Tooltip, grid, and axis text now use AntD CSS variables for
automatic light/dark adaptation; replaced the SVG body.dark forks
in Sparkline.css with a single 5-line stylesheet.
- Bundle: vendor +~100KB gzip (Recharts + its d3 deps), trade-off
for less custom chart code to maintain and a more standard API
for future charts (multi-series, brush, etc.).
* build(frontend): split Recharts + d3 deps into vendor-recharts chunk
Pulls Recharts (~75KB gzip) and its d3-shape/array/color/path/scale
+ victory-vendor deps out of the catch-all vendor chunk so they
load on demand on the three pages that use Sparkline
(SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) and cache
independently from the rest of the panel JS.
* refactor(frontend): drop body.dark forks in favor of AntD CSS variables
- ClientInfoModal/InboundInfoModal: link-panel-text and link-panel-anchor now use
var(--ant-color-fill-tertiary) and color-mix on --ant-color-primary, removing
the body.dark light/dark background pair.
- InboundFormModal: advanced-panel uses --ant-color-border-secondary and
--ant-color-fill-quaternary; body.dark/html[data-theme='ultra-dark'] pair gone.
- CustomGeoSection: custom-geo-count, custom-geo-ext-code, custom-geo-copyable:hover
use --ant-color-fill-tertiary/-secondary; body.dark forks gone.
- SystemHistoryModal: cpu-chart-wrap collapsed from three theme-specific gradients
into one using color-mix on --ant-color-primary and --ant-color-fill-quaternary.
- page-cards.css: body.dark / html[data-theme='ultra-dark'] selectors renamed to
page-scoped .is-dark / .is-dark.is-ultra, keeping the same shadow tuning but
consistent with the page-scoping convention used elsewhere.
* refactor(sidebar): modernize AppSidebar with AntD CSS variables and icons
- Replace hardcoded rgba(0,0,0,X) colors with var(--ant-color-text)
and var(--ant-color-text-secondary) so light/dark adapt automatically.
- Replace rgba(128,128,128,0.15) borders with var(--ant-color-border-secondary)
and rgba(128,128,128,0.18) backgrounds with var(--ant-color-fill-tertiary).
- Drop all body.dark/html[data-theme='ultra-dark'] color forks for
.drawer-brand, .sider-brand, .drawer-close, .sidebar-theme-cycle,
.sidebar-donate (CSS variables already adapt).
- Drop the body.dark Drawer background !important pair; AntD's
colorBgElevated token from the dark algorithm handles it now.
- Replace inline sun/moon SVGs in ThemeCycleButton with AntD's
SunOutlined/MoonOutlined/MoonFilled to match LoginPage/SubPage.
- Convert .sidebar-theme-cycle hover and the menu item selected/hover
highlights from hardcoded #4096ff to color-mix on --ant-color-primary,
keeping !important on menu rules to beat AntD's CSS-in-JS specificity.
* refactor(frontend): swap hardcoded AntD palette colors for CSS variables
The dot/badge/pill styles still hardcoded AntD's default palette values
(#52c41a, #1677ff, #ff4d4f, #fa8c16, #ff4d4f). Replace each with its
semantic --ant-color-* equivalent so they auto-adapt to any theme
customization through ConfigProvider.
- ClientsPage: .dot-green/.dot-blue/.dot-red/.dot-orange/.dot-gray now
use --ant-color-success / -primary / -error / -warning / -text-quaternary.
.bulk-count / .client-card / .client-card.is-selected backgrounds use
color-mix on --ant-color-primary and --ant-color-fill-quaternary, which
also let the body-dark .client-card fork go away.
- XrayMetricsModal: .obs-dot is-alive/is-dead and its pulse keyframe now
build their box-shadow tint via color-mix on --ant-color-success and
--ant-color-error instead of rgba literals.
- IndexPage: .action-update warning color uses --ant-color-warning.
- OutboundsTab: .outbound-card border, .address-pill background, and
.mode-badge tint now use AntD CSS variables; the .xray-page.is-dark
.address-pill fork is gone.
- InboundFormModal/InboundsPage/ClientBulkAddModal: drop the stale
`, #1677ff`/`, #1890ff` fallbacks on var(--ant-color-primary), and
switch .danger-icon to --ant-color-error.
The teal/cyan brand colors (#008771, #3c89e8, #e04141) used by traffic
and pill rows are intentionally kept hardcoded — they are brand-specific
shades, not AntD palette colors.
* refactor(frontend): swap neutral gray rgba literals for AntD CSS variables
Across 12 files the same neutral grays kept reappearing — rgba(128,128,128,
0.06|0.08|0.12|0.15|0.18|0.2|0.25) for borders, dividers, and subtle
backgrounds. Each maps cleanly to an AntD CSS variable that already
adapts to light/dark and to any theme customization through ConfigProvider:
- 0.12–0.18 borders → var(--ant-color-border-secondary)
- 0.2–0.25 borders → var(--ant-color-border)
- 0.06–0.08 backgrounds → var(--ant-color-fill-tertiary)
- 0.02–0.03 card surfaces → var(--ant-color-fill-quaternary)
Card surfaces (InboundList .inbound-card, NodeList .node-card) had a
light/dark fork pair — the variable covers both, so the .is-dark .card
override is gone.
RoutingTab .rule-card.drop-before/after used hardcoded #1677ff for the
inset focus shadow; replaced with var(--ant-color-primary) so reordering
indicators follow the theme primary.
ClientsPage bucketBadgeColor returned hex literals (#ff4d4f, #fa8c16,
#52c41a, rgba gray) for a Badge color prop. Switched to status="error"|
"warning"|"success"|"default" so the dot color now comes from AntD's
semantic palette directly.
* refactor(xray): collapse RoutingTab dark forks into AntD CSS variables
- .criterion-more bg light/dark fork → var(--ant-color-fill-tertiary)
- .xray-page.is-dark .rule-card and .criterion-chip overrides removed;
the rules already use --bg-card and --ant-color-fill-tertiary that
adapt to the theme on their own.
* refactor(frontend): inline style hex literals and Alert icon redundancy
- FinalMaskForm: five DeleteOutlined icons used rgb(255,77,79) inline;
swap for var(--ant-color-error) so they follow theme customization.
- NodesPage: CheckCircleOutlined / CloseCircleOutlined statistic prefixes
switch to var(--ant-color-success) / -error.
- NodeList: ExclamationCircleOutlined warning icons (two callsites) now
use var(--ant-color-warning).
- BasicsTab: four <Alert type="warning"> blocks shipped a custom
ExclamationCircleFilled icon styled to match the warning palette —
exactly the icon and color AntD Alert renders for type="warning" by
default. Replace the icon prop with showIcon and drop the now-unused
ExclamationCircleFilled import.
- JsonEditor: focus-within box-shadow tint now uses color-mix on
--ant-color-primary instead of an rgba(22,119,255,0.1) literal.
* refactor(logs): collapse log-container dark forks to AntD CSS variables
LogModal and XrayLogModal each had a body.dark fork that overrode the
log container's background, border-color, and text color in addition
to the --log-* severity tokens. Background/border/color all map cleanly
to var(--ant-color-fill-tertiary) / var(--ant-color-border) /
var(--ant-color-text) which already adapt to the theme, so only the
severity color tokens remain inside the dark/ultra-dark blocks.
* refactor(xray): drop stale --ant-primary-color fallbacks and hex literals
- RoutingTab .drop-before/.drop-after box-shadow: #1677ff → var(--ant-color-primary)
- OutboundFormModal .random-icon: drop the --ant-primary-color/#1890ff
pair (the old AntD v4 token name with stale fallback) for the v6
--ant-color-primary; .danger-icon hex #ff4d4f → var(--ant-color-error).
- XrayPage .restart-icon: same drop of the --ant-primary-color fallback.
These were all leftovers from the AntD v4 → v6 rename — the v6
--ant-color-primary is already populated by ConfigProvider, so the
fallback hex was dead code that would only trigger if AntD wasn't
mounted.
* refactor(frontend): consolidate margin utility classes into one stylesheet
Page CSS files each carried their own copies of the same atomic margin
utilities (.mt-4, .mt-8, .mb-12, .ml-8, .my-10, ...). The definitions
were identical everywhere they appeared, with each file holding only
the subset it happened to need.
Move all of them into a single styles/utils.css imported once from
main.tsx, and delete the per-page copies from InboundFormModal,
CustomGeoSection, PanelUpdateModal, VersionModal, BasicsTab, NordModal,
OutboundFormModal, and WarpModal. The classes are available globally
on the panel app; login.tsx and subpage.tsx entries do not consume any
of them so they stay untouched.
* refactor(frontend): consolidate shared page-shell rules into one stylesheet
Every panel page CSS file repeated the same wrapper boilerplate — the
--bg-page/--bg-card token triples for light/dark/ultra-dark, the
min-height + background root rule, the .ant-layout transparent reset,
the .content-shell transparent reset, and the .loading-spacer min-height.
That's ~30 identical lines duplicated across IndexPage, ClientsPage,
InboundsPage, XrayPage, SettingsPage, NodesPage, and ApiDocsPage.
Move all of it into styles/page-shell.css and import it once from
main.tsx alongside utils.css and page-cards.css. Each page CSS file
now only contains genuinely page-specific rules (content-area padding
overrides, page-specific tokens like ApiDocs's Swagger --sw-* set).
Also drop the per-page `import '@/styles/page-cards.css'` statements
from the 7 page tsx files now that main.tsx loads it globally.
Net: -211 deleted, +6 inserted in the touched files, plus the new
page-shell.css. .zero-margin (Divider override used by Nord/Warp
modals) folded into utils.css alongside the margin classes.
* refactor(frontend): move default content-area padding to page-shell.css
After page-shell.css landed, six of the seven panel pages still kept an
identical `.X-page .content-area { padding: 24px }` desktop rule, plus
three of them kept an identical `padding: 8px` mobile rule. Hoist both
defaults into page-shell.css under a single 6-page selector group and
delete the per-page copies.
What stays page-specific:
- IndexPage keeps its mobile override (padding 12px + padding-top: 64px
for the fixed drawer handle clearance).
- ApiDocsPage keeps its tighter desktop padding (16px) and its own
mobile padding-top: 56px.
Settings .ldap-no-inbounds also switches from #999 to
var(--ant-color-text-tertiary) for theme adaptation.
* refactor(frontend): hoist .header-row, .icons-only, .summary-card to page-shell.css
Settings and Xray pages both carried identical .header-row /
.header-actions / .header-info rules and an identical six-rule
.icons-only block that styles tabbed page navigation. Clients, Inbounds,
and Nodes all carried identical .summary-card padding rules with the
same mobile reduction. None of these are page-specific.
Consolidate:
- .header-row family → page-shell scoped to .settings-page, .xray-page
- .icons-only family → page-shell global (the class is a deliberate
opt-in marker, no scope needed)
- .summary-card → page-shell scoped to .clients-page, .inbounds-page,
.nodes-page (also fixes InboundsPage's missing scope — its rule was
global and would have matched stray .summary-card uses elsewhere)
InboundsPage.css and NodesPage.css became empty after the move so the
files and their per-page imports are deleted.
* refactor(frontend): hoist .random-icon to utils.css
Three form modals each carried identical .random-icon styles (small
primary-tinted icon next to randomizable inputs):
ClientBulkAddModal, InboundFormModal, OutboundFormModal
Single definition lives in utils.css now. ClientBulkAddModal.css was
just this one rule, so the file and its import are deleted along the way.
.danger-icon is left per file — the margin-left differs slightly
between InboundFormModal (6px) and OutboundFormModal (8px), so it
stays as a page-local rule rather than getting averaged into utils.css.
* refactor(frontend): hoist .danger-icon to utils.css and use it everywhere
InboundFormModal (margin-left 6px) and OutboundFormModal (margin-left
8px) each carried their own .danger-icon, and FinalMaskForm wrote the
same color/cursor/marginLeft trio inline five times. Unify on a single
.danger-icon in utils.css with margin-left: 8px — matching the more
generous OutboundFormModal value — and:
- Drop the per-file .danger-icon copies from InboundFormModal.css and
OutboundFormModal.css.
- Replace the five inline style props in FinalMaskForm.tsx with
className="danger-icon".
The visible change is a 2px wider gap to the right of the delete icons
on InboundFormModal's protocol/peer dividers.
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
@@ -19,7 +19,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
color: var(--ant-color-text-secondary);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
@@ -102,7 +102,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
color: var(--ant-color-text-secondary);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
@@ -110,15 +110,14 @@
|
||||
|
||||
.sidebar-theme-cycle:hover,
|
||||
.sidebar-theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.sidebar-theme-cycle .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
@@ -151,7 +150,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
@@ -165,12 +164,12 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.drawer-menu .ant-menu-item {
|
||||
@@ -186,7 +185,7 @@
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
|
||||
@@ -204,7 +203,7 @@
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -225,55 +224,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .sidebar-donate {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent) !important;
|
||||
color: var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
@@ -282,6 +237,6 @@ html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent) !important;
|
||||
color: var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
@@ -69,6 +72,7 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
onCycle: () => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const icon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
@@ -78,21 +82,7 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
title={ariaLabel}
|
||||
onClick={onCycle}
|
||||
>
|
||||
{!isDark ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : !isUltra ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
)}
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
.ant-statistic-content {
|
||||
font-size: 17px !important;
|
||||
line-height: 1.4 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-statistic-content-value,
|
||||
.ant-statistic-content-prefix,
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix {
|
||||
margin-inline-end: 8px !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ant-statistic-content-prefix .anticon {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.ant-statistic-content-suffix {
|
||||
font-size: 12px !important;
|
||||
opacity: 0.55;
|
||||
margin-inline-start: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-statistic-title {
|
||||
font-size: 11px !important;
|
||||
margin-bottom: 6px !important;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-content {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-statistic-title {
|
||||
color: rgba(255, 255, 255, 0.70);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Statistic } from 'antd';
|
||||
import './CustomStatistic.css';
|
||||
|
||||
interface CustomStatisticProps {
|
||||
title?: string;
|
||||
value?: string | number;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
}
|
||||
|
||||
export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd'
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/outbound.js';
|
||||
import { Protocols } from '@/models/outbound';
|
||||
|
||||
interface StreamShape {
|
||||
network?: string;
|
||||
@@ -138,7 +138,7 @@ export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskF
|
||||
<Divider style={{ margin: 0 }}>
|
||||
TCP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
className="danger-icon"
|
||||
onClick={() => {
|
||||
stream.delTcpMask(mIdx);
|
||||
notify();
|
||||
@@ -238,7 +238,7 @@ export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskF
|
||||
<Divider style={{ margin: 0 }}>
|
||||
UDP Mask {mIdx + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
className="danger-icon"
|
||||
onClick={() => {
|
||||
stream.delUdpMask(mIdx);
|
||||
notify();
|
||||
@@ -403,7 +403,7 @@ function HeaderCustomGroups({
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
className="danger-icon"
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[][]).splice(gi, 1);
|
||||
onChange();
|
||||
@@ -445,7 +445,7 @@ function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => vo
|
||||
<Divider style={{ margin: 0 }}>
|
||||
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
className="danger-icon"
|
||||
onClick={() => {
|
||||
(settings[groupKey] as ItemRow[]).splice(ci, 1);
|
||||
onChange();
|
||||
@@ -493,7 +493,7 @@ function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void })
|
||||
<Divider style={{ margin: 0 }}>
|
||||
Noise {ni + 1}
|
||||
<DeleteOutlined
|
||||
style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
|
||||
className="danger-icon"
|
||||
onClick={() => {
|
||||
(settings.noise as ItemRow[]).splice(ni, 1);
|
||||
onChange();
|
||||
|
||||
@@ -5,22 +5,15 @@
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
line-height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: var(--ant-color-fill-tertiary);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
body.dark .input-addon,
|
||||
html[data-theme='ultra-dark'] .input-addon {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
border-color: #424242;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ant-space-compact > .input-addon:not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.json-editor-host .cm-editor,
|
||||
@@ -11,16 +11,6 @@
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
html[data-theme="ultra-dark"] .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
border-color: var(--ant-color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.setting-list-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
body.dark .setting-list-item,
|
||||
html[data-theme='ultra-dark'] .setting-list-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.setting-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -22,22 +17,12 @@ html[data-theme='ultra-dark'] .setting-list-item {
|
||||
|
||||
.setting-list-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-list-description {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
color: var(--ant-color-text-tertiary);
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
body.dark .setting-list-title,
|
||||
html[data-theme='ultra-dark'] .setting-list-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .setting-list-description,
|
||||
html[data-theme='ultra-dark'] .setting-list-description {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
@@ -2,43 +2,3 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.55);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-tooltip-pill {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import './Sparkline.css';
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
labels?: (string | number)[];
|
||||
vbWidth?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
maxPoints?: number;
|
||||
showGrid?: boolean;
|
||||
gridColor?: string;
|
||||
fillOpacity?: number;
|
||||
showMarker?: boolean;
|
||||
markerRadius?: number;
|
||||
showAxes?: boolean;
|
||||
yTickStep?: number;
|
||||
tickCountX?: number;
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
showTooltip?: boolean;
|
||||
valueMin?: number;
|
||||
valueMax?: number | null;
|
||||
@@ -29,340 +31,136 @@ interface SparklineProps {
|
||||
tooltipFormatter?: ((v: number) => string) | null;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
index: number;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function Sparkline({
|
||||
data,
|
||||
labels = [],
|
||||
vbWidth = 320,
|
||||
height = 80,
|
||||
stroke = '#008771',
|
||||
strokeWidth = 2,
|
||||
maxPoints = 120,
|
||||
showGrid = true,
|
||||
gridColor = 'rgba(0,0,0,0.08)',
|
||||
fillOpacity = 0.22,
|
||||
showMarker = true,
|
||||
markerRadius = 3,
|
||||
showAxes = false,
|
||||
yTickStep = 25,
|
||||
tickCountX = 4,
|
||||
paddingLeft = 56,
|
||||
paddingRight = 6,
|
||||
paddingTop = 6,
|
||||
paddingBottom = 20,
|
||||
showTooltip = false,
|
||||
valueMin = 0,
|
||||
valueMax = 100,
|
||||
yFormatter = (v: number) => `${Math.round(v)}%`,
|
||||
tooltipFormatter = null,
|
||||
}: SparklineProps) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0);
|
||||
const [hoverIdx, setHoverIdx] = useState(-1);
|
||||
|
||||
const reactId = useId();
|
||||
const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
|
||||
const gradId = `spkGrad-${safeId}`;
|
||||
const shadowId = `spkShadow-${safeId}`;
|
||||
const glowId = `spkGlow-${safeId}`;
|
||||
|
||||
useEffect(() => {
|
||||
const el = svgRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) setMeasuredWidth(Math.round(w));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
const points = useMemo<ChartPoint[]>(() => {
|
||||
const n = Math.min(data.length, maxPoints);
|
||||
if (n === 0) return [];
|
||||
const sliceStart = data.length - n;
|
||||
const labelStart = Math.max(0, labels.length - n);
|
||||
return data.slice(sliceStart).map((value, i) => ({
|
||||
index: i,
|
||||
value: Number(value) || 0,
|
||||
label: String(labels[labelStart + i] ?? i + 1),
|
||||
}));
|
||||
}, [data, labels, maxPoints]);
|
||||
|
||||
const yDomain = useMemo<[number, number]>(() => {
|
||||
if (valueMax != null) return [valueMin, valueMax];
|
||||
let max = valueMin;
|
||||
for (const p of points) {
|
||||
if (Number.isFinite(p.value) && p.value > max) max = p.value;
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, []);
|
||||
|
||||
const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
|
||||
const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
|
||||
const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
|
||||
const nPoints = Math.min(data.length, maxPoints);
|
||||
|
||||
const dataSlice = useMemo(
|
||||
() => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
|
||||
[data, nPoints],
|
||||
);
|
||||
|
||||
const labelsSlice = useMemo(() => {
|
||||
if (!labels?.length || nPoints === 0) return [] as (string | number)[];
|
||||
const start = Math.max(0, labels.length - nPoints);
|
||||
return labels.slice(start);
|
||||
}, [labels, nPoints]);
|
||||
|
||||
const yDomain = useMemo(() => {
|
||||
const min = valueMin;
|
||||
if (valueMax != null) return { min, max: valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
}, [dataSlice, valueMin, valueMax]);
|
||||
|
||||
const project = useCallback(
|
||||
(v: number) => {
|
||||
const { min, max } = yDomain;
|
||||
const span = max - min;
|
||||
if (span <= 0) return paddingTop + drawHeight;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
|
||||
},
|
||||
[yDomain, paddingTop, drawHeight],
|
||||
);
|
||||
|
||||
const pointsArr = useMemo<[number, number][]>(() => {
|
||||
if (nPoints === 0) return [];
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
return dataSlice.map((v, i) => {
|
||||
const x = Math.round(paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
}, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
|
||||
|
||||
const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
|
||||
|
||||
const areaPath = useMemo(() => {
|
||||
if (pointsArr.length === 0) return '';
|
||||
const first = pointsArr[0];
|
||||
const last = pointsArr[pointsArr.length - 1];
|
||||
const baseY = paddingTop + drawHeight;
|
||||
const line = pointsStr.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
}, [pointsArr, pointsStr, paddingTop, drawHeight]);
|
||||
|
||||
const gridLines = useMemo(() => {
|
||||
if (!showGrid) return [];
|
||||
const h = drawHeight;
|
||||
const w = drawWidth;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(paddingTop + h * r);
|
||||
return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
|
||||
});
|
||||
}, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
|
||||
|
||||
const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
|
||||
if (max <= valueMin) max = valueMin + 1;
|
||||
return [valueMin, max * 1.1];
|
||||
}, [points, valueMin, valueMax]);
|
||||
|
||||
const yTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
const { min, max } = yDomain;
|
||||
const out: { y: number; label: string }[] = [];
|
||||
if (!showAxes) return undefined;
|
||||
const [min, max] = yDomain;
|
||||
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += yTickStep) {
|
||||
out.push({ y: project(p), label: yFormatter(p) });
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let v = min; v <= max; v += yTickStep) out.push(v);
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
|
||||
const n = 5;
|
||||
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
|
||||
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
|
||||
|
||||
const xTicks = useMemo(() => {
|
||||
if (!showAxes) return [];
|
||||
if (nPoints === 0) return [];
|
||||
const xTickIndexes = useMemo(() => {
|
||||
if (!showAxes || points.length === 0) return undefined;
|
||||
const m = Math.max(2, tickCountX);
|
||||
const w = drawWidth;
|
||||
const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
|
||||
const out: { x: number; label: string }[] = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (nPoints - 1)) / (m - 1));
|
||||
const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
|
||||
const x = Math.round(paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
}, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
|
||||
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
|
||||
}, [showAxes, tickCountX, points.length]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(evt: MouseEvent<SVGSVGElement>) => {
|
||||
if (!showTooltip || pointsArr.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth;
|
||||
const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
|
||||
setHoverIdx(idx);
|
||||
},
|
||||
[showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
|
||||
|
||||
const hoverText = useMemo(() => {
|
||||
const idx = hoverIdx;
|
||||
if (idx < 0 || idx >= dataSlice.length) return '';
|
||||
const raw = Number(dataSlice[idx] || 0);
|
||||
const fmt = tooltipFormatter || yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
|
||||
|
||||
const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
|
||||
const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
|
||||
const tooltipX = hoverPoint
|
||||
? Math.max(
|
||||
paddingLeft + 2,
|
||||
Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
|
||||
)
|
||||
: 0;
|
||||
const fmtTooltip = tooltipFormatter ?? yFormatter;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${effectiveVbWidth} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="sparkline-svg"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
|
||||
<stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
|
||||
<feOffset dx="0" dy="2" result="offsetBlur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.45" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id={glowId}>
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{showGrid && (
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={g.x1}
|
||||
y1={g.y1}
|
||||
x2={g.x2}
|
||||
y2={g.y2}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 5"
|
||||
className="cpu-grid-line"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{showAxes && (
|
||||
<g>
|
||||
{yTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`y${i}`}
|
||||
className="cpu-grid-y-text"
|
||||
x={Math.max(0, paddingLeft - 6)}
|
||||
y={tk.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
{xTicks.map((tk, i) => (
|
||||
<text
|
||||
key={`x${i}`}
|
||||
className="cpu-grid-x-text"
|
||||
x={tk.x}
|
||||
y={paddingTop + drawHeight + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5}
|
||||
>
|
||||
{tk.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
|
||||
<polyline
|
||||
points={pointsStr}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter={`url(#${shadowId})`}
|
||||
/>
|
||||
{showMarker && lastPoint && (
|
||||
<>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
|
||||
<animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
|
||||
<g>
|
||||
<line
|
||||
className="cpu-grid-h-line"
|
||||
x1={pointsArr[hoverIdx][0]}
|
||||
x2={pointsArr[hoverIdx][0]}
|
||||
y1={paddingTop}
|
||||
y2={paddingTop + drawHeight}
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.45}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 4"
|
||||
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
|
||||
<AreaChart data={points} margin={{ top: 6, right: 6, bottom: showAxes ? 14 : 4, left: showAxes ? 4 : 4 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{showGrid && (
|
||||
<CartesianGrid stroke="var(--ant-color-border-secondary)" strokeDasharray="2 4" vertical={false} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={yFormatter}
|
||||
ticks={yTicks}
|
||||
width={48}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
|
||||
contentStyle={{
|
||||
background: 'var(--ant-color-bg-elevated)',
|
||||
border: '1px solid var(--ant-color-border-secondary)',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 2 }}
|
||||
itemStyle={{ color: 'var(--ant-color-text)', padding: 0 }}
|
||||
formatter={(v) => [fmtTooltip(Number(v) || 0), '']}
|
||||
separator=""
|
||||
/>
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
|
||||
<circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
|
||||
<rect
|
||||
x={tooltipX}
|
||||
y={paddingTop + 2}
|
||||
width={tooltipPillWidth}
|
||||
height={18}
|
||||
rx={9}
|
||||
ry={9}
|
||||
className="cpu-tooltip-pill"
|
||||
fill={stroke}
|
||||
fillOpacity={0.92}
|
||||
/>
|
||||
<text
|
||||
className="cpu-tooltip-text"
|
||||
x={tooltipX + tooltipPillWidth / 2}
|
||||
y={paddingTop + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fill="#fff"
|
||||
>
|
||||
{hoverText}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={`url(#${gradId})`}
|
||||
dot={false}
|
||||
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user