Split signal scoring, add Redis cache, Tailwind design system, and fix UI coherence issues
Backend: extract pure scoring logic from signal_service.py into signal_scoring.py (h), add Redis-backed win-rate/PnL caching with graceful degradation (l), add Postgres backup/restore scripts (n), move DB/encryption secrets to Docker secrets pattern (o), fix RSI flat-price bug and MFI wraparound index bug (q, r). 134 backend tests passing. Frontend: consolidate all API calls onto shared apiFetch with auto token refresh (i), wire AnalyticsPage to the real /analytics/dashboard endpoint instead of fake random data (j), migrate all pages and shared components to a Tailwind CSS design system (k) fixing 3 mismatched color palettes found along the way. UI review also found and fixed missing mobile table scroll wrappers, non-stacking grids, and a missing nav/logout bar on ProfilePage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+331
@@ -18,11 +18,13 @@
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"oxlint": "^1.69.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
@@ -2665,6 +2667,290 @@
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.11.1",
|
||||
"@emnapi/runtime": "^1.11.1",
|
||||
"@emnapi/wasi-threads": "^1.2.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||
"version": "3.0.0-pre1",
|
||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||
@@ -3290,6 +3576,20 @@
|
||||
"integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4341,6 +4641,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -5731,6 +6041,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"oxlint": "^1.69.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
|
||||
@@ -29,21 +29,14 @@ export class ErrorBoundary extends React.Component<Props, State> {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div style={{
|
||||
padding: 40, textAlign: 'center', color: '#e74c3c',
|
||||
background: '#1a1a2e', minHeight: '100vh',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 12 }}>⚠️ Something went wrong</h1>
|
||||
<p style={{ color: '#aaa', fontSize: 14, maxWidth: 500 }}>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center gap-3 bg-bg-primary p-10 text-center text-red">
|
||||
<h1 className="text-2xl font-bold">⚠️ Something went wrong</h1>
|
||||
<p className="max-w-[500px] text-sm text-text-secondary">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { this.setState({ hasError: false, error: null }); window.location.href = '/'; }}
|
||||
style={{
|
||||
marginTop: 20, padding: '10px 24px', borderRadius: 6, border: 'none',
|
||||
background: '#3498db', color: '#fff', cursor: 'pointer', fontSize: 14,
|
||||
}}
|
||||
className="mt-2 cursor-pointer rounded-md border-none bg-accent-blue px-6 py-2.5 text-sm text-white hover:opacity-90"
|
||||
>
|
||||
Reload App
|
||||
</button>
|
||||
|
||||
@@ -16,32 +16,20 @@ export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
count = 1,
|
||||
style = {},
|
||||
}) => {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
// width/height/borderRadius are runtime-computed props (can't be static
|
||||
// Tailwind classes), so they stay inline; the shimmer gradient/animation
|
||||
// itself is a shared, static style defined once in index.css.
|
||||
const dynamicStyle: React.CSSProperties = {
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
background: 'linear-gradient(90deg, #2a2a3e 25%, #3a3a4e 50%, #2a2a3e 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-shimmer 1.5s ease-in-out infinite',
|
||||
marginBottom: count > 1 ? 8 : 0,
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined' && !document.getElementById('skeleton-keyframes')) {
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.id = 'skeleton-keyframes';
|
||||
styleEl.textContent = `
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} style={{ ...baseStyle, ...style }} />
|
||||
<div key={i} className="skeleton-shimmer" style={{ ...dynamicStyle, ...style }} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -52,7 +40,7 @@ interface DashboardSkeletonProps {
|
||||
}
|
||||
|
||||
export const DashboardSkeleton: React.FC<DashboardSkeletonProps> = ({ lines = 5 }) => (
|
||||
<div style={{ padding: 20 }}>
|
||||
<div className="p-5">
|
||||
<Skeleton height={32} width="60%" style={{ marginBottom: 20 }} />
|
||||
<Skeleton height={200} width="100%" borderRadius={8} style={{ marginBottom: 16 }} />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
|
||||
@@ -30,6 +30,16 @@ interface ModalState {
|
||||
targetUser: User | null;
|
||||
}
|
||||
|
||||
const btnClass = 'min-h-9 touch-manipulation cursor-pointer whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
const activeBtnClass = 'min-h-9 touch-manipulation cursor-pointer whitespace-nowrap rounded-md border border-accent-blue bg-accent-blue px-4 py-1.5 text-xs text-white';
|
||||
const cellBtnClass = 'min-h-9 touch-manipulation cursor-pointer rounded border border-border-default bg-transparent px-2.5 py-[3px] text-[11px] text-accent';
|
||||
const dangerBtnClass = 'min-h-9 touch-manipulation cursor-pointer rounded border border-red/20 bg-transparent px-2.5 py-[3px] text-[11px] text-red';
|
||||
const inputClass = 'w-full rounded border border-border-default bg-bg-primary px-2 py-[5px] text-xs text-text-primary outline-none';
|
||||
const labelClass = 'mb-1 text-[11px] font-medium text-text-secondary';
|
||||
const thClass = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary';
|
||||
const tdClass = 'px-3 py-2 align-middle';
|
||||
const statCardClass = 'min-w-[100px] rounded-lg border border-border-default bg-bg-surface px-3.5 py-2 text-center';
|
||||
|
||||
export default function AdminPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -303,48 +313,25 @@ export default function AdminPage() {
|
||||
if (!user?.is_admin) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading admin panel...</div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
|
||||
<div className="text-sm text-text-secondary">Loading admin panel...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
|
||||
touchAction: 'manipulation', minHeight: 36,
|
||||
};
|
||||
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
|
||||
const cellBtn: React.CSSProperties = {
|
||||
padding: '3px 10px', fontSize: 11, border: '1px solid #30363d',
|
||||
borderRadius: 4, background: 'transparent', color: '#58a6ff', cursor: 'pointer',
|
||||
touchAction: 'manipulation', minHeight: 36,
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = { ...cellBtn, color: '#f85149', borderColor: '#f8514933' };
|
||||
const badgeGreen: React.CSSProperties = { color: '#3fb950', fontSize: 12, fontWeight: 600 };
|
||||
const badgeRed: React.CSSProperties = { color: '#f85149', fontSize: 12, fontWeight: 600 };
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none', width: '100%', boxSizing: 'border-box',
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||
{/* ═══ Navbar ═══ */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 18 }}>🛡️</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Admin Panel</span>
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 overflow-x-auto border-b border-border-default bg-bg-surface px-3 py-2">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<span className="text-lg">🛡️</span>
|
||||
<span className="text-base font-bold text-text-heading">Admin Panel</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href="/profile" style={{ fontSize: 12, color: '#8b949e', textDecoration: 'none', whiteSpace: 'nowrap' }}>👤 {user.username}</a>
|
||||
<button style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<a href="/profile" className="whitespace-nowrap text-xs text-text-secondary no-underline">👤 {user.username}</a>
|
||||
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button
|
||||
style={{ ...btnStyle, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }}
|
||||
className={`${btnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
@@ -358,9 +345,9 @@ export default function AdminPage() {
|
||||
</nav>
|
||||
|
||||
{/* ═══ Tabs ═══ */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px', borderBottom: '1px solid #21262d' }}>
|
||||
<div className="flex gap-1 border-b border-border-muted px-4 py-3">
|
||||
{(['users', 'exchanges', 'health'] as const).map((tab) => (
|
||||
<button key={tab} style={activeTab === tab ? activeBtn : btnStyle} onClick={() => setActiveTab(tab)}>
|
||||
<button key={tab} className={activeTab === tab ? activeBtnClass : btnClass} onClick={() => setActiveTab(tab)}>
|
||||
{tab === 'users' ? '👥 Users' : tab === 'exchanges' ? '🔌 Exchanges' : '❤️ Health'}
|
||||
</button>
|
||||
))}
|
||||
@@ -370,31 +357,31 @@ export default function AdminPage() {
|
||||
{/* ═══ USERS TAB ═══ */}
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{activeTab === 'users' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div className="p-4">
|
||||
{/* Stats bar */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>TOTAL</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{totalUsers}</div>
|
||||
<div className="mb-3 flex flex-wrap gap-3">
|
||||
<div className={statCardClass}>
|
||||
<div className="mb-0.5 text-[11px] text-text-dim">TOTAL</div>
|
||||
<div className="text-xl font-bold text-text-heading">{totalUsers}</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ACTIVE</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{activeUsers}</div>
|
||||
<div className={statCardClass}>
|
||||
<div className="mb-0.5 text-[11px] text-text-dim">ACTIVE</div>
|
||||
<div className="text-xl font-bold text-green">{activeUsers}</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 14px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 100, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 2 }}>ADMINS</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#d29922' }}>{adminUsers}</div>
|
||||
<div className={statCardClass}>
|
||||
<div className="mb-0.5 text-[11px] text-text-dim">ADMINS</div>
|
||||
<div className="text-xl font-bold text-yellow">{adminUsers}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Role Permission Legend ═══ */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#d29922', fontWeight: 700, marginBottom: 6 }}>🛡️ Admin</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
<strong style={{ color: '#f0f6fc' }}>Toàn quyền</strong> — mọi tính năng.<br />
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-yellow">🛡️ Admin</div>
|
||||
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||
<strong className="text-text-heading">Toàn quyền</strong> — mọi tính năng.<br />
|
||||
• Quản lý user (thêm/sửa/xóa, đặt role)<br />
|
||||
Quản lý exchange & symbol (thêm, sửa, active/deactive)<br />
|
||||
Quản lý exchange & symbol (thêm, sửa, active/deactive)<br />
|
||||
Xem audit log toàn bộ hệ thống<br />
|
||||
Quản lý system config, health check<br />
|
||||
Xem tất cả trades, signals, backtest của mọi user<br />
|
||||
@@ -402,41 +389,41 @@ export default function AdminPage() {
|
||||
Quản lý strategies, alerts, notification channels
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', fontWeight: 700, marginBottom: 6 }}>👤 Trader</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
• Xem dashboard, signals & multi-timeframe chart<br />
|
||||
Chạy backtest & lưu kết quả<br />
|
||||
Xem & quản lý hypothetical trades cá nhân<br />
|
||||
<strong style={{ color: '#f0f6fc' }}>Đặt lệnh real trade</strong> (place order)<br />
|
||||
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-text-secondary">👤 Trader</div>
|
||||
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||
• Xem dashboard, signals & multi-timeframe chart<br />
|
||||
Chạy backtest & lưu kết quả<br />
|
||||
Xem & quản lý hypothetical trades cá nhân<br />
|
||||
<strong className="text-text-heading">Đặt lệnh real trade</strong> (place order)<br />
|
||||
Quản lý watchlist, API credentials, personal alerts<br />
|
||||
Quản lý strategies cá nhân<br />
|
||||
Xem analytics & performance cá nhân
|
||||
Xem analytics & performance cá nhân
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 200px', padding: '10px 12px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 11, color: '#58a6ff', fontWeight: 700, marginBottom: 6 }}>👁️ Viewer</div>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', lineHeight: 1.7 }}>
|
||||
<strong style={{ color: '#f85149' }}>Chỉ xem (read-only)</strong><br />
|
||||
• Xem dashboard & real-time chart<br />
|
||||
<div className="flex-1 basis-[200px] rounded-lg border border-border-default bg-bg-surface px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-bold text-accent">👁️ Viewer</div>
|
||||
<div className="text-[11px] leading-[1.7] text-text-secondary">
|
||||
<strong className="text-red">Chỉ xem (read-only)</strong><br />
|
||||
• Xem dashboard & real-time chart<br />
|
||||
Xem signals, trades, backtest history<br />
|
||||
Xem analytics, alerts, watchlist<br />
|
||||
<strong style={{ color: '#f85149' }}>Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
|
||||
<strong className="text-red">Không thể:</strong> trade, đặt lệnh, tạo/sửa user,<br />
|
||||
chạy backtest mới, chỉnh sửa credentials
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Add bar */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<input
|
||||
style={{ ...inputStyle, maxWidth: 300 }}
|
||||
className={`${inputClass} max-w-[300px]`}
|
||||
placeholder="🔍 Search by username, email, display name..."
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
className={`${btnClass} flex items-center gap-1 border-[#238636] bg-[#238636] text-white`}
|
||||
onClick={() => openModal('add')}
|
||||
>
|
||||
+ Add User
|
||||
@@ -444,60 +431,60 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
{/* User table */}
|
||||
{usersLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading users...</p>}
|
||||
{usersError && <p style={{ color: '#f85149', fontSize: 13 }}>{usersError}</p>}
|
||||
{usersLoading && <p className="text-[13px] text-text-dim">Loading users...</p>}
|
||||
{usersError && <p className="text-[13px] text-red">{usersError}</p>}
|
||||
{!usersLoading && !usersError && (
|
||||
<>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
|
||||
<div className="p-6 text-center text-[13px] text-text-dim">
|
||||
{userSearch ? 'No users match your search.' : 'No users yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -12px', padding: '0 12px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 600 }}>
|
||||
<div className="-mx-3 overflow-x-auto px-3">
|
||||
<table className="w-full min-w-[600px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Username</th>
|
||||
<th style={thStyle}>Display Name</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Role</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Created</th>
|
||||
<th style={thStyle}>Actions</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Username</th>
|
||||
<th className={thClass}>Display Name</th>
|
||||
<th className={thClass}>Email</th>
|
||||
<th className={thClass}>Role</th>
|
||||
<th className={thClass}>Status</th>
|
||||
<th className={thClass}>Created</th>
|
||||
<th className={thClass}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((u) => (
|
||||
<tr key={String(u.id)} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: '#f0f6fc' }}>{u.username}</td>
|
||||
<td style={tdStyle}>{u.display_name || <span style={{ color: '#484f58' }}>—</span>}</td>
|
||||
<td style={tdStyle}>{u.email}</td>
|
||||
<td style={tdStyle}>
|
||||
<tr key={String(u.id)} className="border-b border-border-muted">
|
||||
<td className={`${tdClass} font-semibold text-text-heading`}>{u.username}</td>
|
||||
<td className={tdClass}>{u.display_name || <span className="text-text-dim">—</span>}</td>
|
||||
<td className={tdClass}>{u.email}</td>
|
||||
<td className={tdClass}>
|
||||
{(() => {
|
||||
const role = u.role || (u.is_admin ? 'admin' : 'trader');
|
||||
if (role === 'admin') return <span style={{ color: '#d29922', fontWeight: 600 }}>🛡️ Admin</span>;
|
||||
if (role === 'viewer') return <span style={{ color: '#58a6ff', fontWeight: 500 }}>👁️ Viewer</span>;
|
||||
return <span style={{ color: '#8b949e' }}>👤 Trader</span>;
|
||||
if (role === 'admin') return <span className="font-semibold text-yellow">🛡️ Admin</span>;
|
||||
if (role === 'viewer') return <span className="font-medium text-accent">👁️ Viewer</span>;
|
||||
return <span className="text-text-secondary">👤 Trader</span>;
|
||||
})()}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_active')}>
|
||||
{u.is_active ? <span style={badgeGreen}>● Active</span> : <span style={badgeRed}>● Inactive</span>}
|
||||
<td className={tdClass}>
|
||||
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_active')}>
|
||||
{u.is_active ? <span className="text-xs font-semibold text-green">● Active</span> : <span className="text-xs font-semibold text-red">● Inactive</span>}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: '#484f58' }}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button style={cellBtn} onClick={() => toggleUserField(u, 'is_admin')}>
|
||||
<td className={`${tdClass} text-text-dim`}>{u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button className={cellBtnClass} onClick={() => toggleUserField(u, 'is_admin')}>
|
||||
{u.is_admin ? 'Demote' : 'Promote'}
|
||||
</button>
|
||||
<button style={cellBtn} onClick={() => openModal('edit', u)}>
|
||||
<button className={cellBtnClass} onClick={() => openModal('edit', u)}>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button style={cellBtn} onClick={() => openModal('reset', u)}>
|
||||
<button className={cellBtnClass} onClick={() => openModal('reset', u)}>
|
||||
🔑 Reset
|
||||
</button>
|
||||
<button style={dangerBtn} onClick={() => openModal('delete', u)}>
|
||||
<button className={dangerBtnClass} onClick={() => openModal('delete', u)}>
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -508,7 +495,7 @@ export default function AdminPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginTop: 8 }}>
|
||||
<div className="mt-2 text-[11px] text-text-dim">
|
||||
Showing {filteredUsers.length} of {totalUsers} users
|
||||
</div>
|
||||
</>
|
||||
@@ -517,21 +504,12 @@ export default function AdminPage() {
|
||||
{/* ═══ MODAL ═══ */}
|
||||
{modal.show && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
}}
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#161b22', border: '1px solid #30363d', borderRadius: 12,
|
||||
padding: 24, maxWidth: 440, width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[90%] max-w-[440px] rounded-xl border border-border-default bg-bg-surface p-6 shadow-[0_8px_32px_rgba(0,0,0,0.5)]">
|
||||
{/* Title */}
|
||||
<h3 style={{ fontSize: 15, color: '#f0f6fc', margin: '0 0 16px 0', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h3 className="mb-4 flex items-center gap-2 text-[15px] text-text-heading">
|
||||
{modal.type === 'add' && '➕ Add User'}
|
||||
{modal.type === 'edit' && '✏️ Edit User'}
|
||||
{modal.type === 'reset' && '🔑 Reset Password'}
|
||||
@@ -540,30 +518,30 @@ export default function AdminPage() {
|
||||
|
||||
{/* Form fields */}
|
||||
{modal.type === 'add' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div style={labelStyle}>Username</div>
|
||||
<input style={inputStyle} value={formData.username}
|
||||
<div className={labelClass}>Username</div>
|
||||
<input className={inputClass} value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Email</div>
|
||||
<input style={inputStyle} type="email" value={formData.email}
|
||||
<div className={labelClass}>Email</div>
|
||||
<input className={inputClass} type="email" value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input style={inputStyle} type="password" value={formData.password}
|
||||
<div className={labelClass}>Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input className={inputClass} type="password" value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Display Name</div>
|
||||
<input style={inputStyle} value={formData.display_name}
|
||||
<div className={labelClass}>Display Name</div>
|
||||
<input className={inputClass} value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Role</div>
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
<div className={labelClass}>Role</div>
|
||||
<select className={`${inputClass} cursor-pointer`}
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||
<option value="admin">🛡️ Admin</option>
|
||||
@@ -575,24 +553,24 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{modal.type === 'edit' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div style={labelStyle}>Username</div>
|
||||
<input style={{ ...inputStyle, opacity: 0.5 }} value={formData.username} disabled />
|
||||
<div className={labelClass}>Username</div>
|
||||
<input className={`${inputClass} opacity-50`} value={formData.username} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Email</div>
|
||||
<input style={inputStyle} type="email" value={formData.email}
|
||||
<div className={labelClass}>Email</div>
|
||||
<input className={inputClass} type="email" value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Display Name</div>
|
||||
<input style={inputStyle} value={formData.display_name}
|
||||
<div className={labelClass}>Display Name</div>
|
||||
<input className={inputClass} value={formData.display_name}
|
||||
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={labelStyle}>Role</div>
|
||||
<select style={{ ...inputStyle, cursor: 'pointer' }}
|
||||
<div className={labelClass}>Role</div>
|
||||
<select className={`${inputClass} cursor-pointer`}
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value, is_admin: e.target.value === 'admin' })}>
|
||||
<option value="admin">🛡️ Admin</option>
|
||||
@@ -605,13 +583,13 @@ export default function AdminPage() {
|
||||
|
||||
{modal.type === 'reset' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: '#8b949e', margin: '0 0 12px 0' }}>
|
||||
Reset password for <strong style={{ color: '#f0f6fc' }}>{modal.targetUser?.username}</strong>.
|
||||
<p className="mb-3 text-xs text-text-secondary">
|
||||
Reset password for <strong className="text-text-heading">{modal.targetUser?.username}</strong>.
|
||||
All active sessions will be revoked.
|
||||
</p>
|
||||
<div>
|
||||
<div style={labelStyle}>New Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input style={inputStyle} type="password" value={formData.password}
|
||||
<div className={labelClass}>New Password (8+ chars, 1 number, 1 special)</div>
|
||||
<input className={inputClass} type="password" value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,7 +597,7 @@ export default function AdminPage() {
|
||||
|
||||
{modal.type === 'delete' && (
|
||||
<div>
|
||||
<p style={{ fontSize: 12, color: '#f85149', margin: '0 0 12px 0', background: 'rgba(248,81,73,0.1)', padding: 10, borderRadius: 6, border: '1px solid rgba(248,81,73,0.2)' }}>
|
||||
<p className="mb-3 rounded-md border border-red/20 bg-red/10 p-2.5 text-xs text-red">
|
||||
⚠️ This will permanently delete user <strong>{modal.targetUser?.username}</strong>.
|
||||
Watchlist, credentials, and sessions will be removed. This cannot be undone!
|
||||
</p>
|
||||
@@ -627,14 +605,14 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{formError && <p style={{ color: '#f85149', fontSize: 12, marginTop: 8 }}>{formError}</p>}
|
||||
{formError && <p className="mt-2 text-xs text-red">{formError}</p>}
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className={btnClass} onClick={closeModal}>Cancel</button>
|
||||
{modal.type === 'add' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636' }}
|
||||
className={`${btnClass} border-[#238636] bg-[#238636] text-white`}
|
||||
onClick={handleAddUser}
|
||||
disabled={!formData.username || !formData.email || formData.password.length < 8}
|
||||
>
|
||||
@@ -643,7 +621,7 @@ export default function AdminPage() {
|
||||
)}
|
||||
{modal.type === 'edit' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}
|
||||
className={`${btnClass} border-accent-blue bg-accent-blue text-white`}
|
||||
onClick={handleEditUser}
|
||||
>
|
||||
💾 Save Changes
|
||||
@@ -651,7 +629,7 @@ export default function AdminPage() {
|
||||
)}
|
||||
{modal.type === 'reset' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#d29922', color: '#fff', borderColor: '#d29922' }}
|
||||
className={`${btnClass} border-yellow bg-yellow text-white`}
|
||||
onClick={handleResetPassword}
|
||||
disabled={!formData.password || formData.password.length < 8}
|
||||
>
|
||||
@@ -660,7 +638,7 @@ export default function AdminPage() {
|
||||
)}
|
||||
{modal.type === 'delete' && (
|
||||
<button
|
||||
style={{ ...btnStyle, background: '#da3633', color: '#fff', borderColor: '#da3633' }}
|
||||
className={`${btnClass} border-[#da3633] bg-[#da3633] text-white`}
|
||||
onClick={handleDeleteUser}
|
||||
>
|
||||
🗑️ Delete Forever
|
||||
@@ -677,48 +655,48 @@ export default function AdminPage() {
|
||||
{/* ═══ EXCHANGES TAB ═══ */}
|
||||
{/* ════════════════════════════════════════════════════ */}
|
||||
{activeTab === 'exchanges' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: 0 }}>Exchange Management</h3>
|
||||
<button style={btnStyle} onClick={() => setShowAddEx(!showAddEx)}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="m-0 text-sm text-text-heading">Exchange Management</h3>
|
||||
<button className={btnClass} onClick={() => setShowAddEx(!showAddEx)}>
|
||||
{showAddEx ? '✕ Cancel' : '+ Add Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddEx && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: 12, background: '#161b22', borderRadius: 8, marginBottom: 12, border: '1px solid #30363d' }}>
|
||||
<input style={inputStyle} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
|
||||
<input style={inputStyle} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
|
||||
<button style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', marginTop: 4 }} onClick={handleAddExchange}>
|
||||
<div className="mb-3 flex flex-col gap-2 rounded-lg border border-border-default bg-bg-surface p-3">
|
||||
<input className={inputClass} placeholder="Name (e.g. binance)" value={newEx.name} onChange={(e) => setNewEx({ ...newEx, name: e.target.value })} />
|
||||
<input className={inputClass} placeholder="Display Name (e.g. Binance)" value={newEx.display_name} onChange={(e) => setNewEx({ ...newEx, display_name: e.target.value })} />
|
||||
<input className={inputClass} placeholder="Base URL (e.g. https://api.binance.com)" value={newEx.base_url} onChange={(e) => setNewEx({ ...newEx, base_url: e.target.value })} />
|
||||
<input className={inputClass} placeholder="WS URL (e.g. wss://stream.binance.com:9443)" value={newEx.ws_url} onChange={(e) => setNewEx({ ...newEx, ws_url: e.target.value })} />
|
||||
<button className={`${btnClass} mt-1 border-[#238636] bg-[#238636] text-white`} onClick={handleAddExchange}>
|
||||
✅ Create Exchange
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading exchanges...</p>}
|
||||
{exLoading && <p className="text-[13px] text-text-dim">Loading exchanges...</p>}
|
||||
{!exLoading && (
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Name</th>
|
||||
<th style={thStyle}>Display</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Action</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Name</th>
|
||||
<th className={thClass}>Display</th>
|
||||
<th className={thClass}>Status</th>
|
||||
<th className={thClass}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exchanges.map((ex) => (
|
||||
<tr key={ex.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={tdStyle}>{ex.name}</td>
|
||||
<td style={tdStyle}>{ex.display_name || ex.name}</td>
|
||||
<td style={tdStyle}>
|
||||
{ex.is_active ? <span style={badgeGreen}>Active</span> : <span style={badgeRed}>Inactive</span>}
|
||||
<tr key={ex.id} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{ex.name}</td>
|
||||
<td className={tdClass}>{ex.display_name || ex.name}</td>
|
||||
<td className={tdClass}>
|
||||
{ex.is_active ? <span className="text-xs font-semibold text-green">Active</span> : <span className="text-xs font-semibold text-red">Inactive</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button style={cellBtn} onClick={() => toggleExchange(ex)}>
|
||||
<td className={tdClass}>
|
||||
<button className={cellBtnClass} onClick={() => toggleExchange(ex)}>
|
||||
{ex.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</td>
|
||||
@@ -733,72 +711,72 @@ export default function AdminPage() {
|
||||
|
||||
{/* ═══ HEALTH TAB ═══ */}
|
||||
{activeTab === 'health' && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h3 style={{ fontSize: 14, color: '#f0f6fc', margin: '0 0 12px 0' }}>System Health</h3>
|
||||
{healthLoading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading health data...</p>}
|
||||
<div className="p-4">
|
||||
<h3 className="mb-3 text-sm text-text-heading">System Health</h3>
|
||||
{healthLoading && <p className="text-[13px] text-text-dim">Loading health data...</p>}
|
||||
{!healthLoading && !health && (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: '#484f58', fontSize: 13, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<div style={{ fontSize: 24, marginBottom: 8 }}>⚠️</div>
|
||||
<div className="rounded-lg border border-border-default bg-bg-surface px-4 py-6 text-center text-[13px] text-text-dim">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
Unable to fetch health data. The health endpoint may not be available.
|
||||
</div>
|
||||
)}
|
||||
{health && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>STATUS</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: health.status === 'healthy' ? '#3fb950' : '#f85149' }}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||
<div className="mb-1 text-[11px] text-text-dim">STATUS</div>
|
||||
<div className={`text-lg font-bold ${health.status === 'healthy' ? 'text-green' : 'text-red'}`}>
|
||||
{health.status === 'healthy' ? '✅ Healthy' : '⚠️ Degraded'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>UPTIME</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{formatUptime(health.uptime)}</div>
|
||||
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||
<div className="mb-1 text-[11px] text-text-dim">UPTIME</div>
|
||||
<div className="text-lg font-bold text-text-heading">{formatUptime(health.uptime)}</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', minWidth: 150 }}>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 4 }}>VERSION</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{health.version}</div>
|
||||
<div className="min-w-[150px] rounded-lg border border-border-default bg-bg-surface px-4 py-3">
|
||||
<div className="mb-1 text-[11px] text-text-dim">VERSION</div>
|
||||
<div className="text-lg font-bold text-text-heading">{health.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Database</h4>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
|
||||
<h4 className="mb-2 text-[13px] text-text-heading">Database</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Connected</th>
|
||||
<th style={thStyle}>Latency</th>
|
||||
<th style={thStyle}>Pool Size</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Connected</th>
|
||||
<th className={thClass}>Latency</th>
|
||||
<th className={thClass}>Pool Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={tdStyle}>{health.db.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}</td>
|
||||
<td style={tdStyle}>{health.db.latency_ms} ms</td>
|
||||
<td style={tdStyle}>{health.db.pool_size} connections</td>
|
||||
<td className={tdClass}>{health.db.connected ? <span className="text-xs font-semibold text-green">● Connected</span> : <span className="text-xs font-semibold text-red">● Disconnected</span>}</td>
|
||||
<td className={tdClass}>{health.db.latency_ms} ms</td>
|
||||
<td className={tdClass}>{health.db.pool_size} connections</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 12, background: '#161b22', borderRadius: 8, border: '1px solid #30363d' }}>
|
||||
<h4 style={{ fontSize: 13, color: '#f0f6fc', margin: '0 0 8px 0' }}>Exchange Connections</h4>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, minWidth: 400 }}>
|
||||
<div className="rounded-lg border border-border-default bg-bg-surface p-3">
|
||||
<h4 className="mb-2 text-[13px] text-text-heading">Exchange Connections</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[400px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Exchange</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Exchange</th>
|
||||
<th className={thClass}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.exchange_connections.map((ec) => (
|
||||
<tr key={ec.exchange} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={tdStyle}>{ec.exchange}</td>
|
||||
<td style={tdStyle}>
|
||||
{ec.connected ? <span style={badgeGreen}>● Connected</span> : <span style={badgeRed}>● Disconnected</span>}
|
||||
<tr key={ec.exchange} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{ec.exchange}</td>
|
||||
<td className={tdClass}>
|
||||
{ec.connected ? <span className="text-xs font-semibold text-green">● Connected</span> : <span className="text-xs font-semibold text-red">● Disconnected</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -813,10 +791,3 @@ export default function AdminPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '8px 12px', verticalAlign: 'middle',
|
||||
};
|
||||
|
||||
@@ -11,54 +11,12 @@ import {
|
||||
type ConditionObject,
|
||||
} from '../api/alertApi';
|
||||
|
||||
// ── Styles ──
|
||||
const pageStyle: React.CSSProperties = {
|
||||
minHeight: '100vh', background: '#0d1117', color: '#c9d1d9',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||
padding: '24px',
|
||||
};
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 24, flexWrap: 'wrap', gap: 12,
|
||||
};
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: 16, background: '#161b22', borderRadius: 8,
|
||||
border: '1px solid #30363d', marginBottom: 12,
|
||||
};
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9',
|
||||
cursor: 'pointer', touchAction: 'manipulation', minHeight: 32,
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb',
|
||||
};
|
||||
const dangerBtn: React.CSSProperties = {
|
||||
...btnStyle, color: '#f85149', borderColor: '#f8514933',
|
||||
};
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none',
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
};
|
||||
const selectStyle: React.CSSProperties = { ...inputStyle, width: '100%' };
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#8b949e', fontWeight: 500, marginBottom: 4,
|
||||
};
|
||||
const modalOverlay: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
};
|
||||
const modalContent: React.CSSProperties = {
|
||||
background: '#161b22', borderRadius: 8, border: '1px solid #30363d',
|
||||
padding: 24, width: '90%', maxWidth: 600, maxHeight: '80vh',
|
||||
overflow: 'auto', color: '#c9d1d9',
|
||||
};
|
||||
const toggleStyle: React.CSSProperties = {
|
||||
width: 36, height: 20, borderRadius: 10, border: 'none',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background 0.2s',
|
||||
};
|
||||
// ── Shared Tailwind class fragments ──
|
||||
const btnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
const primaryBtnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-accent-blue bg-accent-blue px-4 py-1.5 text-xs text-white';
|
||||
const dangerBtnClass = 'min-h-8 touch-manipulation cursor-pointer rounded-md border border-red/20 bg-bg-hover px-4 py-1.5 text-xs text-red';
|
||||
const inputClass = 'box-border w-full rounded border border-border-default bg-bg-primary px-2 py-1.5 text-xs text-text-primary outline-none';
|
||||
const labelClass = 'mb-1 text-[11px] font-medium text-text-secondary';
|
||||
|
||||
const INDICATOR_OPTIONS = [
|
||||
{ value: 'rsi', label: 'RSI' },
|
||||
@@ -233,21 +191,25 @@ export default function AlertsPage() {
|
||||
|
||||
// ── Render ──
|
||||
if (initializing) {
|
||||
return <div style={pageStyle}><p style={{ textAlign: 'center', paddingTop: 80 }}>Loading...</p></div>;
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary p-6 font-sans text-text-primary">
|
||||
<p className="pt-20 text-center">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>🔔</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Smart Alerts</span>
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">🔔</span>
|
||||
<span className="text-base font-bold text-text-heading">Smart Alerts</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button
|
||||
style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }}
|
||||
className={`${btnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
@@ -261,246 +223,236 @@ export default function AlertsPage() {
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '16px 24px' }}>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>🔔 Smart Alerts</h2>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button style={primaryBtn} onClick={openCreateModal}>+ New Alert</button>
|
||||
<button style={btnStyle} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ padding: '8px 12px', background: 'rgba(248,81,73,0.15)', borderRadius: 6, marginBottom: 16, fontSize: 12, color: '#f85149' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <p style={{ textAlign: 'center', color: '#8b949e' }}>Loading alerts...</p>}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && alerts.length === 0 && (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#8b949e' }}>
|
||||
<p style={{ fontSize: 40, margin: 0 }}>🔔</p>
|
||||
<p>No alerts configured yet.</p>
|
||||
<button style={primaryBtn} onClick={openCreateModal}>Create your first alert</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert cards */}
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} style={cardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>{alert.name}</h3>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '1px 6px', borderRadius: 4,
|
||||
background: alert.is_active ? 'rgba(63,185,80,0.15)' : 'rgba(72,79,88,0.3)',
|
||||
color: alert.is_active ? '#3fb950' : '#484f58',
|
||||
}}>
|
||||
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: '#8b949e' }}>via {alert.notify_platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{/* Toggle */}
|
||||
<button
|
||||
style={{
|
||||
...toggleStyle,
|
||||
background: alert.is_active ? '#1f6feb' : '#21262d',
|
||||
}}
|
||||
onClick={() => handleToggle(alert)}
|
||||
title={alert.is_active ? 'Pause' : 'Activate'}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, width: 16, height: 16,
|
||||
borderRadius: '50%', background: '#fff',
|
||||
left: alert.is_active ? 18 : 2,
|
||||
transition: 'left 0.2s',
|
||||
}} />
|
||||
</button>
|
||||
<button style={btnStyle} onClick={() => openEditModal(alert)}>Edit</button>
|
||||
<button style={dangerBtn} onClick={() => handleDelete(alert)}>Delete</button>
|
||||
</div>
|
||||
<div className="p-4 px-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="m-0 text-xl font-semibold">🔔 Smart Alerts</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={primaryBtnClass} onClick={openCreateModal}>+ New Alert</button>
|
||||
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>Conditions:</div>
|
||||
{alert.conditions.map((cond, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 8px', background: '#0d1117', borderRadius: 4,
|
||||
margin: '2px 4px 2px 0', fontSize: 11, color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
}}>
|
||||
<span style={{ color: '#58a6ff' }}>{cond.indicator}</span>
|
||||
<span style={{ color: '#d29922' }}>{cond.operator}</span>
|
||||
<span style={{ color: '#3fb950' }}>{cond.value}</span>
|
||||
{cond.timeframe && <span style={{ color: '#8b949e' }}>({cond.timeframe})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Create/Edit Modal ── */}
|
||||
{showModal && (
|
||||
<div style={modalOverlay} onClick={closeModal}>
|
||||
<div style={modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
|
||||
{editTarget ? 'Edit Alert' : 'New Alert'}
|
||||
</h3>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red/15 px-3 py-2 text-xs text-red">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={labelStyle}>Name</div>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. RSI overbought + volume spike"
|
||||
/>
|
||||
</div>
|
||||
{/* Loading */}
|
||||
{loading && <p className="text-center text-text-secondary">Loading alerts...</p>}
|
||||
|
||||
{/* Platform */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={labelStyle}>Notify via</div>
|
||||
<select
|
||||
style={selectStyle}
|
||||
value={formPlatform}
|
||||
onChange={(e) => setFormPlatform(e.target.value)}
|
||||
>
|
||||
{PLATFORM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Empty state */}
|
||||
{!loading && alerts.length === 0 && (
|
||||
<div className="pt-16 text-center text-text-secondary">
|
||||
<p className="m-0 text-4xl">🔔</p>
|
||||
<p>No alerts configured yet.</p>
|
||||
<button className={primaryBtnClass} onClick={openCreateModal}>Create your first alert</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert cards */}
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className="mb-3 rounded-lg border border-border-default bg-bg-surface p-4">
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="m-0 text-sm font-semibold">{alert.name}</h3>
|
||||
<span className={`rounded px-1.5 py-px text-[10px] ${alert.is_active ? 'bg-green/15 text-green' : 'bg-text-dim/30 text-text-dim'}`}>
|
||||
{alert.is_active ? 'ACTIVE' : 'PAUSED'}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary">via {alert.notify_platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
className={`relative h-5 w-9 cursor-pointer rounded-full border-none transition-colors ${alert.is_active ? 'bg-accent-blue' : 'bg-bg-hover'}`}
|
||||
onClick={() => handleToggle(alert)}
|
||||
title={alert.is_active ? 'Pause' : 'Activate'}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 h-4 w-4 rounded-full bg-white transition-[left]"
|
||||
style={{ left: alert.is_active ? 18 : 2 }}
|
||||
/>
|
||||
</button>
|
||||
<button className={btnClass} onClick={() => openEditModal(alert)}>Edit</button>
|
||||
<button className={dangerBtnClass} onClick={() => handleDelete(alert)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={labelStyle}>Conditions (all must pass)</span>
|
||||
<button style={btnStyle} onClick={addCondition}>+ Add Condition</button>
|
||||
<div className="mb-1 text-[11px] text-text-secondary">Conditions:</div>
|
||||
{alert.conditions.map((cond, idx) => (
|
||||
<div key={idx} className="my-0.5 mr-1 inline-flex items-center gap-1 rounded border border-border-default bg-bg-primary px-2 py-0.5 text-[11px] text-text-primary">
|
||||
<span className="text-accent">{cond.indicator}</span>
|
||||
<span className="text-yellow">{cond.operator}</span>
|
||||
<span className="text-green">{cond.value}</span>
|
||||
{cond.timeframe && <span className="text-text-secondary">({cond.timeframe})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Create/Edit Modal ── */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60" onClick={closeModal}>
|
||||
<div
|
||||
className="max-h-[80vh] w-[90%] max-w-[600px] overflow-auto rounded-lg border border-border-default bg-bg-surface p-6 text-text-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-4 text-base font-semibold">
|
||||
{editTarget ? 'Edit Alert' : 'New Alert'}
|
||||
</h3>
|
||||
|
||||
{/* Name */}
|
||||
<div className="mb-3">
|
||||
<div className={labelClass}>Name</div>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. RSI overbought + volume spike"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formConditions.map((cond, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap',
|
||||
padding: '8px 0', borderBottom: idx < formConditions.length - 1 ? '1px solid #21262d' : 'none',
|
||||
}}>
|
||||
{/* Indicator */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 110 }}
|
||||
value={cond.indicator}
|
||||
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
|
||||
>
|
||||
{INDICATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Platform */}
|
||||
<div className="mb-3">
|
||||
<div className={labelClass}>Notify via</div>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={formPlatform}
|
||||
onChange={(e) => setFormPlatform(e.target.value)}
|
||||
>
|
||||
{PLATFORM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 100 }}
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Value */}
|
||||
<input
|
||||
style={{ ...inputStyle, width: 80 }}
|
||||
type="number"
|
||||
step="any"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
{/* Timeframe */}
|
||||
<select
|
||||
style={{ ...selectStyle, width: 70 }}
|
||||
value={cond.timeframe || ''}
|
||||
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
|
||||
>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Extra fields for volume */}
|
||||
{cond.indicator === 'volume' && (
|
||||
<>
|
||||
<select
|
||||
style={{ ...selectStyle, width: 110 }}
|
||||
value={cond.type || 'absolute'}
|
||||
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
|
||||
>
|
||||
<option value="absolute">Absolute</option>
|
||||
<option value="avg_multiplier">Avg Multiplier</option>
|
||||
</select>
|
||||
{cond.type === 'avg_multiplier' && (
|
||||
<input
|
||||
style={{ ...inputStyle, width: 60 }}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Extra for sma/ema */}
|
||||
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
|
||||
<input
|
||||
style={{ ...inputStyle, width: 60 }}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
style={{ ...dangerBtn, padding: '2px 8px', fontSize: 10 }}
|
||||
onClick={() => removeCondition(idx)}
|
||||
disabled={formConditions.length <= 1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* Conditions */}
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className={labelClass}>Conditions (all must pass)</span>
|
||||
<button className={btnClass} onClick={addCondition}>+ Add Condition</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError && (
|
||||
<div style={{ padding: '6px 10px', background: 'rgba(248,81,73,0.15)', borderRadius: 4, marginBottom: 12, fontSize: 11, color: '#f85149' }}>
|
||||
{formError}
|
||||
{formConditions.map((cond, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex flex-wrap items-center gap-1.5 py-2 ${idx < formConditions.length - 1 ? 'border-b border-border-muted' : ''}`}
|
||||
>
|
||||
{/* Indicator */}
|
||||
<select
|
||||
className={`${inputClass} w-[110px]`}
|
||||
value={cond.indicator}
|
||||
onChange={(e) => updateCondition(idx, 'indicator', e.target.value)}
|
||||
>
|
||||
{INDICATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Operator */}
|
||||
<select
|
||||
className={`${inputClass} w-[100px]`}
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(idx, 'operator', e.target.value)}
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Value */}
|
||||
<input
|
||||
className={`${inputClass} w-20`}
|
||||
type="number"
|
||||
step="any"
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(idx, 'value', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
|
||||
{/* Timeframe */}
|
||||
<select
|
||||
className={`${inputClass} w-[70px]`}
|
||||
value={cond.timeframe || ''}
|
||||
onChange={(e) => updateCondition(idx, 'timeframe', e.target.value || undefined)}
|
||||
>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Extra fields for volume */}
|
||||
{cond.indicator === 'volume' && (
|
||||
<>
|
||||
<select
|
||||
className={`${inputClass} w-[110px]`}
|
||||
value={cond.type || 'absolute'}
|
||||
onChange={(e) => updateCondition(idx, 'type', e.target.value)}
|
||||
>
|
||||
<option value="absolute">Absolute</option>
|
||||
<option value="avg_multiplier">Avg Multiplier</option>
|
||||
</select>
|
||||
{cond.type === 'avg_multiplier' && (
|
||||
<input
|
||||
className={`${inputClass} w-[60px]`}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Extra for sma/ema */}
|
||||
{(cond.indicator === 'sma' || cond.indicator === 'ema') && (
|
||||
<input
|
||||
className={`${inputClass} w-[60px]`}
|
||||
type="number"
|
||||
placeholder="Period"
|
||||
value={cond.period || 20}
|
||||
onChange={(e) => updateCondition(idx, 'period', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
className={`${dangerBtnClass} px-2 py-0.5 text-[10px]`}
|
||||
onClick={() => removeCondition(idx)}
|
||||
disabled={formConditions.length <= 1}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button style={btnStyle} onClick={closeModal}>Cancel</button>
|
||||
<button
|
||||
style={{ ...primaryBtn, opacity: saving ? 0.6 : 1 }}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
|
||||
</button>
|
||||
{/* Form error */}
|
||||
{formError && (
|
||||
<div className="mb-3 rounded bg-red/15 px-2.5 py-1.5 text-[11px] text-red">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className={btnClass} onClick={closeModal}>Cancel</button>
|
||||
<button
|
||||
className={primaryBtnClass}
|
||||
style={{ opacity: saving ? 0.6 : 1 }}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : editTarget ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,83 +1,53 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { fetchCurrentUser } from '../auth/authSlice';
|
||||
import { logoutThunk } from '../auth/authSlice';
|
||||
import { useT } from '../../translations';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
|
||||
// ── Mock/temporary analytics data ──
|
||||
// These would be replaced by real API calls. For now, generate realistic demo data.
|
||||
interface DailyPnL {
|
||||
// ── Types matching backend GET /analytics/dashboard ──
|
||||
interface PnlHistoryPoint {
|
||||
date: string;
|
||||
pnl: number;
|
||||
isSignal: boolean;
|
||||
cumulative: number;
|
||||
}
|
||||
|
||||
interface AnalyticsStats {
|
||||
totalPnL: number;
|
||||
winRate: number;
|
||||
bestTrade: number;
|
||||
worstTrade: number;
|
||||
profitFactor: number;
|
||||
maxDrawdown: number;
|
||||
totalTrades: number;
|
||||
interface EquityPoint {
|
||||
date: string;
|
||||
equity: number;
|
||||
}
|
||||
|
||||
interface WinRatePeriod {
|
||||
trades: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
win_rate: number;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
function generateDemoData(isSignal: boolean): { dailyPnL: DailyPnL[]; stats: AnalyticsStats } {
|
||||
const dailyPnL: DailyPnL[] = [];
|
||||
let equity = 10000;
|
||||
let peak = 10000;
|
||||
let maxDd = 0;
|
||||
let totalWins = 0;
|
||||
let totalLosses = 0;
|
||||
let totalWinAmount = 0;
|
||||
let totalLossAmount = 0;
|
||||
let bestTrade = 0;
|
||||
let worstTrade = 0;
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const basePnL = (Math.random() - 0.48) * (isSignal ? 300 : 200);
|
||||
const pnl = Math.round(basePnL * 100) / 100;
|
||||
dailyPnL.push({ date: date.toISOString().slice(0, 10), pnl, isSignal });
|
||||
equity += pnl;
|
||||
if (equity > peak) peak = equity;
|
||||
const dd = ((peak - equity) / peak) * 100;
|
||||
if (dd > maxDd) maxDd = dd;
|
||||
if (pnl >= 0) {
|
||||
totalWins++;
|
||||
totalWinAmount += pnl;
|
||||
if (pnl > bestTrade) bestTrade = pnl;
|
||||
} else {
|
||||
totalLosses++;
|
||||
totalLossAmount += Math.abs(pnl);
|
||||
if (pnl < worstTrade) worstTrade = pnl;
|
||||
}
|
||||
}
|
||||
|
||||
const totalTrades = totalWins + totalLosses;
|
||||
const winRate = totalTrades > 0 ? (totalWins / totalTrades) * 100 : 0;
|
||||
const profitFactor = totalLossAmount > 0 ? totalWinAmount / totalLossAmount : totalWinAmount > 0 ? Infinity : 0;
|
||||
|
||||
return {
|
||||
dailyPnL,
|
||||
stats: {
|
||||
totalPnL: Math.round((equity - 10000) * 100) / 100,
|
||||
winRate: Math.round(winRate * 10) / 10,
|
||||
bestTrade: Math.round(bestTrade * 100) / 100,
|
||||
worstTrade: Math.round(worstTrade * 100) / 100,
|
||||
profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100,
|
||||
maxDrawdown: Math.round(maxDd * 10) / 10,
|
||||
totalTrades,
|
||||
wins: totalWins,
|
||||
losses: totalLosses,
|
||||
},
|
||||
};
|
||||
interface BestWorstTrade {
|
||||
symbol: string;
|
||||
pnl: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface DashboardResponse {
|
||||
pnl_history: PnlHistoryPoint[];
|
||||
equity_curve: EquityPoint[];
|
||||
drawdown: { max_drawdown_pct: number; current_drawdown_pct: number; max_drawdown_date: string };
|
||||
win_rate_by_period: { daily: WinRatePeriod; weekly: WinRatePeriod; monthly: WinRatePeriod };
|
||||
best_trade: BestWorstTrade;
|
||||
worst_trade: BestWorstTrade;
|
||||
total_trades: number;
|
||||
total_pnl: number;
|
||||
open_trades: number;
|
||||
avg_trade_pnl: number;
|
||||
profit_factor: number;
|
||||
avg_hold_time_hours: number;
|
||||
}
|
||||
|
||||
const DAY_RANGES = [7, 30, 90] as const;
|
||||
|
||||
// ── Simple SVG Bar Chart ──
|
||||
function BarChart({
|
||||
data,
|
||||
@@ -85,7 +55,7 @@ function BarChart({
|
||||
colorUp = '#3fb950',
|
||||
colorDown = '#f85149',
|
||||
}: {
|
||||
data: DailyPnL[];
|
||||
data: PnlHistoryPoint[];
|
||||
height?: number;
|
||||
colorUp?: string;
|
||||
colorDown?: string;
|
||||
@@ -129,21 +99,22 @@ function LineChart({
|
||||
height = 200,
|
||||
lineColor = '#58a6ff',
|
||||
}: {
|
||||
equityData: number[];
|
||||
equityData: EquityPoint[];
|
||||
height?: number;
|
||||
lineColor?: string;
|
||||
}) {
|
||||
if (equityData.length < 2) return null;
|
||||
|
||||
const min = Math.min(...equityData);
|
||||
const max = Math.max(...equityData);
|
||||
const values = equityData.map((e) => e.equity);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const padding = 20;
|
||||
const chartWidth = Math.max(800, equityData.length * 4);
|
||||
const chartWidth = Math.max(800, values.length * 4);
|
||||
|
||||
const points = equityData
|
||||
const points = values
|
||||
.map((val, i) => {
|
||||
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
|
||||
const x = padding + (i / (values.length - 1)) * (chartWidth - padding * 2);
|
||||
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
@@ -165,13 +136,13 @@ function LineChart({
|
||||
{/* Line */}
|
||||
<polyline points={points} fill="none" stroke={lineColor} strokeWidth={2} strokeLinejoin="round" />
|
||||
{/* Dots on line */}
|
||||
{equityData.map((val, i) => {
|
||||
if (i % 5 !== 0 && i !== equityData.length - 1) return null;
|
||||
const x = padding + (i / (equityData.length - 1)) * (chartWidth - padding * 2);
|
||||
{values.map((val, i) => {
|
||||
if (i % 5 !== 0 && i !== values.length - 1) return null;
|
||||
const x = padding + (i / (values.length - 1)) * (chartWidth - padding * 2);
|
||||
const y = height - padding - ((val - min) / range) * (height - padding * 2);
|
||||
return (
|
||||
<circle key={i} cx={x} cy={y} r={3} fill={lineColor} opacity={0.8}>
|
||||
<title>{`Day ${i + 1}: $${val.toFixed(2)}`}</title>
|
||||
<title>{`${equityData[i].date}: $${val.toFixed(2)}`}</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
@@ -190,25 +161,11 @@ function StatCard({ label, value, color, isCurrency }: {
|
||||
? `${Number(value) >= 0 ? '+' : ''}$${Number(value).toFixed(2)}`
|
||||
: value;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
<div className="min-w-0 rounded-lg border border-border-default bg-bg-surface px-3.5 py-3">
|
||||
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-text-secondary">
|
||||
{label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
||||
color: color || '#f0f6fc',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}>
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-lg font-bold" style={{ color: color || '#f0f6fc' }}>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,15 +180,10 @@ export default function AnalyticsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { user, isAuthenticated, isLoading } = useAppSelector((state) => state.auth);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [dataMode, setDataMode] = useState<'signal' | 'real'>('signal');
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Responsive
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const [days, setDays] = useState<(typeof DAY_RANGES)[number]>(30);
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Auth guard
|
||||
useEffect(() => {
|
||||
@@ -245,216 +197,178 @@ export default function AnalyticsPage() {
|
||||
setInitializing(false);
|
||||
}, [isAuthenticated, user, dispatch, navigate]);
|
||||
|
||||
// Generate demo data based on toggle
|
||||
const demoData = useMemo(() => generateDemoData(dataMode === 'signal'), [dataMode]);
|
||||
const equityCurve = useMemo(() => {
|
||||
let eq = 10000;
|
||||
return demoData.dailyPnL.map((d) => {
|
||||
eq += d.pnl;
|
||||
return Math.round(eq * 100) / 100;
|
||||
});
|
||||
}, [demoData]);
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await apiFetch<DashboardResponse>(`/analytics/dashboard?days=${days}`);
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load analytics');
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing) loadDashboard();
|
||||
}, [initializing, loadDashboard]);
|
||||
|
||||
const { t } = useT(); // eslint-disable-line
|
||||
|
||||
if (!isAuthenticated) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#8b949e' }}>
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg-primary text-text-secondary">
|
||||
Loading analytics...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navBtn: React.CSSProperties = {
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
const navBtnClass = 'min-h-9 touch-manipulation whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
const toggleBtnClass = (active: boolean) =>
|
||||
`inline-flex min-h-9 touch-manipulation items-center rounded-md border px-4 py-1.5 text-xs transition-colors ${
|
||||
active ? 'border-accent-blue bg-accent-blue font-semibold text-white' : 'border-border-default bg-bg-hover font-normal text-text-primary'
|
||||
}`;
|
||||
|
||||
const toggleBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: '6px 16px',
|
||||
fontSize: 12,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: active ? '#1f6feb' : '#21262d',
|
||||
color: active ? '#fff' : '#c9d1d9',
|
||||
cursor: 'pointer',
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: 'all 0.15s ease',
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const s = demoData.stats;
|
||||
const monthly = data?.win_rate_by_period?.monthly;
|
||||
const winRate = monthly?.win_rate ?? 0;
|
||||
const wins = monthly?.wins ?? 0;
|
||||
const losses = Math.max((monthly?.trades ?? 0) - wins, 0);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
overflowX: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', flexShrink: 0 }} onClick={() => navigate('/dashboard')}>
|
||||
<span style={{ fontSize: 18 }}>📈</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Analytics</span>
|
||||
<nav className="scrollable-x flex flex-wrap items-center justify-between gap-2 overflow-x-auto border-b border-border-default bg-bg-surface px-3 py-2">
|
||||
<div className="flex shrink-0 cursor-pointer items-center gap-3" onClick={() => navigate('/dashboard')}>
|
||||
<span className="text-lg">📈</span>
|
||||
<span className="text-base font-bold text-text-heading">Analytics</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12, color: '#8b949e', whiteSpace: 'nowrap' }}>👤 {user?.username}</span>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', whiteSpace: 'nowrap' }} onClick={() => navigate('/profile')}>Profile</button>
|
||||
<button style={{ ...navBtn, minHeight: 36, touchAction: 'manipulation', color: '#f85149', borderColor: '#f8514933', whiteSpace: 'nowrap' }} onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-xs text-text-secondary">👤 {user?.username}</span>
|
||||
<button className={navBtnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button className={navBtnClass} onClick={() => navigate('/profile')}>Profile</button>
|
||||
<button
|
||||
className={`${navBtnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ─── PAGE CONTENT ─── */}
|
||||
<div style={{ padding: isMobile ? 12 : 20, maxWidth: 960, margin: '0 auto' }}>
|
||||
{/* Header + Toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc', margin: 0 }}>
|
||||
<div className="mx-auto max-w-[960px] p-3 md:p-5">
|
||||
{/* Header + Range picker */}
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="m-0 text-lg font-bold text-text-heading">
|
||||
📊 Performance Overview
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
style={toggleBtn(dataMode === 'signal')}
|
||||
onClick={() => setDataMode('signal')}
|
||||
>
|
||||
📈 Signal
|
||||
</button>
|
||||
<button
|
||||
style={toggleBtn(dataMode === 'real')}
|
||||
onClick={() => setDataMode('real')}
|
||||
>
|
||||
💹 Real
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{DAY_RANGES.map((d) => (
|
||||
<button key={d} className={toggleBtnClass(days === d)} onClick={() => setDays(d)}>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── STATS CARDS GRID ─── */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(6, 1fr)',
|
||||
gap: isMobile ? 8 : 10,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<StatCard label="Total P&L" value={s.totalPnL} color={s.totalPnL >= 0 ? '#3fb950' : '#f85149'} isCurrency />
|
||||
<StatCard label="Win Rate" value={`${s.winRate}%`} color={s.winRate >= 50 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Best Trade" value={s.bestTrade} color="#3fb950" isCurrency />
|
||||
<StatCard label="Worst Trade" value={s.worstTrade} color="#f85149" isCurrency />
|
||||
<StatCard label="Profit Factor" value={s.profitFactor} color={s.profitFactor >= 1 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Max Drawdown" value={`${s.maxDrawdown}%`} color="#d29922" />
|
||||
</div>
|
||||
|
||||
{/* ─── PnL Bar Chart ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px 12px',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
|
||||
📊 Daily P&L (Last 30 Days)
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<BarChart data={demoData.dailyPnL} height={180} />
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red bg-[#2d0e0e] px-3.5 py-2.5 text-[13px] text-red">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Equity Curve ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px 12px',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 4px' }}>
|
||||
📈 Equity Curve
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'baseline', marginBottom: 8, marginLeft: 4 }}>
|
||||
<span style={{ fontSize: 12, color: '#8b949e' }}>Starting: <strong style={{ color: '#c9d1d9' }}>$10,000.00</strong></span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: s.totalPnL >= 0 ? '#3fb950' : '#f85149' }}>
|
||||
{s.totalPnL >= 0 ? '+' : ''}${s.totalPnL.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
<LineChart equityData={equityCurve} height={200} />
|
||||
</div>
|
||||
</div>
|
||||
{loadingData && !data && (
|
||||
<div className="py-5 text-[13px] text-text-secondary">Loading performance data...</div>
|
||||
)}
|
||||
|
||||
{/* ─── Summary Trades Table ─── */}
|
||||
<div style={{
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 10,
|
||||
padding: '16px',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<h3 style={{ fontSize: 13, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '0 0 12px 0' }}>
|
||||
📋 Trade Summary
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr 1fr' : 'repeat(4, 1fr)',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Total Trades</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f0f6fc' }}>{s.totalTrades}</div>
|
||||
{data && (
|
||||
<>
|
||||
{/* ─── STATS CARDS GRID ─── */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-2 md:grid-cols-6 md:gap-2.5">
|
||||
<StatCard label="Total P&L" value={data.total_pnl} color={data.total_pnl >= 0 ? '#3fb950' : '#f85149'} isCurrency />
|
||||
<StatCard label="Win Rate (30d)" value={`${winRate}%`} color={winRate >= 50 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Best Trade" value={data.best_trade.pnl} color="#3fb950" isCurrency />
|
||||
<StatCard label="Worst Trade" value={data.worst_trade.pnl} color="#f85149" isCurrency />
|
||||
<StatCard label="Profit Factor" value={data.profit_factor} color={data.profit_factor >= 1 ? '#3fb950' : '#f85149'} />
|
||||
<StatCard label="Max Drawdown" value={`${data.drawdown.max_drawdown_pct}%`} color="#d29922" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Wins</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#3fb950' }}>{s.wins}</div>
|
||||
|
||||
{/* ─── PnL Bar Chart ─── */}
|
||||
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
|
||||
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||
📊 Daily P&L (Last {days} Days)
|
||||
</h3>
|
||||
{data.pnl_history.length === 0 ? (
|
||||
<div className="py-5 text-center text-xs text-text-dim">No closed trades in this period yet.</div>
|
||||
) : (
|
||||
<div className="scrollable-x overflow-x-auto">
|
||||
<BarChart data={data.pnl_history} height={180} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Losses</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: '#f85149' }}>{s.losses}</div>
|
||||
|
||||
{/* ─── Equity Curve ─── */}
|
||||
<div className="mb-4 overflow-hidden rounded-[10px] border border-border-default bg-bg-surface px-3 py-4">
|
||||
<h3 className="mb-3 ml-1 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||
📈 Equity Curve
|
||||
</h3>
|
||||
{data.equity_curve.length < 2 ? (
|
||||
<div className="py-5 text-center text-xs text-text-dim">Not enough closed trades yet to plot an equity curve.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 ml-1 flex items-baseline gap-3">
|
||||
<span className="text-xs text-text-secondary">Starting: <strong className="text-text-primary">${data.equity_curve[0].equity.toFixed(2)}</strong></span>
|
||||
<span className={`text-sm font-bold ${data.total_pnl >= 0 ? 'text-green' : 'text-red'}`}>
|
||||
{data.total_pnl >= 0 ? '+' : ''}${data.total_pnl.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollable-x overflow-x-auto">
|
||||
<LineChart equityData={data.equity_curve} height={200} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: '#6e7681' }}>Data Source</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#58a6ff' }}>
|
||||
{dataMode === 'signal' ? '📈 Signal' : '💹 Real'}
|
||||
|
||||
{/* ─── Summary Trades Table ─── */}
|
||||
<div className="mb-4 rounded-[10px] border border-border-default bg-bg-surface p-4">
|
||||
<h3 className="mb-3 text-[13px] uppercase tracking-wide text-text-secondary">
|
||||
📋 Trade Summary
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted">Total Trades (all-time)</div>
|
||||
<div className="text-xl font-bold text-text-heading">{data.total_trades}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted">Wins (30d)</div>
|
||||
<div className="text-xl font-bold text-green">{wins}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted">Losses (30d)</div>
|
||||
<div className="text-xl font-bold text-red">{losses}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted">Open Trades</div>
|
||||
<div className="text-base font-semibold text-accent">
|
||||
{data.open_trades}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Footer note ─── */}
|
||||
<div style={{ fontSize: 11, color: '#484f58', textAlign: 'center' as const, padding: '16px 0' }}>
|
||||
Analytics data is based on {dataMode === 'signal' ? 'simulated signal' : 'real exchange'} trades.
|
||||
Charts update in real-time as new trades are executed.
|
||||
</div>
|
||||
{/* ─── Footer note ─── */}
|
||||
<div className="px-0 py-4 text-center text-[11px] text-text-dim">
|
||||
Combines signal (paper) trades and real exchange trades. Win/loss counts above are for the last 30 days; other stats use the selected {days}-day range.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** API functions for user-defined multi-condition alerts. */
|
||||
import { apiFetch } from './apiService';
|
||||
|
||||
export interface ConditionObject {
|
||||
indicator: string;
|
||||
@@ -24,18 +25,8 @@ export interface AlertListResponse {
|
||||
alerts: AlertItem[];
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<AlertItem[]> {
|
||||
const res = await fetch('/api/v1/alerts', { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch alerts: ${res.status}`);
|
||||
const data: AlertListResponse = await res.json();
|
||||
const data = await apiFetch<AlertListResponse>('/alerts');
|
||||
return data.alerts;
|
||||
}
|
||||
|
||||
@@ -44,16 +35,10 @@ export async function createAlert(data: {
|
||||
conditions: ConditionObject[];
|
||||
notify_platform: string;
|
||||
}): Promise<AlertItem> {
|
||||
const res = await fetch('/api/v1/alerts', {
|
||||
return apiFetch<AlertItem>('/alerts', {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateAlert(
|
||||
@@ -65,25 +50,12 @@ export async function updateAlert(
|
||||
is_active?: boolean;
|
||||
},
|
||||
): Promise<AlertItem> {
|
||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
||||
return apiFetch<AlertItem>(`/alerts/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAlert(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/alerts/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
await apiFetch<void>(`/alerts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** API functions for real trades. */
|
||||
import { apiFetch } from './apiService';
|
||||
|
||||
export interface RealTrade {
|
||||
id: number;
|
||||
@@ -46,26 +47,9 @@ export async function fetchRealTrades(
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch(`/api/v1/real-trades?${params}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch real trades: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<RealTradeListResponse>(`/real-trades?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchWinRate(): Promise<WinRateResponse> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await fetch('/api/v1/real-trades/win-rate', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch win rate: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<WinRateResponse>('/real-trades/win-rate');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** API functions for trading signals and hypothetical trades. */
|
||||
import { apiFetch } from './apiService';
|
||||
|
||||
export interface Signal {
|
||||
id: number;
|
||||
@@ -48,22 +49,11 @@ export interface TradeListResponse {
|
||||
win_rate: number | null;
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSignals(symbol?: string, limit = 50): Promise<SignalListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const res = await fetch(`/api/v1/signals?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch signals: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<SignalListResponse>(`/signals?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchTrades(symbol?: string, status?: string, limit = 100): Promise<TradeListResponse> {
|
||||
@@ -71,10 +61,7 @@ export async function fetchTrades(symbol?: string, status?: string, limit = 100)
|
||||
if (symbol) params.set('symbol', symbol);
|
||||
if (status) params.set('status', status);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const res = await fetch(`/api/v1/signals/trades?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch trades: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<TradeListResponse>(`/signals/trades?${params}`);
|
||||
}
|
||||
|
||||
/** Signal type display config */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** API functions for user watchlist management. */
|
||||
import { apiFetch } from './apiService';
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: string;
|
||||
@@ -18,45 +19,23 @@ export interface SymbolOption {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWatchlist(): Promise<WatchlistItem[]> {
|
||||
const res = await fetch('/api/v1/watchlist', { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch watchlist: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<WatchlistItem[]>('/watchlist');
|
||||
}
|
||||
|
||||
export async function addToWatchlist(symbolId: number, label?: string): Promise<WatchlistItem> {
|
||||
const res = await fetch('/api/v1/watchlist', {
|
||||
return apiFetch<WatchlistItem>('/watchlist', {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ symbol_id: symbolId, label: label || null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `Failed to add: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function removeFromWatchlist(id: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/watchlist/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to remove: ${res.status}`);
|
||||
await apiFetch<void>(`/watchlist/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchAllSymbols(exchange = 'mexc', q = ''): Promise<SymbolOption[]> {
|
||||
const params = new URLSearchParams({ exchange });
|
||||
if (q) params.set('q', q);
|
||||
const res = await fetch(`/api/v1/watchlist/all-symbols?${params}`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch symbols: ${res.status}`);
|
||||
return res.json();
|
||||
return apiFetch<SymbolOption[]>(`/watchlist/all-symbols?${params}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useT } from '../../translations';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
|
||||
interface SignalCounts {
|
||||
[key: string]: number;
|
||||
@@ -59,6 +60,11 @@ const SIGNAL_ICONS: Record<string, string> = {
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h'];
|
||||
|
||||
const selectClass = 'rounded-md border border-border-default bg-bg-surface px-2.5 py-1.5 text-sm text-text-primary';
|
||||
const thClass = 'px-3 py-2 text-left font-medium text-text-secondary';
|
||||
const tdClass = 'px-3 py-1.5';
|
||||
const cardClass = 'rounded-lg bg-bg-surface p-4';
|
||||
|
||||
export default function BacktestPage() {
|
||||
const { t } = useT();
|
||||
const [exchange, setExchange] = useState('binance');
|
||||
@@ -76,12 +82,7 @@ export default function BacktestPage() {
|
||||
let cancelled = false;
|
||||
async function loadSymbols() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/backtest/symbols?exchange=${encodeURIComponent(exchange)}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const data = await apiFetch<any>(`/backtest/symbols?exchange=${encodeURIComponent(exchange)}`);
|
||||
if (cancelled) return;
|
||||
const names = (data.symbols || []).map((s: any) => s.symbol);
|
||||
setSymbols(names);
|
||||
@@ -100,15 +101,9 @@ export default function BacktestPage() {
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
|
||||
{ credentials: 'include' }
|
||||
const data = await apiFetch<BacktestResult>(
|
||||
`/backtest/run?symbol=${encodeURIComponent(symbol)}&exchange=${exchange}&timeframe=${timeframe}&days=${days}&trade_size=${tradeSize}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
@@ -118,92 +113,89 @@ export default function BacktestPage() {
|
||||
}, [symbol, timeframe, days, tradeSize]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<h1>📊 {t('Backtest')}</h1>
|
||||
<div className="mx-auto max-w-[1000px] bg-bg-primary p-5 text-text-primary">
|
||||
<h1 className="text-2xl font-bold text-text-heading">📊 {t('Backtest')}</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '20px', alignItems: 'end' }}>
|
||||
<div className="mb-5 flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Exchange</label><br />
|
||||
<select value={exchange} onChange={e => setExchange(e.target.value)}
|
||||
style={selectStyle}>
|
||||
<label className="text-xs text-text-secondary">Exchange</label><br />
|
||||
<select value={exchange} onChange={e => setExchange(e.target.value)} className={selectClass}>
|
||||
{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Symbol ({symbols.length})</label><br />
|
||||
<select value={symbol} onChange={e => setSymbol(e.target.value)}
|
||||
style={{ ...selectStyle, minWidth: '140px' }}>
|
||||
<label className="text-xs text-text-secondary">Symbol ({symbols.length})</label><br />
|
||||
<select value={symbol} onChange={e => setSymbol(e.target.value)} className={`${selectClass} min-w-[140px]`}>
|
||||
{symbols.length > 0
|
||||
? symbols.map(s => <option key={s} value={s}>{s}</option>)
|
||||
: <option value="">Loading...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Timeframe</label><br />
|
||||
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} style={selectStyle}>
|
||||
<label className="text-xs text-text-secondary">Timeframe</label><br />
|
||||
<select value={timeframe} onChange={e => setTimeframe(e.target.value)} className={selectClass}>
|
||||
{TIMEFRAMES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Days</label><br />
|
||||
<select value={days} onChange={e => setDays(Number(e.target.value))} style={selectStyle}>
|
||||
<label className="text-xs text-text-secondary">Days</label><br />
|
||||
<select value={days} onChange={e => setDays(Number(e.target.value))} className={selectClass}>
|
||||
{[3, 7, 14, 30, 90, 180, 365].map(d => <option key={d} value={d}>{d}d</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: '#888' }}>Trade Size (USDT)</label><br />
|
||||
<input type="number" value={tradeSize} onChange={e => setTradeSize(Number(e.target.value))}
|
||||
style={{ ...selectStyle, width: '80px' }} min={1} />
|
||||
<label className="text-xs text-text-secondary">Trade Size (USDT)</label><br />
|
||||
<input
|
||||
type="number"
|
||||
value={tradeSize}
|
||||
onChange={e => setTradeSize(Number(e.target.value))}
|
||||
className={`${selectClass} w-20`}
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={runBacktest} disabled={loading}
|
||||
style={{
|
||||
padding: '8px 24px', background: '#2563eb', color: '#fff', border: 'none',
|
||||
borderRadius: '6px', cursor: loading ? 'wait' : 'pointer', fontWeight: 600,
|
||||
}}>
|
||||
<button
|
||||
onClick={runBacktest}
|
||||
disabled={loading}
|
||||
className={`rounded-md border-none bg-accent-blue px-6 py-2 font-semibold text-white ${loading ? 'cursor-wait' : 'cursor-pointer'}`}
|
||||
>
|
||||
{loading ? '⏳ Running...' : '▶ Run Backtest'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ef4444', marginBottom: '16px' }}>❌ {error}</div>}
|
||||
{error && <div className="mb-4 text-red">❌ {error}</div>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
|
||||
<div className="mb-5 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-3">
|
||||
<SummaryCard label="Trades" value={result.trades.total.toString()} />
|
||||
<SummaryCard label="Win Rate" value={`${result.trades.win_rate}%`}
|
||||
color={result.trades.win_rate >= 50 ? '#22c55e' : '#ef4444'} />
|
||||
color={result.trades.win_rate >= 50 ? 'text-green' : 'text-red'} />
|
||||
<SummaryCard label="Total PnL" value={`$${result.trades.total_pnl.toFixed(2)}`}
|
||||
color={result.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444'} />
|
||||
color={result.trades.total_pnl >= 0 ? 'text-green' : 'text-red'} />
|
||||
<SummaryCard label="Profit Factor" value={result.trades.profit_factor?.toFixed(2) ?? '∞'}
|
||||
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? '#22c55e' : '#ef4444'} />
|
||||
color={result.trades.profit_factor && result.trades.profit_factor >= 1 ? 'text-green' : 'text-red'} />
|
||||
<SummaryCard label="Wins/Losses" value={`${result.trades.wins}W / ${result.trades.losses}L`} />
|
||||
<SummaryCard label="Candles" value={result.candles_count.toString()} />
|
||||
</div>
|
||||
|
||||
{/* Signal Breakdown */}
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Signal Breakdown</h3>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div className={`${cardClass} mb-4`}>
|
||||
<h3 className="mb-3 text-text-heading">📋 Signal Breakdown</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{Object.entries(result.signal_counts).map(([sig, count]) => {
|
||||
const icon = SIGNAL_ICONS[sig] || '❓';
|
||||
const sigTrades = result.trades.per_signal[sig] || { trades: 0, wins: 0, pnl: 0 };
|
||||
return (
|
||||
<div key={sig} style={{
|
||||
padding: '8px 14px', background: '#16213e', borderRadius: '6px',
|
||||
border: '1px solid #0f3460',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>
|
||||
<div key={sig} className="rounded-md border border-border-default bg-bg-primary px-3.5 py-2">
|
||||
<div className="text-sm font-semibold text-text-primary">
|
||||
{icon} {sig.replace('_', ' ')}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#aaa' }}>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{count} signals → {sigTrades.trades} trades
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px', fontWeight: 600,
|
||||
color: sigTrades.pnl >= 0 ? '#22c55e' : '#ef4444'
|
||||
}}>
|
||||
<div className={`text-xs font-semibold ${sigTrades.pnl >= 0 ? 'text-green' : 'text-red'}`}>
|
||||
${sigTrades.pnl.toFixed(2)} ({sigTrades.trades ? Math.round(sigTrades.wins / sigTrades.trades * 100) : 0}% WR)
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,43 +205,43 @@ export default function BacktestPage() {
|
||||
</div>
|
||||
|
||||
{/* Best/Worst Trades */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
{result.trades.best_trade && (
|
||||
<div style={{ background: '#064e3b', borderRadius: '8px', padding: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#6ee7b7' }}>🏆 Best Trade</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
|
||||
<div style={{ color: '#22c55e', fontWeight: 700 }}>+${result.trades.best_trade.pnl.toFixed(2)}</div>
|
||||
<div className="rounded-lg bg-green/10 p-3">
|
||||
<div className="text-xs text-green">🏆 Best Trade</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{result.trades.best_trade.direction} @ ${result.trades.best_trade.entry_price.toFixed(2)}</div>
|
||||
<div className="font-bold text-green">+${result.trades.best_trade.pnl.toFixed(2)}</div>
|
||||
</div>
|
||||
)}
|
||||
{result.trades.worst_trade && (
|
||||
<div style={{ background: '#450a0a', borderRadius: '8px', padding: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#fca5a5' }}>💸 Worst Trade</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
|
||||
<div style={{ color: '#ef4444', fontWeight: 700 }}>{result.trades.worst_trade.pnl.toFixed(2)}$</div>
|
||||
<div className="rounded-lg bg-red/10 p-3">
|
||||
<div className="text-xs text-red">💸 Worst Trade</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{result.trades.worst_trade.direction} @ ${result.trades.worst_trade.entry_price.toFixed(2)}</div>
|
||||
<div className="font-bold text-red">{result.trades.worst_trade.pnl.toFixed(2)}$</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Signals */}
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Signals (last 15)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<div className={`${cardClass} mb-4`}>
|
||||
<h3 className="mb-3 text-text-heading">📋 Recent Signals (last 15)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>Signal</th>
|
||||
<th style={thStyle}>Price</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>Signal</th>
|
||||
<th className={thClass}>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.recent_signals.map((s, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={tdStyle}>{s.time.slice(5, 16)}</td>
|
||||
<td style={tdStyle}>
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{s.time.slice(5, 16)}</td>
|
||||
<td className={tdClass}>
|
||||
{SIGNAL_ICONS[s.signal] || '❓'} {s.signal.replace('_', ' ')}
|
||||
</td>
|
||||
<td style={tdStyle}>${s.price.toFixed(2)}</td>
|
||||
<td className={tdClass}>${s.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -259,31 +251,31 @@ export default function BacktestPage() {
|
||||
|
||||
{/* Recent Trades */}
|
||||
{result.trades.recent.length > 0 && (
|
||||
<div style={{ background: '#1a1a2e', borderRadius: '8px', padding: '16px' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>📋 Recent Trades (last 10)</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<div className={cardClass}>
|
||||
<h3 className="mb-3 text-text-heading">📋 Recent Trades (last 10)</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #333' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>Dir</th>
|
||||
<th style={thStyle}>Entry</th>
|
||||
<th style={thStyle}>Exit</th>
|
||||
<th style={thStyle}>PnL</th>
|
||||
<th style={thStyle}>Reason</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>Dir</th>
|
||||
<th className={thClass}>Entry</th>
|
||||
<th className={thClass}>Exit</th>
|
||||
<th className={thClass}>PnL</th>
|
||||
<th className={thClass}>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.trades.recent.slice().reverse().map((t, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={tdStyle}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
||||
<td style={tdStyle}>{t.direction}</td>
|
||||
<td style={tdStyle}>${t.entry_price.toFixed(2)}</td>
|
||||
<td style={tdStyle}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ ...tdStyle, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className={tdClass}>{t.entry_time?.slice(5, 16) || '-'}</td>
|
||||
<td className={tdClass}>{t.direction}</td>
|
||||
<td className={tdClass}>${t.entry_price.toFixed(2)}</td>
|
||||
<td className={tdClass}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td className={`${tdClass} ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>
|
||||
${t.pnl?.toFixed(2) || '0.00'}
|
||||
</td>
|
||||
<td style={tdStyle}>{t.exit_reason || '-'}</td>
|
||||
<td className={tdClass}>{t.exit_reason || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -297,27 +289,11 @@ export default function BacktestPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '6px 10px', borderRadius: '6px', border: '1px solid #333',
|
||||
background: '#1a1a2e', color: '#e0e0e0', fontSize: '14px',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '8px 12px', color: '#888', fontWeight: 500,
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
};
|
||||
|
||||
function SummaryCard({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1a2e', borderRadius: '8px', padding: '14px',
|
||||
border: '1px solid #0f3460',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>{label}</div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 700, color: color || '#e0e0e0' }}>{value}</div>
|
||||
<div className="rounded-lg border border-border-default bg-bg-surface p-3.5">
|
||||
<div className="mb-1 text-xs text-text-secondary">{label}</div>
|
||||
<div className={`text-xl font-bold ${color || 'text-text-primary'}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import {
|
||||
createChart,
|
||||
createSeriesMarkers,
|
||||
@@ -297,18 +298,12 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
|
||||
setError(null);
|
||||
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Fetch candles — use query params to avoid encoding issues
|
||||
const candleRes = await fetch(
|
||||
`/api/v1/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
|
||||
{ headers }
|
||||
const candleResponse = await apiFetch<any>(
|
||||
`/symbols/candles?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}&limit=200`,
|
||||
);
|
||||
if (!candleRes.ok) throw new Error(`Candle fetch error: ${candleRes.status}`);
|
||||
const candleResponse = await candleRes.json();
|
||||
// API returns {candles: [...], cursor: "...", has_more: bool} — unwrap it
|
||||
const rawCandles: any[] = candleResponse.candles || candleResponse || [];
|
||||
// Reverse so oldest candle comes first (lightweight-charts needs ASC order)
|
||||
@@ -353,12 +348,9 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
|
||||
}
|
||||
|
||||
// Fetch indicators
|
||||
const indicatorsRes = await fetch(
|
||||
`/api/v1/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
|
||||
{ headers }
|
||||
const indicatorData = await apiFetch<IndicatorResponse>(
|
||||
`/symbols/indicators?symbol=${symbol}&exchange=${exchange}&timeframe=${timeframe}`,
|
||||
);
|
||||
if (!indicatorsRes.ok) throw new Error(`Indicator fetch error: ${indicatorsRes.status}`);
|
||||
const indicatorData: IndicatorResponse = await indicatorsRes.json();
|
||||
|
||||
// Helper to build time-annotated line data from indicator array
|
||||
function buildLineData(values: number[], times: Candle[]): LineData<UTCTimestamp>[] {
|
||||
@@ -543,46 +535,18 @@ export default function ChartContainer({ symbol, exchange, timeframe, onPriceUpd
|
||||
}, [symbol, exchange, timeframe, onPriceUpdate]);
|
||||
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
{loading && <div style={styles.overlay}>Loading chart data...</div>}
|
||||
{error && <div style={styles.errorOverlay}>Error: {error}</div>}
|
||||
<div ref={chartContainerRef} style={styles.chartContainer} />
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{loading && (
|
||||
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-bg-primary/80 px-4 py-2 text-sm text-text-secondary">
|
||||
Loading chart data...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-bg-primary/80 px-4 py-2 text-sm text-red-alt">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<div ref={chartContainerRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
wrapper: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
chartContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#8b949e',
|
||||
fontSize: 14,
|
||||
zIndex: 10,
|
||||
background: '#0d1117cc',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
},
|
||||
errorOverlay: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
color: '#ef5350',
|
||||
fontSize: 14,
|
||||
zIndex: 10,
|
||||
background: '#0d1117cc',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
|
||||
interface Exchange {
|
||||
id: number;
|
||||
@@ -27,6 +28,8 @@ interface Props {
|
||||
|
||||
const TIMEFRAMES = ['15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
|
||||
const selectClass = 'h-6 w-full min-w-0 max-w-none rounded-md border border-border-default bg-bg-primary px-2 text-[11px] leading-4 text-text-primary outline-none cursor-pointer md:w-auto md:min-w-[100px] md:max-w-[140px]';
|
||||
|
||||
export default function ChartToolbar({
|
||||
exchanges,
|
||||
symbol,
|
||||
@@ -39,14 +42,6 @@ export default function ChartToolbar({
|
||||
const [symbols, setSymbols] = useState<SymbolData[]>([]);
|
||||
const [loadingSymbols, setLoadingSymbols] = useState(false);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Fetch symbols when exchange changes
|
||||
useEffect(() => {
|
||||
if (exchange) {
|
||||
@@ -57,12 +52,7 @@ export default function ChartToolbar({
|
||||
async function fetchSymbols(exch: string) {
|
||||
setLoadingSymbols(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/symbols?exchange=${exch}&active_only=true`,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` } }
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to fetch symbols');
|
||||
const data: SymbolData[] = await res.json();
|
||||
const data = await apiFetch<SymbolData[]>(`/symbols?exchange=${exch}&active_only=true`);
|
||||
setSymbols(data);
|
||||
} catch (err) {
|
||||
console.error('fetchSymbols error:', err);
|
||||
@@ -72,13 +62,13 @@ export default function ChartToolbar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.toolbar, gap: isMobile ? 8 : 16 }}>
|
||||
<div className="flex flex-row flex-wrap items-center gap-2 border-b border-border-default bg-bg-surface px-4 py-1.5 md:gap-4">
|
||||
{/* Exchange + Symbol row */}
|
||||
<div style={{ ...styles.selectRow, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 6 : 12 }}>
|
||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
||||
<label style={styles.label}>Exchange</label>
|
||||
<div className="flex flex-col flex-wrap items-center gap-1.5 md:flex-row md:gap-3">
|
||||
<div className="flex w-full items-center gap-1.5 md:w-auto">
|
||||
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Exchange</label>
|
||||
<select
|
||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
||||
className={selectClass}
|
||||
value={exchange}
|
||||
onChange={(e) => onExchangeChange(e.target.value)}
|
||||
>
|
||||
@@ -90,10 +80,10 @@ export default function ChartToolbar({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.group, width: isMobile ? '100%' : 'auto' }}>
|
||||
<label style={styles.label}>Symbol</label>
|
||||
<div className="flex w-full items-center gap-1.5 md:w-auto">
|
||||
<label className="shrink-0 text-[11px] font-medium uppercase tracking-wide text-text-secondary">Symbol</label>
|
||||
<select
|
||||
style={{ ...styles.select, width: isMobile ? '100%' : 'auto', minWidth: isMobile ? 0 : 100, maxWidth: isMobile ? 'none' : 140 }}
|
||||
className={selectClass}
|
||||
value={symbol}
|
||||
onChange={(e) => onSymbolChange(e.target.value)}
|
||||
disabled={loadingSymbols}
|
||||
@@ -108,19 +98,18 @@ export default function ChartToolbar({
|
||||
</div>
|
||||
|
||||
{/* Timeframe - horizontal scroll on mobile */}
|
||||
<div style={{ ...styles.timeframeSection, flex: isMobile ? 'none' : 1 }}>
|
||||
<label style={styles.label}>TF</label>
|
||||
<div style={styles.timeframeScroll} className="scrollable-x">
|
||||
<div style={styles.timeframeGroup}>
|
||||
<div className="flex min-w-0 flex-none items-center gap-1.5 md:flex-1">
|
||||
<label className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">TF</label>
|
||||
<div className="scrollable-x min-w-0 flex-1 overflow-x-auto overflow-y-hidden pb-0.5">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{TIMEFRAMES.map((tf) => (
|
||||
<button
|
||||
key={tf}
|
||||
style={{
|
||||
...styles.timeframeButton,
|
||||
...(tf === timeframe ? styles.timeframeActive : {}),
|
||||
padding: isMobile ? '2px 6px' : '3px 8px',
|
||||
fontSize: isMobile ? 10 : 11,
|
||||
}}
|
||||
className={`h-6 min-w-8 shrink-0 whitespace-nowrap rounded border px-1.5 py-0.5 text-center align-middle text-[10px] font-medium leading-4 transition-colors md:px-2 md:text-[11px] ${
|
||||
tf === timeframe
|
||||
? 'border-accent-blue bg-accent-blue text-text-heading'
|
||||
: 'border-transparent bg-transparent text-text-secondary'
|
||||
}`}
|
||||
onClick={() => onTimeframeChange(tf)}
|
||||
>
|
||||
{tf}
|
||||
@@ -132,96 +121,3 @@ export default function ChartToolbar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '6px 16px',
|
||||
background: '#161b22',
|
||||
borderBottom: '1px solid #30363d',
|
||||
flexWrap: 'wrap',
|
||||
// Responsive: on small screens, items stack
|
||||
flexDirection: 'row' as const,
|
||||
},
|
||||
selectRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
group: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
select: {
|
||||
padding: '3px 8px',
|
||||
fontSize: 11,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
minWidth: 100,
|
||||
maxWidth: 140,
|
||||
height: 24,
|
||||
boxSizing: 'border-box' as const,
|
||||
lineHeight: '16px',
|
||||
},
|
||||
timeframeSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
timeframeScroll: {
|
||||
overflowX: 'auto' as const,
|
||||
overflowY: 'hidden',
|
||||
WebkitOverflowScrolling: 'touch' as any,
|
||||
scrollBehavior: 'smooth',
|
||||
paddingBottom: 2,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
timeframeGroup: {
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
timeframeButton: {
|
||||
padding: '3px 6px',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
background: 'transparent',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
minWidth: 32,
|
||||
textAlign: 'center' as const,
|
||||
height: 24,
|
||||
lineHeight: '16px',
|
||||
boxSizing: 'border-box' as const,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
timeframeActive: {
|
||||
color: '#f0f6fc',
|
||||
background: '#1f6feb',
|
||||
borderColor: '#1f6feb',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,17 +32,7 @@ interface BalanceResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ── Styles (kept from original) ──
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
page: { minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' },
|
||||
nav: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 },
|
||||
grid: { display: 'grid', gridTemplateColumns: '1fr 340px', gap: 0, height: 'calc(100vh - 44px)' },
|
||||
sidePanel: { background: '#0d1117', borderLeft: '1px solid #30363d', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
|
||||
mainContent: { display: 'flex', flexDirection: 'column', overflow: 'hidden' },
|
||||
btn: { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 },
|
||||
activeBtn: { padding: '6px 16px', fontSize: 12, borderRadius: 6, background: '#1f6feb', color: '#fff', border: '1px solid #1f6feb', cursor: 'pointer' },
|
||||
sectionTitle: { fontSize: 12, fontWeight: 600, color: '#8b949e', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
|
||||
};
|
||||
const navBtnClass = 'min-h-9 touch-manipulation rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary cursor-pointer';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -133,101 +123,65 @@ export default function DashboardPage() {
|
||||
const { t } = useT();
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||
{/* ─── NAV ─── */}
|
||||
<nav style={styles.nav}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }} onClick={() => navigate('/')}>
|
||||
<span style={{ fontSize: 18 }}>📊</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>{t('nav.tradingPortal')}</span>
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||
<div className="flex cursor-pointer items-center gap-3" onClick={() => navigate('/')}>
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="text-base font-bold text-text-heading">{t('nav.tradingPortal')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Hamburger: toggle side panel */}
|
||||
<button
|
||||
onClick={() => { setShowSidePanel(!showSidePanel); setShowNavDropdown(false); }}
|
||||
style={{
|
||||
...styles.btn,
|
||||
fontSize: 18,
|
||||
padding: '8px 14px',
|
||||
lineHeight: 1,
|
||||
minHeight: 36,
|
||||
minWidth: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
className="flex min-h-9 min-w-9 touch-manipulation items-center justify-center rounded-md border border-border-default bg-bg-hover text-lg leading-none text-text-primary"
|
||||
title={t('nav.panelToggle')}
|
||||
>
|
||||
{showSidePanel ? '✕' : '☰'}
|
||||
</button>
|
||||
{/* Dropdown toggle for nav links */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNavDropdown(!showNavDropdown)}
|
||||
style={{
|
||||
...styles.btn,
|
||||
fontSize: 16,
|
||||
padding: '8px 14px',
|
||||
lineHeight: 1,
|
||||
minHeight: 36,
|
||||
minWidth: 36,
|
||||
touchAction: 'manipulation',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
background: showNavDropdown ? '#1f6feb' : '#21262d',
|
||||
borderColor: showNavDropdown ? '#1f6feb' : '#30363d',
|
||||
}}
|
||||
className={`flex min-h-9 min-w-9 touch-manipulation items-center justify-center gap-1 rounded-md border px-3.5 py-2 text-base leading-none ${showNavDropdown ? 'border-accent-blue bg-accent-blue' : 'border-border-default bg-bg-hover'}`}
|
||||
title="Menu"
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>☰</span>
|
||||
<span style={{ fontSize: 10 }}>▼</span>
|
||||
<span className="text-sm">☰</span>
|
||||
<span className="text-[10px]">▼</span>
|
||||
</button>
|
||||
{showNavDropdown && (
|
||||
<>
|
||||
{/* Backdrop to close on outside click */}
|
||||
<div
|
||||
onClick={() => setShowNavDropdown(false)}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
zIndex: 90, background: 'transparent',
|
||||
}}
|
||||
className="fixed inset-0 z-[90] bg-transparent"
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0,
|
||||
marginTop: 4, zIndex: 100,
|
||||
background: '#161b22', border: '1px solid #30363d',
|
||||
borderRadius: 8, padding: 4,
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
minWidth: 160,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<div className="absolute right-0 top-full z-[100] mt-1 flex min-w-[160px] flex-col gap-0.5 rounded-lg border border-border-default bg-bg-surface p-1 shadow-[0_8px_24px_rgba(0,0,0,0.5)]">
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
|
||||
onClick={() => { navigate('/profile'); setShowNavDropdown(false); }}
|
||||
>
|
||||
{t('nav.profile', { name: userName })}
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.is_admin) && (
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
|
||||
onClick={() => { navigate('/admin'); setShowNavDropdown(false); }}
|
||||
>
|
||||
🛡️ Admin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%' }}
|
||||
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-text-primary"
|
||||
onClick={() => { navigate('/analytics'); setShowNavDropdown(false); }}
|
||||
>
|
||||
📈 Analytics
|
||||
</button>
|
||||
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||
<div className="my-0.5 h-px bg-border-default" />
|
||||
<button
|
||||
style={{ ...styles.btn, textAlign: 'left', padding: '10px 14px', fontSize: 13, minHeight: 36, border: 'none', background: 'transparent', width: '100%', color: '#f85149' }}
|
||||
className="min-h-9 w-full rounded-md border-none bg-transparent px-3.5 py-2.5 text-left text-[13px] text-red"
|
||||
onClick={async () => {
|
||||
setShowNavDropdown(false);
|
||||
await dispatch(logoutThunk());
|
||||
@@ -245,25 +199,19 @@ export default function DashboardPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/profile')}>
|
||||
<button className={navBtnClass} onClick={() => navigate('/profile')}>
|
||||
{t('nav.profile', { name: userName })}
|
||||
</button>
|
||||
{(user?.role === 'admin' || user?.is_admin) && (
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/admin')}>
|
||||
<button className={navBtnClass} onClick={() => navigate('/admin')}>
|
||||
🛡️ Admin
|
||||
</button>
|
||||
)}
|
||||
<button style={{ ...styles.btn, touchAction: 'manipulation', minHeight: isMobile ? 36 : undefined }} onClick={() => navigate('/analytics')}>
|
||||
<button className={navBtnClass} onClick={() => navigate('/analytics')}>
|
||||
📈 Analytics
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...styles.btn,
|
||||
touchAction: 'manipulation',
|
||||
minHeight: isMobile ? 36 : undefined,
|
||||
color: '#f85149',
|
||||
borderColor: '#f8514933',
|
||||
}}
|
||||
className={`${navBtnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
@@ -278,10 +226,10 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ─── GRID LAYOUT: 1fr (main) | 280px (side) ─── */}
|
||||
<div style={{ ...styles.grid, gridTemplateColumns: isMobile ? '1fr' : '1fr 340px' }}>
|
||||
{/* ─── GRID LAYOUT: 1fr (main) | 340px (side) ─── */}
|
||||
<div className="grid h-[calc(100vh-44px)] grid-cols-1 gap-0 md:grid-cols-[1fr_340px]">
|
||||
{/* ═══ MAIN CONTENT (LEFT) ═══ */}
|
||||
<div style={styles.mainContent}>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
{/* Toolbar: exchange / symbol / timeframe selectors */}
|
||||
<ChartToolbar
|
||||
exchanges={exchanges}
|
||||
@@ -294,7 +242,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
|
||||
{/* Chart area: fills remaining vertical space */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{symbol ? (
|
||||
<ChartContainer
|
||||
symbol={symbol}
|
||||
@@ -303,14 +251,7 @@ export default function DashboardPage() {
|
||||
onPriceUpdate={setLastPrice}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#484f58',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-dim">
|
||||
Select a symbol from watchlist
|
||||
</div>
|
||||
)}
|
||||
@@ -318,12 +259,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Signal panel: below chart, only when symbol selected */}
|
||||
{symbol && (
|
||||
<div style={{
|
||||
borderTop: '1px solid #30363d',
|
||||
height: isMobile ? 200 : 280,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div className={`min-h-0 overflow-hidden border-t border-border-default ${isMobile ? 'h-[200px]' : 'h-[280px]'}`}>
|
||||
<SignalPanel symbol={symbol} />
|
||||
</div>
|
||||
)}
|
||||
@@ -331,7 +267,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ═══ SIDE PANEL (RIGHT) ═══ */}
|
||||
{!isMobile && (
|
||||
<div style={styles.sidePanel}>
|
||||
<div className="flex flex-col overflow-hidden border-l border-border-default bg-bg-primary">
|
||||
{/* Watchlist at top of side panel */}
|
||||
<WatchlistPanel
|
||||
currentSymbol={symbol}
|
||||
@@ -340,9 +276,9 @@ export default function DashboardPage() {
|
||||
/>
|
||||
|
||||
{/* Order panel below watchlist */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
|
||||
<div className="flex-1 overflow-y-auto border-t border-border-default">
|
||||
{user?.role === 'viewer' ? (
|
||||
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
|
||||
<div className="p-3 px-4 text-center text-xs text-text-dim">
|
||||
👁️ View-only mode — trading is disabled
|
||||
</div>
|
||||
) : (
|
||||
@@ -368,9 +304,9 @@ export default function DashboardPage() {
|
||||
onSymbolChange={(s) => { setSymbol(s); setShowSidePanel(false); }}
|
||||
exchange={exchange}
|
||||
/>
|
||||
<div style={{ flex: 1, overflowY: 'auto', borderTop: '1px solid #30363d' }}>
|
||||
<div className="flex-1 overflow-y-auto border-t border-border-default">
|
||||
{user?.role === 'viewer' ? (
|
||||
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#484f58', fontSize: 12 }}>
|
||||
<div className="p-3 px-4 text-center text-xs text-text-dim">
|
||||
👁️ View-only mode — trading is disabled
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -59,14 +59,6 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
}
|
||||
}, [lastPrice]);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Auto-update clock every second
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -114,63 +106,54 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
}
|
||||
};
|
||||
|
||||
const btnCursor = isConnected ? 'pointer' : 'not-allowed';
|
||||
const btnOpacity = isConnected ? 1 : 0.5;
|
||||
const disabledClass = isConnected ? 'cursor-pointer opacity-100' : 'cursor-not-allowed opacity-50';
|
||||
|
||||
return (
|
||||
<div style={{ ...styles.panel, padding: isMobile ? '8px 12px' : '16px 20px' }}>
|
||||
<div className="flex flex-col gap-5 px-3 py-2 md:px-5 md:py-4">
|
||||
{/* Symbol info */}
|
||||
<div style={styles.symbolSection}>
|
||||
<h2 style={{ ...styles.symbolTitle, fontSize: isMobile ? 18 : 22 }}>{symbol}</h2>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginBottom: 8 }}>
|
||||
Ordering <strong style={{ color: '#8b949e' }}>{baseToken}</strong> with <strong style={{ color: '#8b949e' }}>{quoteToken}</strong>
|
||||
<div className="border-b border-border-default pb-4">
|
||||
<h2 className="mb-2 text-lg font-semibold text-text-heading md:text-[22px]">{symbol}</h2>
|
||||
<div className="mb-2 text-[11px] text-text-dim">
|
||||
Ordering <strong className="text-text-secondary">{baseToken}</strong> with <strong className="text-text-secondary">{quoteToken}</strong>
|
||||
</div>
|
||||
<div style={styles.priceRow}>
|
||||
<span style={styles.priceLabel}>Last Price</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-text-secondary">Last Price</span>
|
||||
{lastPrice !== null ? (
|
||||
<span style={{
|
||||
...styles.priceValue,
|
||||
fontSize: isMobile ? 15 : 18,
|
||||
color: priceDirection === 'up' ? '#26a69a' : priceDirection === 'down' ? '#ef5350' : '#26a69a',
|
||||
}}>
|
||||
<span className={`text-[15px] font-semibold md:text-lg ${priceDirection === 'down' ? 'text-red-alt' : 'text-green-alt'}`}>
|
||||
{priceDirection === 'up' ? '▲ ' : priceDirection === 'down' ? '▼ ' : '● '}
|
||||
{formatPrice(lastPrice)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ ...styles.priceLoading, fontSize: isMobile ? 13 : 14 }}>Loading price...</span>
|
||||
<span className="text-[13px] italic text-text-secondary md:text-sm">Loading price...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time + Balance row (50/50) - stacks on mobile */}
|
||||
<div style={{ ...styles.infoRow, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<div style={styles.infoBlock}>
|
||||
<h3 style={styles.sectionTitle}>Time</h3>
|
||||
<div style={{ ...styles.clock, fontSize: isMobile ? 14 : 15 }}>{currentTime.toLocaleTimeString()}</div>
|
||||
<div style={styles.date}>
|
||||
<div className="flex flex-col items-stretch overflow-hidden rounded-lg border border-border-default bg-bg-surface md:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Time</h3>
|
||||
<div className="mb-1 font-mono text-sm text-text-primary md:text-[15px]">{currentTime.toLocaleTimeString()}</div>
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{currentTime.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...styles.infoDivider, width: isMobile ? 'auto' : 1, height: isMobile ? 1 : 'auto', margin: isMobile ? '6px 0' : 0 }} />
|
||||
<div style={styles.infoBlock}>
|
||||
<h3 style={styles.sectionTitle}>Account</h3>
|
||||
<div style={{
|
||||
...styles.balanceValue,
|
||||
fontSize: isMobile ? 14 : 15,
|
||||
color: (balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? '#3fb950' : '#8b949e',
|
||||
}}>
|
||||
<div className="mx-0 my-1.5 h-px w-auto shrink-0 bg-border-default md:mx-0 md:my-0 md:h-auto md:w-px" />
|
||||
<div className="flex flex-1 flex-col justify-center px-3.5 py-3">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Account</h3>
|
||||
<div className={`mb-0.5 text-sm font-semibold md:text-[15px] ${(balance?.find(b => b.asset === 'USDT')?.total ?? 0) > 0 ? 'text-green' : 'text-text-secondary'}`}>
|
||||
${(balance?.find(b => b.asset === 'USDT')?.total ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div style={styles.balanceLabel}>Available</div>
|
||||
<div className="text-xs text-text-secondary">Available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Order */}
|
||||
<div style={styles.section}>
|
||||
<h3 style={styles.sectionTitle}>Quick Order · {baseToken}/{quoteToken}</h3>
|
||||
<div className="border-b border-border-default pb-4">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-text-secondary">Quick Order · {baseToken}/{quoteToken}</h3>
|
||||
|
||||
{/* Amount input */}
|
||||
<div style={styles.inputRow}>
|
||||
<div className="mb-2">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -178,25 +161,25 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
placeholder={`Amount in ${quoteToken}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ ...styles.amountInput, padding: isMobile ? '8px 10px' : '10px 12px', fontSize: isMobile ? 13 : 14 }}
|
||||
className="box-border w-full rounded-md border border-border-default bg-bg-primary px-2.5 py-2 font-sans text-[13px] text-text-primary outline-none md:px-3 md:py-2.5 md:text-sm"
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: '#484f58', marginTop: 4, fontStyle: 'italic' }}>
|
||||
<div className="mt-1 text-[11px] italic text-text-dim">
|
||||
{quoteToken} amount · Buy = spend {quoteToken} · Sell = sell {baseToken}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy / Sell buttons */}
|
||||
<div style={styles.orderButtons}>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
style={{ ...styles.buyButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
|
||||
className={`flex-1 rounded-md border-none py-2 text-[13px] font-semibold text-white md:py-2.5 md:text-sm ${disabledClass} bg-green-alt`}
|
||||
disabled={!isConnected || orderLoading}
|
||||
onClick={() => placeOrder('buy')}
|
||||
>
|
||||
{orderLoading ? 'Placing...' : `Buy ${baseToken}`}
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.sellButton, padding: isMobile ? '8px 0' : '10px 0', fontSize: isMobile ? 13 : 14, cursor: btnCursor, opacity: btnOpacity }}
|
||||
className={`flex-1 rounded-md border-none py-2 text-[13px] font-semibold text-white md:py-2.5 md:text-sm ${disabledClass} bg-red-alt`}
|
||||
disabled={!isConnected || orderLoading}
|
||||
onClick={() => placeOrder('sell')}
|
||||
>
|
||||
@@ -206,40 +189,40 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
|
||||
{/* Connection hint */}
|
||||
{!isConnected && (
|
||||
<p style={styles.placeholder}>Connect exchange API in Profile → API Keys to enable trading</p>
|
||||
<p className="mt-1.5 text-center text-[11px] italic text-text-dim">Connect exchange API in Profile → API Keys to enable trading</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{orderSuccess && (
|
||||
<div style={styles.successBanner}>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-green-alt bg-[#0a2e29] px-2.5 py-2 text-xs text-green-alt">
|
||||
✅ {orderSuccess}
|
||||
<button style={styles.dismissBtn} onClick={() => setOrderSuccess(null)}>✕</button>
|
||||
<button className="ml-2 border-none bg-transparent p-0 text-sm leading-none text-inherit" onClick={() => setOrderSuccess(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{orderError && (
|
||||
<div style={styles.errorBanner}>
|
||||
<div className="mt-2 flex items-center justify-between rounded-md border border-red bg-[#2d0e0e] px-2.5 py-2 text-xs text-red">
|
||||
❌ {orderError}
|
||||
<button style={styles.dismissBtn} onClick={() => setOrderError(null)}>✕</button>
|
||||
<button className="ml-2 border-none bg-transparent p-0 text-sm leading-none text-inherit" onClick={() => setOrderError(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* P1-21: Confirmation dialog before placing real trade */}
|
||||
{pendingOrder && (
|
||||
<div style={styles.confirmOverlay}>
|
||||
<div style={styles.confirmDialog}>
|
||||
<div style={{ fontSize: 14, color: '#f0f6fc', marginBottom: 8 }}>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60">
|
||||
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6 text-center">
|
||||
<div className="mb-2 text-sm text-text-heading">
|
||||
⚠️ Confirm {pendingOrder.side.toUpperCase()} order?
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#8b949e', marginBottom: 12 }}>
|
||||
<div className="mb-3 text-xs text-text-secondary">
|
||||
{pendingOrder.amount} {quoteToken} {pendingOrder.side === 'buy' ? '→' : '←'} {symbol}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button style={styles.confirmBtn} onClick={confirmOrder}>
|
||||
<div className="flex gap-2">
|
||||
<button className="cursor-pointer rounded-md border-none bg-[#238636] px-4 py-2 text-[13px] font-semibold text-white" onClick={confirmOrder}>
|
||||
✅ {pendingOrder.side === 'buy' ? 'Buy' : 'Sell'}
|
||||
</button>
|
||||
<button style={styles.cancelBtn} onClick={() => setPendingOrder(null)}>
|
||||
<button className="cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-2 text-[13px] text-text-primary" onClick={() => setPendingOrder(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -250,220 +233,3 @@ export default function OrderPanel({ symbol, exchange, lastPrice, balance, isCon
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
},
|
||||
symbolSection: {
|
||||
paddingBottom: 16,
|
||||
borderBottom: '1px solid #30363d',
|
||||
},
|
||||
symbolTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: '0 0 8px',
|
||||
},
|
||||
priceRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
priceLabel: {
|
||||
fontSize: 13,
|
||||
color: '#8b949e',
|
||||
},
|
||||
priceValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: '#26a69a',
|
||||
},
|
||||
priceLoading: {
|
||||
fontSize: 13,
|
||||
color: '#8b949e',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
section: {
|
||||
paddingBottom: 16,
|
||||
borderBottom: '1px solid #30363d',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.5px',
|
||||
margin: '0 0 8px',
|
||||
},
|
||||
infoRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
gap: 0,
|
||||
background: '#161b22',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #30363d',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
infoBlock: {
|
||||
flex: 1,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoDivider: {
|
||||
width: 1,
|
||||
background: '#30363d',
|
||||
flexShrink: 0,
|
||||
},
|
||||
clock: {
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 4,
|
||||
},
|
||||
date: {
|
||||
fontSize: 11,
|
||||
color: '#8b949e',
|
||||
},
|
||||
balanceValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
marginBottom: 2,
|
||||
},
|
||||
balanceLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
},
|
||||
inputRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
amountInput: {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box' as const,
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
orderButtons: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
buyButton: {
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: '#26a69a',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.5,
|
||||
},
|
||||
sellButton: {
|
||||
flex: 1,
|
||||
padding: '10px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: '#ef5350',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.5,
|
||||
},
|
||||
placeholder: {
|
||||
fontSize: 11,
|
||||
color: '#484f58',
|
||||
textAlign: 'center',
|
||||
margin: '6px 0 0',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
successBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 12,
|
||||
color: '#26a69a',
|
||||
background: '#0a2e29',
|
||||
border: '1px solid #26a69a',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
marginTop: 8,
|
||||
},
|
||||
errorBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 12,
|
||||
color: '#f85149',
|
||||
background: '#2d0e0e',
|
||||
border: '1px solid #f85149',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
marginTop: 8,
|
||||
},
|
||||
dismissBtn: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
padding: 0,
|
||||
marginLeft: 8,
|
||||
},
|
||||
// P1-21: Order confirmation dialog styles
|
||||
confirmOverlay: {
|
||||
position: 'fixed' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
confirmDialog: {
|
||||
background: '#161b22',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 360,
|
||||
width: '90%',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
confirmBtn: {
|
||||
background: '#238636',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
},
|
||||
cancelBtn: {
|
||||
background: '#21262d',
|
||||
color: '#c9d1d9',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,13 +14,6 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalPnl, setTotalPnl] = useState<number | null>(null);
|
||||
const [winRate, setWinRate] = useState<number | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -48,24 +41,28 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
}, [loadData]);
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header + Summary */}
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.title}>📊 Signals & Trades</h3>
|
||||
<button style={styles.refreshBtn} onClick={loadData} disabled={loading}>
|
||||
<div className="flex items-center justify-between px-4 pb-2 pt-3">
|
||||
<h3 className="m-0 text-sm font-semibold text-text-heading">📊 Signals & Trades</h3>
|
||||
<button
|
||||
className="flex h-9 min-h-9 w-9 min-w-9 touch-manipulation items-center justify-center rounded-md border border-border-default bg-bg-surface text-sm text-text-secondary"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PnL Summary */}
|
||||
{totalPnl !== null && (
|
||||
<div style={styles.summary}>
|
||||
<span style={styles.summaryLabel}>Total P&L</span>
|
||||
<span style={{ ...styles.summaryValue, color: totalPnl >= 0 ? '#3fb950' : '#f85149' }}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border-muted px-4 pb-2 pt-1">
|
||||
<span className="text-xs text-text-secondary">Total P&L</span>
|
||||
<span className={`font-mono text-[15px] font-bold ${totalPnl >= 0 ? 'text-green' : 'text-red'}`}>
|
||||
{formatPnL(totalPnl)}
|
||||
</span>
|
||||
{winRate !== null && (
|
||||
<span style={styles.winRate}>
|
||||
<span className="ml-auto text-xs text-text-secondary">
|
||||
Win Rate: {winRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
@@ -73,15 +70,19 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={styles.tabs}>
|
||||
<div className="flex border-b border-border-muted">
|
||||
<button
|
||||
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'signals' ? styles.tabActive : {}) }}
|
||||
className={`min-h-9 flex-1 touch-manipulation border-b-2 bg-transparent px-3 py-2 text-[10px] font-medium transition-colors md:text-xs ${
|
||||
activeTab === 'signals' ? 'border-accent text-accent' : 'border-transparent text-text-secondary'
|
||||
}`}
|
||||
onClick={() => setActiveTab('signals')}
|
||||
>
|
||||
Signals ({signals.length})
|
||||
</button>
|
||||
<button
|
||||
style={{ ...styles.tab, fontSize: isMobile ? 10 : 12, ...(activeTab === 'trades' ? styles.tabActive : {}) }}
|
||||
className={`min-h-9 flex-1 touch-manipulation border-b-2 bg-transparent px-3 py-2 text-[10px] font-medium transition-colors md:text-xs ${
|
||||
activeTab === 'trades' ? 'border-accent text-accent' : 'border-transparent text-text-secondary'
|
||||
}`}
|
||||
onClick={() => setActiveTab('trades')}
|
||||
>
|
||||
Trades ({trades.length})
|
||||
@@ -89,27 +90,27 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={styles.content}>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{activeTab === 'signals' ? (
|
||||
signals.length === 0 ? (
|
||||
<div style={styles.empty}>No signals yet — waiting for candle data</div>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-text-dim">No signals yet — waiting for candle data</div>
|
||||
) : (
|
||||
signals.map((sig) => {
|
||||
const cfg = SIGNAL_CONFIG[sig.signal_type] || { label: sig.signal_type, icon: '📊', color: '#8b949e', bg: 'transparent' };
|
||||
return (
|
||||
<div key={sig.id} style={styles.signalRow}>
|
||||
<div style={styles.signalLeft}>
|
||||
<span style={{ fontSize: 16 }}>{cfg.icon}</span>
|
||||
<div key={sig.id} className="flex cursor-pointer items-center justify-between border-b border-border-muted px-3 py-2 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{cfg.icon}</span>
|
||||
<div>
|
||||
<div style={{ ...styles.signalType, color: cfg.color }}>{cfg.label}</div>
|
||||
<div style={styles.signalMeta}>
|
||||
<div className="text-[13px] font-semibold" style={{ color: cfg.color }}>{cfg.label}</div>
|
||||
<div className="mt-px text-[11px] text-text-muted">
|
||||
{sig.symbol} · {sig.timeframe} · {sig.strength}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.signalRight}>
|
||||
<div style={styles.signalPrice}>${formatPrice(sig.price)}</div>
|
||||
<div style={styles.signalTime}>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-[13px] font-medium text-text-primary">${formatPrice(sig.price)}</div>
|
||||
<div className="text-[11px] text-text-dim">
|
||||
{new Date(sig.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,32 +120,31 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
)
|
||||
) : (
|
||||
trades.length === 0 ? (
|
||||
<div style={styles.empty}>No trades yet</div>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-text-dim">No trades yet</div>
|
||||
) : (
|
||||
trades.map((trade) => (
|
||||
<div key={trade.id} style={styles.tradeRow}>
|
||||
<div style={styles.tradeHeader}>
|
||||
<span style={{ ...styles.tradeDirection, color: trade.direction === 'LONG' ? '#3fb950' : '#f85149' }}>
|
||||
<div key={trade.id} className="border-b border-border-muted px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold ${trade.direction === 'LONG' ? 'text-green' : 'text-red'}`}>
|
||||
{trade.direction === 'LONG' ? '▲' : '▼'} {trade.direction}
|
||||
</span>
|
||||
<span style={styles.tradeSymbol}>{trade.symbol}</span>
|
||||
<span style={{
|
||||
...styles.tradeStatus,
|
||||
color: trade.status === 'OPEN' ? '#58a6ff' : (trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149'),
|
||||
}}>
|
||||
<span className="text-xs text-text-primary">{trade.symbol}</span>
|
||||
<span className={`ml-auto text-[11px] font-semibold ${
|
||||
trade.status === 'OPEN' ? 'text-accent' : (trade.pnl && trade.pnl > 0 ? 'text-green' : 'text-red')
|
||||
}`}>
|
||||
{trade.status === 'OPEN' ? '🟢 OPEN' : `🔒 ${formatPnL(trade.pnl)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.tradeDetails}>
|
||||
<div className="mt-1 flex flex-wrap gap-1 font-mono text-[11px] text-text-secondary">
|
||||
<span>Entry: ${formatPrice(trade.entry_price)}</span>
|
||||
{trade.exit_price && <span> → Exit: ${formatPrice(trade.exit_price)}</span>}
|
||||
{trade.pnl_percent !== null && (
|
||||
<span style={{ color: trade.pnl && trade.pnl > 0 ? '#3fb950' : '#f85149', fontWeight: 600 }}>
|
||||
<span className={`font-semibold ${trade.pnl && trade.pnl > 0 ? 'text-green' : 'text-red'}`}>
|
||||
{formatPnLPercent(trade.pnl_percent)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.tradeReason}>
|
||||
<div className="mt-0.5 text-[10px] text-text-dim">
|
||||
{trade.entry_reason && <span>📥 {trade.entry_reason}</span>}
|
||||
{trade.exit_reason && <span> 📤 {trade.exit_reason}</span>}
|
||||
</div>
|
||||
@@ -156,167 +156,3 @@ export default function SignalPanel({ symbol }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px 8px',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: 0,
|
||||
},
|
||||
refreshBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#161b22',
|
||||
color: '#8b949e',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'manipulation',
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '4px 16px 8px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
},
|
||||
winRate: {
|
||||
fontSize: 12,
|
||||
color: '#8b949e',
|
||||
marginLeft: 'auto' as const,
|
||||
},
|
||||
tabs: {
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: '#8b949e',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
},
|
||||
tabActive: {
|
||||
color: '#58a6ff',
|
||||
borderBottomColor: '#58a6ff',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto' as const,
|
||||
padding: '4px 0',
|
||||
},
|
||||
empty: {
|
||||
padding: '24px 16px',
|
||||
textAlign: 'center' as const,
|
||||
color: '#484f58',
|
||||
fontSize: 13,
|
||||
},
|
||||
signalRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
},
|
||||
signalLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
signalType: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
},
|
||||
signalMeta: {
|
||||
fontSize: 11,
|
||||
color: '#6e7681',
|
||||
marginTop: 1,
|
||||
},
|
||||
signalRight: {
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
signalPrice: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
},
|
||||
signalTime: {
|
||||
fontSize: 11,
|
||||
color: '#484f58',
|
||||
},
|
||||
tradeRow: {
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
tradeHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
tradeDirection: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
},
|
||||
tradeSymbol: {
|
||||
fontSize: 12,
|
||||
color: '#c9d1d9',
|
||||
},
|
||||
tradeStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
marginLeft: 'auto' as const,
|
||||
},
|
||||
tradeDetails: {
|
||||
fontSize: 11,
|
||||
color: '#8b949e',
|
||||
marginTop: 4,
|
||||
fontFamily: "'SF Mono', monospace",
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
tradeReason: {
|
||||
fontSize: 10,
|
||||
color: '#484f58',
|
||||
marginTop: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,14 +16,6 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signals, setSignals] = useState<Signal[]>([]);
|
||||
|
||||
// Responsive state
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Add symbol state
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -111,25 +103,24 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
const availableSymbols = symbols.filter((s) => !watchlistSymbolIds.has(s.id));
|
||||
|
||||
return (
|
||||
<div style={styles.panel}>
|
||||
<style>{`
|
||||
.wl-remove-btn:hover { opacity: 1 !important; color: #f85149 !important; background: rgba(248,81,73,0.1) !important; }
|
||||
.wl-item:hover .wl-remove-btn { opacity: 0.65 !important; }
|
||||
`}</style>
|
||||
<div className="flex flex-col border-b border-border-muted">
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<h3 style={styles.title}>👁️ Watchlist</h3>
|
||||
<button style={styles.addBtn} onClick={() => setShowAdd(!showAdd)}>
|
||||
<div className="flex items-center justify-between px-3.5 pb-1.5 pt-2.5">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-text-heading">👁️ Watchlist</h3>
|
||||
<button
|
||||
className="flex h-[26px] w-[26px] items-center justify-center rounded-md border border-border-default bg-bg-surface text-base font-semibold leading-none text-accent"
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
>
|
||||
{showAdd ? '✕' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Symbol Panel */}
|
||||
{showAdd && (
|
||||
<div style={styles.addPanel}>
|
||||
<div className="border-b border-border-muted px-3.5 pb-2.5 pt-1.5">
|
||||
<input
|
||||
ref={searchRef}
|
||||
style={styles.searchInput}
|
||||
className="mb-1.5 w-full rounded-md border border-border-default bg-bg-primary px-2.5 py-1.5 text-xs text-text-primary outline-none"
|
||||
placeholder="Search symbols..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
@@ -137,21 +128,21 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
searchSymbols(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
<div style={styles.symbolList}>
|
||||
{error && <div className="mb-1 text-[11px] text-red">{error}</div>}
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{searching ? (
|
||||
<div style={styles.loadingText}>Searching...</div>
|
||||
<div className="px-3.5 py-3 text-center text-xs text-text-dim">Searching...</div>
|
||||
) : availableSymbols.length === 0 ? (
|
||||
<div style={styles.emptyText}>No symbols found</div>
|
||||
<div className="px-3.5 py-3 text-center text-xs text-text-dim">No symbols found</div>
|
||||
) : (
|
||||
availableSymbols.slice(0, 30).map((sym) => (
|
||||
<button
|
||||
key={sym.id}
|
||||
style={styles.symbolOption}
|
||||
className="flex w-full items-center justify-between rounded border-none bg-transparent px-2 py-1.5 text-left text-xs text-text-primary"
|
||||
onClick={() => handleAdd(sym)}
|
||||
>
|
||||
<span style={styles.symName}>{sym.symbol}</span>
|
||||
<span style={styles.symExchange}>{exchange}</span>
|
||||
<span className="font-medium">{sym.symbol}</span>
|
||||
<span className="text-[10px] text-text-dim">{exchange}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -160,11 +151,11 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
)}
|
||||
|
||||
{/* Watchlist Items */}
|
||||
<div style={styles.list}>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
<div className="px-3.5 py-3 text-center text-xs text-text-dim">Loading...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={styles.emptyText}>
|
||||
<div className="px-3.5 py-3 text-center text-xs text-text-dim">
|
||||
No symbols watched yet. Click + to add.
|
||||
</div>
|
||||
) : (
|
||||
@@ -175,30 +166,26 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="wl-item"
|
||||
style={{
|
||||
...styles.item,
|
||||
...(isActive ? styles.itemActive : {}),
|
||||
padding: isMobile ? '5px 10px' : '7px 14px',
|
||||
}}
|
||||
className={`group flex cursor-pointer items-center justify-between border-l-[3px] px-3.5 py-1.5 transition-colors md:px-3.5 md:py-[7px] ${
|
||||
isActive ? 'border-l-accent bg-accent/[0.08]' : 'border-l-transparent'
|
||||
}`}
|
||||
onClick={() => onSymbolChange(item.symbol)}
|
||||
>
|
||||
{/* Symbol info */}
|
||||
<div style={styles.itemLeft}>
|
||||
<div style={{ ...styles.itemSymbol, fontSize: isMobile ? 12 : 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 180 }}>{item.symbol}</div>
|
||||
{item.label && <div style={styles.itemLabel}>{item.label}</div>}
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<div className="max-w-[180px] overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-primary md:text-[13px]">{item.symbol}</div>
|
||||
{item.label && <div className="text-[10px] text-text-muted">{item.label}</div>}
|
||||
</div>
|
||||
|
||||
{/* Signal indicator */}
|
||||
<div style={styles.itemRight}>
|
||||
<div className="flex items-center gap-1">
|
||||
{cfg && (
|
||||
<span style={{ fontSize: 14, marginRight: 4 }} title={cfg.label}>
|
||||
<span className="mr-1 text-sm" title={cfg.label}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="wl-remove-btn"
|
||||
style={styles.removeBtn}
|
||||
className="z-[5] flex h-5 w-5 items-center justify-center rounded border-none bg-transparent text-xs text-text-dim opacity-40 transition-opacity group-hover:opacity-65 hover:!opacity-100 hover:!text-red hover:!bg-red/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(item.id);
|
||||
@@ -216,148 +203,3 @@ export default function WatchlistPanel({ currentSymbol, onSymbolChange, exchange
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
panel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 14px 6px',
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#f0f6fc',
|
||||
margin: 0,
|
||||
},
|
||||
addBtn: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
background: '#161b22',
|
||||
color: '#58a6ff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
},
|
||||
addPanel: {
|
||||
padding: '6px 14px 10px',
|
||||
borderBottom: '1px solid #21262d',
|
||||
},
|
||||
searchInput: {
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
color: '#c9d1d9',
|
||||
background: '#0d1117',
|
||||
border: '1px solid #30363d',
|
||||
borderRadius: 6,
|
||||
outline: 'none',
|
||||
marginBottom: 6,
|
||||
},
|
||||
error: {
|
||||
fontSize: 11,
|
||||
color: '#f85149',
|
||||
marginBottom: 4,
|
||||
},
|
||||
symbolList: {
|
||||
maxHeight: 180,
|
||||
overflowY: 'auto' as const,
|
||||
},
|
||||
symbolOption: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '5px 8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#c9d1d9',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
textAlign: 'left' as const,
|
||||
},
|
||||
symName: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
symExchange: {
|
||||
fontSize: 10,
|
||||
color: '#484f58',
|
||||
},
|
||||
list: {
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto' as const,
|
||||
},
|
||||
loadingText: {
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
color: '#484f58',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
emptyText: {
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
color: '#484f58',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '7px 14px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: '3px solid transparent',
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
itemActive: {
|
||||
borderLeftColor: '#58a6ff',
|
||||
background: 'rgba(88, 166, 255, 0.08)',
|
||||
},
|
||||
itemLeft: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemSymbol: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#c9d1d9',
|
||||
},
|
||||
itemLabel: {
|
||||
fontSize: 10,
|
||||
color: '#6e7681',
|
||||
},
|
||||
itemRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
removeBtn: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#484f58',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
opacity: 0.4,
|
||||
transition: 'opacity 0.15s ease, color 0.15s ease, background 0.15s ease',
|
||||
zIndex: 5,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppSelector } from '../../app/hooks';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppSelector, useAppDispatch } from '../../app/hooks';
|
||||
import { logoutThunk } from '../auth/authSlice';
|
||||
import { apiFetch } from '../api/apiService';
|
||||
import { useT } from '../../translations';
|
||||
|
||||
@@ -11,10 +13,10 @@ function authFetch<T = any>(url: string, options: RequestInit = {}): Promise<T>
|
||||
return apiFetch<T>(apiUrl, options);
|
||||
}
|
||||
|
||||
const btnStyle: React.CSSProperties = { padding: '6px 16px', fontSize: 12, border: '1px solid #30363d', borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer', touchAction: 'manipulation', minHeight: 36 };
|
||||
const inputStyle: React.CSSProperties = { padding: '6px 10px', borderRadius: 6, border: '1px solid #30363d', background: '#0d1117', color: '#c9d1d9', fontSize: 13 };
|
||||
const labelStyle: React.CSSProperties = { fontSize: 11, color: '#8b949e', marginBottom: 4 };
|
||||
const cardStyle: React.CSSProperties = { background: '#161b22', borderRadius: 8, padding: 14, border: '1px solid #21262d' };
|
||||
const btnClass = 'min-h-9 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
const inputClass = 'rounded-md border border-border-default bg-bg-primary px-2.5 py-1.5 text-[13px] text-text-primary';
|
||||
const labelClass = 'mb-1 text-[11px] text-text-secondary';
|
||||
const cardClass = 'rounded-lg border border-border-muted bg-bg-surface p-3.5';
|
||||
|
||||
const EXCHANGES = ['binance', 'bybit', 'mexc', 'gate', 'bingx'];
|
||||
const TFS_BT = ['15m', '30m', '1h', '4h'];
|
||||
@@ -62,8 +64,14 @@ function formatTimeAgo(dateStr: string): string {
|
||||
|
||||
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div onClick={() => onChange(!value)} style={{ width: 40, height: 22, borderRadius: 11, cursor: 'pointer', position: 'relative', background: value ? '#238636' : '#21262d', transition: 'background 0.2s' }}>
|
||||
<div style={{ width: 18, height: 18, borderRadius: '50%', background: '#fff', position: 'absolute', top: 2, left: value ? 20 : 2, transition: 'all 0.2s' }} />
|
||||
<div
|
||||
onClick={() => onChange(!value)}
|
||||
className={`relative h-[22px] w-10 cursor-pointer rounded-full transition-colors ${value ? 'bg-[#238636]' : 'bg-bg-hover'}`}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-all"
|
||||
style={{ left: value ? 20 : 2 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,37 +100,37 @@ function InfoTab() {
|
||||
try { await authFetch('/api/v1/auth/change-password', { method: 'POST', body: JSON.stringify({ old_password: pwForm.old_password, new_password: pwForm.new_password }) }); setPwMsg('✅ Password changed'); setPwModal(false); setPwForm({ old_password: '', new_password: '', confirm: '' }); } catch (e: any) { setPwMsg('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (!profile) return <div style={{ color: '#ef4444' }}>❌ Không tải được profile</div>;
|
||||
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||
if (!profile) return <div className="text-red-alt">❌ Không tải được profile</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('✅') ? '#22c55e' : '#ef4444' }}>{msg}</div>}
|
||||
<div style={{ display: 'grid', gap: 10, maxWidth: 400 }}>
|
||||
<div><div style={labelStyle}>Username</div><input disabled value={profile.username} style={{ ...inputStyle, width: '100%', opacity: 0.6 }} /></div>
|
||||
{msg && <div className={`mb-2.5 ${msg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{msg}</div>}
|
||||
<div className="grid max-w-[400px] gap-2.5">
|
||||
<div><div className={labelClass}>Username</div><input disabled value={profile.username} className={`${inputClass} w-full opacity-60`} /></div>
|
||||
{editing ? (<>
|
||||
<div><div style={labelStyle}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={save} style={btnStyle}>💾 Save</button><button onClick={() => setEditing(false)} style={btnStyle}>Cancel</button></div>
|
||||
<div><div className={labelClass}>Display Name</div><input value={form.display_name} onChange={e => setForm({ ...form, display_name: e.target.value })} className={`${inputClass} w-full`} /></div>
|
||||
<div><div className={labelClass}>Email</div><input value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} className={`${inputClass} w-full`} /></div>
|
||||
<div className="flex gap-2"><button onClick={save} className={btnClass}>💾 Save</button><button onClick={() => setEditing(false)} className={btnClass}>Cancel</button></div>
|
||||
</>) : (<>
|
||||
<div><div style={labelStyle}>Display Name</div><div style={{ color: '#c9d1d9' }}>{profile.display_name || '-'}</div></div>
|
||||
<div><div style={labelStyle}>Email</div><div style={{ color: '#c9d1d9' }}>{profile.email}</div></div>
|
||||
<div><div style={labelStyle}>Role</div><div style={{ color: '#c9d1d9' }}>{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
|
||||
<div><div style={labelStyle}>Member since</div><div style={{ color: '#c9d1d9' }}>{profile.created_at?.slice(0, 10)}</div></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={() => setEditing(true)} style={btnStyle}>✏️ Edit</button><button onClick={() => setPwModal(true)} style={{ ...btnStyle, color: '#d29922', borderColor: '#d29922' }}>🔑 Change Password</button></div>
|
||||
<div><div className={labelClass}>Display Name</div><div className="text-text-primary">{profile.display_name || '-'}</div></div>
|
||||
<div><div className={labelClass}>Email</div><div className="text-text-primary">{profile.email}</div></div>
|
||||
<div><div className={labelClass}>Role</div><div className="text-text-primary">{profile.role} {profile.is_admin ? '👑' : ''}</div></div>
|
||||
<div><div className={labelClass}>Member since</div><div className="text-text-primary">{profile.created_at?.slice(0, 10)}</div></div>
|
||||
<div className="flex gap-2"><button onClick={() => setEditing(true)} className={btnClass}>✏️ Edit</button><button onClick={() => setPwModal(true)} className={`${btnClass} border-yellow text-yellow`}>🔑 Change Password</button></div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{pwModal && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#161b22', borderRadius: 12, padding: 24, border: '1px solid #30363d', maxWidth: 360, width: '90%' }}>
|
||||
<h3 style={{ color: '#f0f6fc', margin: '0 0 16px' }}>🔑 Change Password</h3>
|
||||
{pwMsg && <div style={{ marginBottom: 10, color: pwMsg.startsWith('✅') ? '#22c55e' : '#ef4444', fontSize: 12 }}>{pwMsg}</div>}
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div><div style={labelStyle}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div><div style={labelStyle}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} style={{ ...inputStyle, width: '100%' }} /></div>
|
||||
<div style={{ display: 'flex', gap: 8 }}><button onClick={changePw} style={{ ...btnStyle, background: '#d29922', color: '#000', borderColor: '#d29922' }}>Change Password</button><button onClick={() => setPwModal(false)} style={btnStyle}>Cancel</button></div>
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setPwModal(false); }} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60">
|
||||
<div className="w-[90%] max-w-[360px] rounded-xl border border-border-default bg-bg-surface p-6">
|
||||
<h3 className="mb-4 text-text-heading">🔑 Change Password</h3>
|
||||
{pwMsg && <div className={`mb-2.5 text-xs ${pwMsg.startsWith('✅') ? 'text-green-alt' : 'text-red-alt'}`}>{pwMsg}</div>}
|
||||
<div className="grid gap-2.5">
|
||||
<div><div className={labelClass}>Old Password</div><input type="password" value={pwForm.old_password} onChange={e => setPwForm({ ...pwForm, old_password: e.target.value })} className={`${inputClass} w-full`} /></div>
|
||||
<div><div className={labelClass}>New Password</div><input type="password" value={pwForm.new_password} onChange={e => setPwForm({ ...pwForm, new_password: e.target.value })} className={`${inputClass} w-full`} /></div>
|
||||
<div><div className={labelClass}>Confirm New Password</div><input type="password" value={pwForm.confirm} onChange={e => setPwForm({ ...pwForm, confirm: e.target.value })} className={`${inputClass} w-full`} /></div>
|
||||
<div className="flex gap-2"><button onClick={changePw} className={`${btnClass} border-yellow bg-yellow text-black`}>Change Password</button><button onClick={() => setPwModal(false)} className={btnClass}>Cancel</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,119 +187,119 @@ function KeysTab() {
|
||||
|
||||
const activeExchanges = keys.filter(k => k.is_active).map(k => k.exchange_name).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ marginBottom: 10, color: msg.startsWith('❌') ? '#ef4444' : '#22c55e', fontSize: 12 }}>{msg}</div>}
|
||||
<div style={{ marginBottom: 12, color: activeExchanges.length > 0 ? '#3fb950' : '#8b949e', fontWeight: 600 }}>
|
||||
{msg && <div className={`mb-2.5 text-xs ${msg.startsWith('❌') ? 'text-red-alt' : 'text-green-alt'}`}>{msg}</div>}
|
||||
<div className={`mb-3 font-semibold ${activeExchanges.length > 0 ? 'text-green' : 'text-text-secondary'}`}>
|
||||
{activeExchanges.length > 0 ? `🔗 Connected to ${activeExchanges.map(e => e?.toUpperCase()).join(', ')}` : '🔌 No exchanges connected'}
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => setShowAdd(!showAdd)} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' }}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
|
||||
<div className="mb-3">
|
||||
<button onClick={() => setShowAdd(!showAdd)} className={`${btnClass} border-accent-blue bg-accent-blue text-white`}>{showAdd ? 'Cancel' : '➕ Add API Key'}</button>
|
||||
</div>
|
||||
{showAdd && (
|
||||
<div style={{ ...cardStyle, marginBottom: 16, maxWidth: 400 }}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div><div style={labelStyle}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API key" /></div>
|
||||
<div><div style={labelStyle}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Enter API secret" /></div>
|
||||
<div><div style={labelStyle}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} style={{ ...inputStyle, width: '100%' }} placeholder="Exchange passphrase" /></div>
|
||||
<button onClick={addKey} style={{ ...btnStyle, background: '#22c55e', color: '#000', borderColor: '#22c55e' }}>💾 Save Key</button>
|
||||
<div className={`${cardClass} mb-4 max-w-[400px]`}>
|
||||
<div className="grid gap-2">
|
||||
<div><div className={labelClass}>Exchange</div><select value={form.exchange_name} onChange={e => setForm({ ...form, exchange_name: e.target.value })} className={`${inputClass} w-full cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div className={labelClass}>API Key</div><input value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} className={`${inputClass} w-full`} placeholder="Enter API key" /></div>
|
||||
<div><div className={labelClass}>API Secret</div><input type="password" value={form.api_secret} onChange={e => setForm({ ...form, api_secret: e.target.value })} className={`${inputClass} w-full`} placeholder="Enter API secret" /></div>
|
||||
<div><div className={labelClass}>Passphrase (optional)</div><input type="password" value={form.passphrase} onChange={e => setForm({ ...form, passphrase: e.target.value })} className={`${inputClass} w-full`} placeholder="Exchange passphrase" /></div>
|
||||
<button onClick={addKey} className={`${btnClass} border-green bg-green text-black`}>💾 Save Key</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{keys.length === 0 ? (
|
||||
<div style={{ color: '#8b949e' }}>Chưa có API key nào. Thêm key để bot có thể trade.</div>
|
||||
<div className="text-text-secondary">Chưa có API key nào. Thêm key để bot có thể trade.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div className="grid gap-2">
|
||||
{keys.map((k: any) => (
|
||||
<div key={k.id} style={{ ...cardStyle }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => toggleExpand(k.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#8b949e', fontSize: 10, transition: 'transform 0.2s', transform: expandedId === k.id ? 'rotate(90deg)' : 'rotate(0deg)' }}>▶</span>
|
||||
<span style={{ color: '#f0f6fc', fontWeight: 600 }}>{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
|
||||
<div key={k.id} className={cardClass}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 cursor-pointer" onClick={() => toggleExpand(k.id)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[10px] text-text-secondary transition-transform ${expandedId === k.id ? 'rotate-90' : 'rotate-0'}`}>▶</span>
|
||||
<span className="font-semibold text-text-heading">{k.exchange_name?.toUpperCase()} {k.is_testnet ? '🧪' : ''}</span>
|
||||
</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 12, marginLeft: 16 }}>Key: {k.api_key}</div>
|
||||
<div style={{ fontSize: 11, color: k.is_active ? '#3fb950' : '#484f58', marginTop: 2, marginLeft: 16 }}>
|
||||
<div className="ml-4 text-xs text-text-secondary">Key: {k.api_key}</div>
|
||||
<div className={`ml-4 mt-0.5 text-[11px] ${k.is_active ? 'text-green' : 'text-text-dim'}`}>
|
||||
{k.is_active ? '● Connected' : '● Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button onClick={() => testKey(k.id)} style={{ ...btnStyle, padding: '4px 10px', fontSize: 11 }}>🔍 Test</button>
|
||||
<button onClick={() => deleteKey(k.id)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', padding: '4px 10px', fontSize: 11 }}>🗑</button>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => testKey(k.id)} className={`${btnClass} px-2.5 py-1 text-[11px]`}>🔍 Test</button>
|
||||
<button onClick={() => deleteKey(k.id)} className={`${btnClass} border-red-alt px-2.5 py-1 text-[11px] text-red-alt`}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
{testResults[k.id] && (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: testResults[k.id].success ? '#3fb950' : '#ef4444' }}>
|
||||
<div className={`mt-1.5 text-[11px] ${testResults[k.id].success ? 'text-green' : 'text-red-alt'}`}>
|
||||
{testResults[k.id].success ? '✅' : '❌'} {testResults[k.id].message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable details: token balances + open orders */}
|
||||
{expandedId === k.id && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', borderTop: '1px solid #21262d', background: '#0d1117', borderRadius: '0 0 8px 8px' }}>
|
||||
<div className="mt-3 rounded-b-lg border-t border-border-muted bg-bg-primary px-3.5 py-2.5">
|
||||
{keyDetails[k.id]?.loading ? (
|
||||
<div style={{ color: '#8b949e', fontSize: 12 }}>⏳ Loading balances & orders...</div>
|
||||
<div className="text-xs text-text-secondary">⏳ Loading balances & orders...</div>
|
||||
) : keyDetails[k.id]?.error ? (
|
||||
<div style={{ color: '#f85149', fontSize: 12 }}>❌ {keyDetails[k.id].error}</div>
|
||||
<div className="text-xs text-red">❌ {keyDetails[k.id].error}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div className="grid gap-3">
|
||||
{/* Balances */}
|
||||
<div>
|
||||
<div style={{ color: '#58a6ff', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
|
||||
<div className="mb-1.5 text-xs font-semibold text-accent">
|
||||
💰 Balances — ≈ ${keyDetails[k.id]?.usdt_balance?.toFixed(2) || '0.00'} USDT
|
||||
</div>
|
||||
{keyDetails[k.id]?.balances?.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
|
||||
<div className="flex max-h-[150px] flex-wrap gap-1 overflow-y-auto">
|
||||
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).slice(0, 30).map((b: any) => (
|
||||
<span key={b.asset} style={{ padding: '3px 8px', background: '#21262d', borderRadius: 6, fontSize: 11, color: '#c9d1d9', border: '1px solid #30363d', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ color: '#58a6ff' }}>{b.asset}</span>{' '}
|
||||
<span style={{ color: '#8b949e' }}>{Number(b.total).toFixed(4)}</span>
|
||||
<span key={b.asset} className="whitespace-nowrap rounded-md border border-border-default bg-bg-hover px-2 py-0.5 text-[11px] text-text-primary">
|
||||
<span className="text-accent">{b.asset}</span>{' '}
|
||||
<span className="text-text-secondary">{Number(b.total).toFixed(4)}</span>
|
||||
</span>
|
||||
))}
|
||||
{keyDetails[k.id].balances.filter((b: any) => (b.total || 0) > 0).length === 0 && (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No non-zero balances</span>
|
||||
<span className="text-[11px] text-text-dim">No non-zero balances</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No balance data available</span>
|
||||
<span className="text-[11px] text-text-dim">No balance data available</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Orders */}
|
||||
<div>
|
||||
<div style={{ color: '#d29922', fontSize: 12, fontWeight: 600, marginBottom: 6 }}>
|
||||
<div className="mb-1.5 text-xs font-semibold text-yellow">
|
||||
📋 Open Orders — {keyDetails[k.id]?.orders?.length || 0}
|
||||
</div>
|
||||
{keyDetails[k.id]?.orders?.length > 0 ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Symbol</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Side</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Type</th>
|
||||
<th style={{ textAlign: 'right', padding: 2 }}>Amount</th>
|
||||
<th style={{ textAlign: 'right', padding: 2 }}>Price</th>
|
||||
<th style={{ textAlign: 'left', padding: 2 }}>Status</th>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<thead><tr className="border-b border-border-default text-text-secondary">
|
||||
<th className="p-0.5 text-left">Symbol</th>
|
||||
<th className="p-0.5 text-left">Side</th>
|
||||
<th className="p-0.5 text-left">Type</th>
|
||||
<th className="p-0.5 text-right">Amount</th>
|
||||
<th className="p-0.5 text-right">Price</th>
|
||||
<th className="p-0.5 text-left">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{keyDetails[k.id].orders.map((o: any, idx: number) => (
|
||||
<tr key={o.order_id || idx} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 2, color: '#c9d1d9' }}>{o.symbol}</td>
|
||||
<td style={{ padding: 2, color: o.side === 'buy' ? '#3fb950' : '#f85149' }}>{o.side}</td>
|
||||
<td style={{ padding: 2, color: '#8b949e' }}>{o.order_type}</td>
|
||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.amount}</td>
|
||||
<td style={{ padding: 2, textAlign: 'right', color: '#c9d1d9' }}>{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
|
||||
<td style={{ padding: 2, color: o.status === 'open' ? '#d29922' : '#8b949e' }}>{o.status}</td>
|
||||
<tr key={o.order_id || idx} className="border-b border-border-muted">
|
||||
<td className="p-0.5 text-text-primary">{o.symbol}</td>
|
||||
<td className={`p-0.5 ${o.side === 'buy' ? 'text-green' : 'text-red'}`}>{o.side}</td>
|
||||
<td className="p-0.5 text-text-secondary">{o.order_type}</td>
|
||||
<td className="p-0.5 text-right text-text-primary">{o.amount}</td>
|
||||
<td className="p-0.5 text-right text-text-primary">{o.price != null ? `$${Number(o.price).toFixed(4)}` : '-'}</td>
|
||||
<td className={`p-0.5 ${o.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{o.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#484f58', fontSize: 11 }}>No open orders</span>
|
||||
<span className="text-[11px] text-text-dim">No open orders</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,27 +322,30 @@ function SessionsTab() {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const revoke = async (hash: string) => { try { await authFetch(`/api/v1/auth/sessions/${hash}`, { method: 'DELETE' }); load(); } catch {} };
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (sessions.length === 0) return <div style={{ color: '#8b949e', padding: 40, textAlign: 'center' }}>🔓 Không có session nào.</div>;
|
||||
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||
if (sessions.length === 0) return <div className="p-10 text-center text-text-secondary">🔓 Không có session nào.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ color: '#8b949e', fontSize: 12, marginBottom: 12 }}>🛡️ Active Sessions — {sessions.length}</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div className="mb-3 text-xs text-text-secondary">🛡️ Active Sessions — {sessions.length}</div>
|
||||
<div className="grid gap-2.5">
|
||||
{sessions.map((s: any) => {
|
||||
const ua = parseUA(s.user_agent);
|
||||
const icon = deviceIcon[ua.device];
|
||||
const isCurrent = s.is_current;
|
||||
return (
|
||||
<div key={s.token_hash} style={{ ...cardStyle, border: isCurrent ? '1px solid #1f6feb44' : '1px solid #21262d', background: isCurrent ? 'linear-gradient(135deg, #161b22 0%, #0d1a28 100%)' : '#161b22', position: 'relative' }}>
|
||||
{isCurrent && <span style={{ position: 'absolute', top: 8, right: 10, fontSize: 10, fontWeight: 700, color: '#1f6feb', background: '#1f6feb15', padding: '2px 8px', borderRadius: 4 }}>CURRENT</span>}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, background: isCurrent ? '#1f6feb20' : '#21262d' }}>{icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>{ua.browser} · {ua.os}{isCurrent && <span style={{ color: '#1f6feb', fontSize: 11, marginLeft: 8 }}>● Active now</span>}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
|
||||
<div
|
||||
key={s.token_hash}
|
||||
className={`relative rounded-lg border p-3.5 ${isCurrent ? 'border-accent-blue/25 bg-gradient-to-br from-bg-surface to-[#0d1a28]' : 'border-border-muted bg-bg-surface'}`}
|
||||
>
|
||||
{isCurrent && <span className="absolute right-2.5 top-2 rounded bg-accent-blue/10 px-2 py-0.5 text-[10px] font-bold text-accent-blue">CURRENT</span>}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-[10px] text-xl ${isCurrent ? 'bg-accent-blue/10' : 'bg-bg-hover'}`}>{icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13px] font-semibold text-text-heading">{ua.browser} · {ua.os}{isCurrent && <span className="ml-2 text-[11px] text-accent-blue">● Active now</span>}</div>
|
||||
<div className="mt-0.5 text-[11px] text-text-secondary">🌐 {s.ip_address} · 🕐 {formatTimeAgo(s.created_at)}</div>
|
||||
</div>
|
||||
{!isCurrent && <button onClick={() => revoke(s.token_hash)} style={{ ...btnStyle, color: '#ef4444', borderColor: '#ef4444', background: '#ef444410', padding: '4px 12px', fontSize: 11 }}>🚫 Revoke</button>}
|
||||
{!isCurrent && <button onClick={() => revoke(s.token_hash)} className="rounded-md border border-red-alt bg-red-alt/10 px-3 py-1 text-[11px] text-red-alt">🚫 Revoke</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -398,57 +409,79 @@ function SettingsTab() {
|
||||
} catch (e: any) { setMsg('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||
|
||||
const filteredSymbols = allSymbols.filter(s => !tokenSearch || s.base.toLowerCase().includes(tokenSearch.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && <div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: 9999, padding: '20px 36px', borderRadius: 12, fontSize: 15, fontWeight: 600, color: msg.startsWith('✅') ? '#3fb950' : '#f85149', background: msg.startsWith('✅') ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', border: `1px solid ${msg.startsWith('✅') ? '#3fb95044' : '#f8514944'}`, backdropFilter: 'blur(8px)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', textAlign: 'center' }}>{msg}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button onClick={save} style={{ ...btnStyle, background: '#238636', color: '#fff', borderColor: '#238636', fontWeight: 600 }}>💾 Save Settings</button>
|
||||
{msg && (
|
||||
<div className={`fixed left-1/2 top-1/2 z-[9999] -translate-x-1/2 -translate-y-1/2 rounded-xl border px-9 py-5 text-center text-[15px] font-semibold shadow-[0_8px_32px_rgba(0,0,0,0.5)] backdrop-blur-md ${
|
||||
msg.startsWith('✅') ? 'border-green/25 bg-green/15 text-green' : 'border-red/25 bg-red/15 text-red'
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button onClick={save} className={`${btnClass} border-[#238636] bg-[#238636] font-semibold text-white`}>💾 Save Settings</button>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Exchange + Timeframe */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">🏛️ Exchange</div><select value={prefExchange} onChange={e => setPrefExchange(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">⏱️ Timeframe</div><select value={prefTimeframe} onChange={e => setPrefTimeframe(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{TIMEFRAMES.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Theme + Language */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12, marginBottom: 12 }}>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎨 Theme</div><div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setPrefTheme('dark')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'dark' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'dark' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'dark' ? 700 : 400 }}>🌙 Dark</button>
|
||||
<button onClick={() => setPrefTheme('light')} style={{ padding: '6px 16px', borderRadius: 6, border: prefTheme === 'light' ? '2px solid #1f6feb' : '1px solid #30363d', background: prefTheme === 'light' ? '#1f6feb20' : '#21262d', color: '#c9d1d9', cursor: 'pointer', fontSize: 12, fontWeight: prefTheme === 'light' ? 700 : 400 }}>☀️ Light</button>
|
||||
</div></div>
|
||||
<div style={cardStyle}><div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} style={{ ...inputStyle, width: '100%', cursor: 'pointer' }}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
|
||||
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className={cardClass}>
|
||||
<div className="mb-1.5 text-[11px] text-text-secondary">🎨 Theme</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPrefTheme('dark')} className={`rounded-md px-4 py-1.5 text-xs ${prefTheme === 'dark' ? 'border-2 border-accent-blue bg-accent-blue/[0.13] font-bold' : 'border border-border-default bg-bg-hover font-normal'} text-text-primary`}>🌙 Dark</button>
|
||||
<button onClick={() => setPrefTheme('light')} className={`rounded-md px-4 py-1.5 text-xs ${prefTheme === 'light' ? 'border-2 border-accent-blue bg-accent-blue/[0.13] font-bold' : 'border border-border-default bg-bg-hover font-normal'} text-text-primary`}>☀️ Light</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cardClass}><div className="mb-1.5 text-[11px] text-text-secondary">🌍 Language</div><select value={prefLanguage} onChange={e => setPrefLanguage(e.target.value)} className={`${inputClass} w-full cursor-pointer`}>{LANGUAGE_OPTIONS.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}</select></div>
|
||||
</div>
|
||||
|
||||
{/* Chart Colors */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>📊 Chart Colors</div>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefUpColor }} /><input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🟢 Up</span></div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}><div style={{ width: 18, height: 18, borderRadius: 4, background: prefDownColor }} /><input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} style={{ width: 28, height: 28, border: 'none', cursor: 'pointer', background: 'transparent' }} /><span style={{ fontSize: 11, color: '#c9d1d9' }}>🔴 Down</span></div>
|
||||
<div className={`${cardClass} mb-3`}>
|
||||
<div className="mb-1.5 text-[11px] text-text-secondary">📊 Chart Colors</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-[18px] w-[18px] rounded" style={{ background: prefUpColor }} />
|
||||
<input type="color" value={prefUpColor} onChange={e => setPrefUpColor(e.target.value)} className="h-7 w-7 cursor-pointer border-none bg-transparent" />
|
||||
<span className="text-[11px] text-text-primary">🟢 Up</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-[18px] w-[18px] rounded" style={{ background: prefDownColor }} />
|
||||
<input type="color" value={prefDownColor} onChange={e => setPrefDownColor(e.target.value)} className="h-7 w-7 cursor-pointer border-none bg-transparent" />
|
||||
<span className="text-[11px] text-text-primary">🔴 Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🔔 Notifications</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
|
||||
<label style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}><span style={{ color: '#c9d1d9', fontSize: 13 }}>💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
|
||||
<div className={`${cardClass} mb-3`}>
|
||||
<div className="mb-1.5 text-[11px] text-text-secondary">🔔 Notifications</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<label className="flex cursor-pointer items-center justify-between"><span className="text-[13px] text-text-primary">📡 Signal alerts</span><ToggleSwitch value={prefNotifSignal} onChange={setPrefNotifSignal} /></label>
|
||||
<label className="flex cursor-pointer items-center justify-between"><span className="text-[13px] text-text-primary">💹 Trade execution</span><ToggleSwitch value={prefNotifTrade} onChange={setPrefNotifTrade} /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy Toggles */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8 }}>📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||||
<div className={`${cardClass} mb-3`}>
|
||||
<div className="mb-2 text-[11px] text-text-secondary">📈 Trading Strategies ({enabledStrategies.length}/{strategyList.length} active)</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{strategyList.map(s => (
|
||||
<label key={s.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', padding: '6px 10px', borderRadius: 6, background: enabledStrategies.includes(s.name) ? '#1f6feb10' : '#0d1117', border: `1px solid ${enabledStrategies.includes(s.name) ? '#1f6feb44' : '#21262d'}`, transition: 'all 0.2s' }}>
|
||||
<span style={{ color: enabledStrategies.includes(s.name) ? '#c9d1d9' : '#484f58', fontSize: 12 }}>{s.display_name}</span>
|
||||
<label
|
||||
key={s.name}
|
||||
className={`flex cursor-pointer items-center justify-between rounded-md border px-2.5 py-1.5 transition-colors ${
|
||||
enabledStrategies.includes(s.name) ? 'border-accent-blue/25 bg-accent-blue/10' : 'border-border-muted bg-bg-primary'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs ${enabledStrategies.includes(s.name) ? 'text-text-primary' : 'text-text-dim'}`}>{s.display_name}</span>
|
||||
<ToggleSwitch
|
||||
value={enabledStrategies.includes(s.name)}
|
||||
onChange={(on) => setEnabledStrategies(prev => on ? [...prev, s.name] : prev.filter(n => n !== s.name))}
|
||||
@@ -459,35 +492,35 @@ function SettingsTab() {
|
||||
</div>
|
||||
|
||||
{/* Trade Size */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13, marginBottom: 4 }}>💰 Trade Size (USDT)</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 8, fontStyle: 'italic' }}>Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} style={{ ...inputStyle, width: 80 }} /><span style={{ color: '#8b949e', fontSize: 13 }}>USDT</span></div>
|
||||
<div className={`${cardClass} mb-3`}>
|
||||
<div className="mb-1 text-[13px] font-semibold text-text-heading">💰 Trade Size (USDT)</div>
|
||||
<div className="mb-2 text-[11px] italic text-text-secondary">Số USDT dùng cho mỗi lệnh — Signal trades & Auto trade</div>
|
||||
<div className="flex items-center gap-2"><input type="number" value={prefTradeSize} onChange={e => setPrefTradeSize(e.target.value)} min={1} step={1} className={`${inputClass} w-20`} /><span className="text-[13px] text-text-secondary">USDT</span></div>
|
||||
</div>
|
||||
|
||||
{/* Auto Trade */}
|
||||
<div style={{ ...cardStyle, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={`${cardClass} mb-3`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div style={{ color: '#f0f6fc', fontWeight: 600, fontSize: 13 }}>🤖 Auto Trade <span style={{ color: '#8b949e', fontSize: 10, background: '#21262d', padding: '1px 6px', borderRadius: 4 }}>SPOT</span></div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>Bot tự động trade tín hiệu cho các token đã chọn</div>
|
||||
<div className="text-[13px] font-semibold text-text-heading">🤖 Auto Trade <span className="rounded bg-bg-hover px-1.5 py-px text-[10px] text-text-secondary">SPOT</span></div>
|
||||
<div className="mt-0.5 text-[11px] text-text-secondary">Bot tự động trade tín hiệu cho các token đã chọn</div>
|
||||
</div>
|
||||
<ToggleSwitch value={prefAutoTrade} onChange={setPrefAutoTrade} />
|
||||
</div>
|
||||
{prefAutoTrade && <div style={{ marginTop: 8, color: '#d29922', fontSize: 11 }}>⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
|
||||
{prefAutoTrade && <div className="mt-2 text-[11px] text-yellow">⚠️ Bot sẽ đặt lệnh thật. Chỉ bật khi đã backtest kỹ!</div>}
|
||||
|
||||
{/* Token Summary */}
|
||||
{prefAutoTradeTokens.length > 0 && (
|
||||
<div style={{ marginTop: 12, borderLeft: `3px solid ${prefAutoTrade ? '#238636' : '#30363d'}`, padding: '8px 12px', borderRadius: 4, background: '#0d1117', opacity: prefAutoTrade ? 1 : 0.4, transition: 'all 0.3s' }}>
|
||||
<div style={{ color: prefAutoTrade ? '#3fb950' : '#8b949e', fontSize: 12, fontWeight: 600 }}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginTop: 2 }}>
|
||||
<div className={`mt-3 rounded border-l-[3px] bg-bg-primary px-3 py-2 transition-opacity ${prefAutoTrade ? 'border-l-[#238636] opacity-100' : 'border-l-border-default opacity-40'}`}>
|
||||
<div className={`text-xs font-semibold ${prefAutoTrade ? 'text-green' : 'text-text-secondary'}`}>📊 Auto Trades {prefAutoTrade ? 'đang theo dõi' : '(tạm dừng)'}</div>
|
||||
<div className="mt-0.5 text-[11px] text-text-secondary">
|
||||
{prefAutoTrade ? `${prefAutoTradeTokens.length} cặp — click ✕ để bỏ theo dõi` : `Auto trade đang tắt — ${prefAutoTradeTokens.length} cặp đã lưu`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{prefAutoTradeTokens.map(token => (
|
||||
<span key={token} style={{ padding: '3px 8px 3px 10px', background: '#23863622', color: '#3fb950', border: '1px solid #23863644', borderRadius: 12, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
|
||||
<span key={token} className="inline-flex items-center gap-1 rounded-xl border border-[#23863644] bg-[#23863622] py-0.5 pl-2.5 pr-2 text-xs font-medium text-green">
|
||||
{token}
|
||||
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} style={{ cursor: 'pointer', color: '#58a6ff', opacity: 0.6 }}>✕</span>}
|
||||
{prefAutoTrade && <span onClick={() => setPrefAutoTradeTokens(prev => prev.filter(s => s !== token))} className="cursor-pointer text-accent opacity-60">✕</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -495,13 +528,18 @@ function SettingsTab() {
|
||||
)}
|
||||
|
||||
{/* Token Selector */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 11, marginBottom: 6 }}>🎯 Chọn token cho Auto Trade</div>
|
||||
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} style={{ ...inputStyle, width: '100%', marginBottom: 8 }} />
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[11px] text-text-secondary">🎯 Chọn token cho Auto Trade</div>
|
||||
<input placeholder="🔍 Search tokens..." value={tokenSearch} onChange={e => setTokenSearch(e.target.value)} className={`${inputClass} mb-2 w-full`} />
|
||||
<div className="flex max-h-[200px] flex-wrap gap-1 overflow-y-auto">
|
||||
{filteredSymbols.slice(0, 100).map(s => (
|
||||
<button key={s.symbol} onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
|
||||
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 6, border: `1px solid ${prefAutoTradeTokens.includes(s.symbol) ? '#238636' : '#30363d'}`, background: prefAutoTradeTokens.includes(s.symbol) ? '#23863622' : '#21262d', color: prefAutoTradeTokens.includes(s.symbol) ? '#3fb950' : '#8b949e', cursor: 'pointer' }}>
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => setPrefAutoTradeTokens(prev => prev.includes(s.symbol) ? prev.filter(t => t !== s.symbol) : [...prev, s.symbol])}
|
||||
className={`rounded-md border px-2.5 py-1 text-[11px] ${
|
||||
prefAutoTradeTokens.includes(s.symbol) ? 'border-[#238636] bg-[#23863622] text-green' : 'border-border-default bg-bg-hover text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{s.base} {prefAutoTradeTokens.includes(s.symbol) && '✓'}
|
||||
</button>
|
||||
))}
|
||||
@@ -545,46 +583,56 @@ function HistoryTab() {
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const renderWinCard = (label: string, data: any) => (
|
||||
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: (data?.win_rate ?? 0) >= 50 ? '#3fb950' : '#f85149' }}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
|
||||
<div style={{ fontSize: 11, color: '#484f58' }}>{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
|
||||
<div className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
|
||||
<div className="mb-1 text-[11px] text-text-secondary">{label}</div>
|
||||
<div className={`text-2xl font-bold ${(data?.win_rate ?? 0) >= 50 ? 'text-green' : 'text-red'}`}>{(data?.win_rate ?? 0).toFixed(0)}%</div>
|
||||
<div className="text-[11px] text-text-dim">{data?.wins ?? 0}/{data?.trades ?? 0} wins</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <div style={{ color: '#8b949e' }}>⏳ Loading...</div>;
|
||||
if (loading) return <div className="text-text-secondary">⏳ Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sub-tab bar */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
|
||||
<button onClick={() => setSubTab('signals')} style={{ ...btnStyle, background: subTab === 'signals' ? '#1f6feb' : '#21262d', color: subTab === 'signals' ? '#fff' : '#8b949e', borderColor: subTab === 'signals' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'signals' ? 700 : 400 }}>📈 Signal Trades</button>
|
||||
<button onClick={() => setSubTab('real')} style={{ ...btnStyle, background: subTab === 'real' ? '#1f6feb' : '#21262d', color: subTab === 'real' ? '#fff' : '#8b949e', borderColor: subTab === 'real' ? '#1f6feb' : '#30363d', fontWeight: subTab === 'real' ? 700 : 400 }}>💹 Real Trades</button>
|
||||
<div className="mb-4 flex gap-1">
|
||||
<button
|
||||
onClick={() => setSubTab('signals')}
|
||||
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
|
||||
subTab === 'signals' ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
|
||||
}`}
|
||||
>📈 Signal Trades</button>
|
||||
<button
|
||||
onClick={() => setSubTab('real')}
|
||||
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
|
||||
subTab === 'real' ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
|
||||
}`}
|
||||
>💹 Real Trades</button>
|
||||
</div>
|
||||
|
||||
{subTab === 'signals' && (<>
|
||||
{signalWinRate !== null && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<div className="mb-4 flex gap-3">
|
||||
{renderWinCard('Win Rate', { win_rate: signalWinRate, wins: signalWinRate !== null ? Math.round((signalWinRate / 100) * signalTrades.length) : 0, trades: signalTrades.length })}
|
||||
<div style={{ flex: 1, padding: '12px 16px', background: '#161b22', borderRadius: 8, border: '1px solid #30363d', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 11, color: '#8b949e', marginBottom: 4 }}>TOTAL P&L</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: (signalTotalPnl ?? 0) >= 0 ? '#3fb950' : '#f85149' }}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
|
||||
<div className="flex-1 rounded-lg border border-border-default bg-bg-surface px-4 py-3 text-center">
|
||||
<div className="mb-1 text-[11px] text-text-secondary">TOTAL P&L</div>
|
||||
<div className={`text-2xl font-bold ${(signalTotalPnl ?? 0) >= 0 ? 'text-green' : 'text-red'}`}>${(signalTotalPnl ?? 0).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{signalTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No signal trades yet.</div> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
|
||||
{signalTrades.length === 0 ? <div className="text-text-secondary">No signal trades yet.</div> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Symbol</th><th className="p-1 text-left">Dir</th><th className="p-1 text-left">Entry</th><th className="p-1 text-left">Exit</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Status</th></tr></thead>
|
||||
<tbody>
|
||||
{signalTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
||||
<td style={{ padding: 4, color: t.direction === 'LONG' ? '#3fb950' : '#f85149' }}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
|
||||
<td style={{ padding: 4 }}>${t.entry_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
||||
<td style={{ padding: 4, color: t.status === 'OPEN' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className="p-1">{t.symbol}</td>
|
||||
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green' : 'text-red'}`}>{t.direction === 'LONG' ? '▲' : '▼'} {t.direction}</td>
|
||||
<td className="p-1">${t.entry_price?.toFixed(2) || '-'}</td>
|
||||
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
|
||||
<td className={`p-1 ${t.status === 'OPEN' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -595,25 +643,25 @@ function HistoryTab() {
|
||||
|
||||
{subTab === 'real' && (<>
|
||||
{winRate && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<div className="mb-4 flex gap-3">
|
||||
{renderWinCard('Daily', winRate.daily)}
|
||||
{renderWinCard('Weekly', winRate.weekly)}
|
||||
{renderWinCard('Monthly', winRate.monthly)}
|
||||
</div>
|
||||
)}
|
||||
{realTrades.length === 0 ? <div style={{ color: '#8b949e' }}>No real trades yet. Place an order from the dashboard.</div> : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Symbol</th><th style={{ textAlign: 'left', padding: 4 }}>Side</th><th style={{ textAlign: 'left', padding: 4 }}>Amount</th><th style={{ textAlign: 'left', padding: 4 }}>Price</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Status</th></tr></thead>
|
||||
{realTrades.length === 0 ? <div className="text-text-secondary">No real trades yet. Place an order from the dashboard.</div> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Symbol</th><th className="p-1 text-left">Side</th><th className="p-1 text-left">Amount</th><th className="p-1 text-left">Price</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Status</th></tr></thead>
|
||||
<tbody>
|
||||
{realTrades.slice().reverse().slice(0, 50).map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ padding: 4 }}>{t.symbol}</td>
|
||||
<td style={{ padding: 4 }}><span style={{ color: t.side === 'buy' ? '#3fb950' : '#f85149', fontWeight: 600 }}>{t.side?.toUpperCase()}</span></td>
|
||||
<td style={{ padding: 4 }}>{t.amount}</td>
|
||||
<td style={{ padding: 4 }}>${t.price?.toFixed(2) || '-'}</td>
|
||||
<td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#3fb950' : '#f85149' }}>${t.pnl?.toFixed(2)}</td>
|
||||
<td style={{ padding: 4, color: t.status === 'open' ? '#d29922' : '#8b949e' }}>{t.status}</td>
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className="p-1">{t.symbol}</td>
|
||||
<td className="p-1"><span className={`font-semibold ${t.side === 'buy' ? 'text-green' : 'text-red'}`}>{t.side?.toUpperCase()}</span></td>
|
||||
<td className="p-1">{t.amount}</td>
|
||||
<td className="p-1">${t.price?.toFixed(2) || '-'}</td>
|
||||
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green' : 'text-red'}`}>${t.pnl?.toFixed(2)}</td>
|
||||
<td className={`p-1 ${t.status === 'open' ? 'text-yellow' : 'text-text-secondary'}`}>{t.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -651,36 +699,50 @@ function BacktestTab() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16, alignItems: 'end' }}>
|
||||
<div><div style={labelStyle}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 100 }}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 140 }}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
|
||||
<div><div style={labelStyle}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} style={{ ...inputStyle, cursor: 'pointer', width: 70 }}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} style={{ ...inputStyle, cursor: 'pointer', width: 65 }}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
|
||||
<div><div style={labelStyle}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} style={{ ...inputStyle, width: 70 }} min={1} /></div>
|
||||
<button onClick={runBacktest} disabled={btLoading} style={{ ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb', padding: '6px 20px', fontWeight: 600 }}>{btLoading ? '⏳...' : '▶ Run'}</button>
|
||||
<div className="mb-4 flex flex-wrap items-end gap-2">
|
||||
<div><div className={labelClass}>Exchange</div><select value={btExchange} onChange={e => setBtExchange(e.target.value)} className={`${inputClass} w-[100px] cursor-pointer`}>{EXCHANGES.map(ex => <option key={ex} value={ex}>{ex.toUpperCase()}</option>)}</select></div>
|
||||
<div><div className={labelClass}>Symbol ({btSymbols.length})</div><select value={btSymbol} onChange={e => setBtSymbol(e.target.value)} className={`${inputClass} w-[140px] cursor-pointer`}>{btSymbols.length > 0 ? btSymbols.map(s => <option key={s} value={s}>{s}</option>) : <option value="">Loading...</option>}</select></div>
|
||||
<div><div className={labelClass}>TF</div><select value={btTf} onChange={e => setBtTf(e.target.value)} className={`${inputClass} w-[70px] cursor-pointer`}>{TFS_BT.map(tf => <option key={tf} value={tf}>{tf}</option>)}</select></div>
|
||||
<div><div className={labelClass}>Days</div><select value={btDays} onChange={e => setBtDays(Number(e.target.value))} className={`${inputClass} w-[65px] cursor-pointer`}>{DAYS.map(d => <option key={d} value={d}>{d}d</option>)}</select></div>
|
||||
<div><div className={labelClass}>Size (USDT)</div><input type="number" value={btTradeSize} onChange={e => setBtTradeSize(Number(e.target.value))} className={`${inputClass} w-[70px]`} min={1} /></div>
|
||||
<button onClick={runBacktest} disabled={btLoading} className={`${btnClass} border-accent-blue bg-accent-blue px-5 font-semibold text-white`}>{btLoading ? '⏳...' : '▶ Run'}</button>
|
||||
</div>
|
||||
{btError && <div style={{ color: '#ef4444', marginBottom: 12 }}>❌ {btError}</div>}
|
||||
{btError && <div className="mb-3 text-red">❌ {btError}</div>}
|
||||
{btResult && (<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}><span style={{ color: btSaved ? '#22c55e' : '#8b949e', fontSize: 12 }}>{btSaved ? '✅ Saved to history' : '💾'}</span>{!btSaved && <button onClick={saveBacktest} style={{ ...btnStyle, fontSize: 11, padding: '3px 10px' }}>Save to History</button>}{btSaveError && <span style={{ color: '#ef4444', fontSize: 11 }}>❌ {btSaveError}</span>}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10, marginBottom: 16 }}>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>{btResult.symbol} @ {btResult.exchange}</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.timeframe} {btResult.days}d</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Candles</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.candles_count?.toLocaleString()}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Signals</div><div style={{ fontSize: 18, fontWeight: 700, color: '#58a6ff' }}>{btResult.total_signals}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Trades</div><div style={{ fontSize: 18, fontWeight: 700, color: '#f0f6fc' }}>{btResult.trades.total}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Wins</div><div style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{btResult.trades.wins}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Losses</div><div style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{btResult.trades.losses}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Win Rate</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.win_rate || 0) >= 50 ? '#22c55e' : '#ef4444' }}>{btResult.trades.win_rate}%</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>PnL</div><div style={{ fontSize: 18, fontWeight: 700, color: btResult.trades.total_pnl >= 0 ? '#22c55e' : '#ef4444' }}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
|
||||
<div style={cardStyle}><div style={{ fontSize: 11, color: '#8b949e' }}>Profit Factor</div><div style={{ fontSize: 18, fontWeight: 700, color: (btResult.trades.profit_factor || 0) >= 1 ? '#22c55e' : '#ef4444' }}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className={`text-xs ${btSaved ? 'text-green-alt' : 'text-text-secondary'}`}>{btSaved ? '✅ Saved to history' : '💾'}</span>
|
||||
{!btSaved && <button onClick={saveBacktest} className={`${btnClass} px-2.5 py-1 text-[11px]`}>Save to History</button>}
|
||||
{btSaveError && <span className="text-[11px] text-red">❌ {btSaveError}</span>}
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">{btResult.symbol} @ {btResult.exchange}</div><div className="text-lg font-bold text-text-heading">{btResult.timeframe} {btResult.days}d</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Candles</div><div className="text-lg font-bold text-text-heading">{btResult.candles_count?.toLocaleString()}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Signals</div><div className="text-lg font-bold text-accent">{btResult.total_signals}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Trades</div><div className="text-lg font-bold text-text-heading">{btResult.trades.total}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Wins</div><div className="text-lg font-bold text-green-alt">{btResult.trades.wins}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Losses</div><div className="text-lg font-bold text-red-alt">{btResult.trades.losses}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Win Rate</div><div className={`text-lg font-bold ${(btResult.trades.win_rate || 0) >= 50 ? 'text-green-alt' : 'text-red-alt'}`}>{btResult.trades.win_rate}%</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">PnL</div><div className={`text-lg font-bold ${btResult.trades.total_pnl >= 0 ? 'text-green-alt' : 'text-red-alt'}`}>${btResult.trades.total_pnl?.toFixed(2)}</div></div>
|
||||
<div className={cardClass}><div className="text-[11px] text-text-secondary">Profit Factor</div><div className={`text-lg font-bold ${(btResult.trades.profit_factor || 0) >= 1 ? 'text-green-alt' : 'text-red-alt'}`}>{btResult.trades.profit_factor?.toFixed(2) || 'N/A'}</div></div>
|
||||
</div>
|
||||
{btResult?.trades?.recent?.length > 0 && (
|
||||
<div style={cardStyle}><h4 style={{ margin: '0 0 10px', color: '#f0f6fc', fontSize: 14 }}>📋 Recent Trades</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid #30363d', color: '#8b949e' }}><th style={{ textAlign: 'left', padding: 4 }}>Time</th><th style={{ textAlign: 'left', padding: 4 }}>Dir</th><th style={{ textAlign: 'left', padding: 4 }}>Entry</th><th style={{ textAlign: 'left', padding: 4 }}>Exit</th><th style={{ textAlign: 'left', padding: 4 }}>PnL</th><th style={{ textAlign: 'left', padding: 4 }}>Reason</th></tr></thead>
|
||||
<div className={cardClass}>
|
||||
<h4 className="mb-2.5 text-sm text-text-heading">📋 Recent Trades</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[500px] border-collapse text-xs">
|
||||
<thead><tr className="border-b border-border-default text-text-secondary"><th className="p-1 text-left">Time</th><th className="p-1 text-left">Dir</th><th className="p-1 text-left">Entry</th><th className="p-1 text-left">Exit</th><th className="p-1 text-left">PnL</th><th className="p-1 text-left">Reason</th></tr></thead>
|
||||
<tbody>{btResult.trades.recent.slice(-10).reverse().map((t: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #21262d' }}><td style={{ padding: 4 }}>{t.entry_time?.slice(5, 16)}</td><td style={{ padding: 4, color: t.direction === 'LONG' ? '#22c55e' : '#ef4444' }}>{t.direction}</td><td style={{ padding: 4 }}>${t.entry_price?.toFixed(2)}</td><td style={{ padding: 4 }}>${t.exit_price?.toFixed(2) || '-'}</td><td style={{ padding: 4, color: (t.pnl || 0) >= 0 ? '#22c55e' : '#ef4444' }}>${t.pnl?.toFixed(2)}</td><td style={{ padding: 4, color: '#8b949e' }}>{t.exit_reason}</td></tr>
|
||||
<tr key={i} className="border-b border-border-muted">
|
||||
<td className="p-1">{t.entry_time?.slice(5, 16)}</td>
|
||||
<td className={`p-1 ${t.direction === 'LONG' ? 'text-green-alt' : 'text-red-alt'}`}>{t.direction}</td>
|
||||
<td className="p-1">${t.entry_price?.toFixed(2)}</td>
|
||||
<td className="p-1">${t.exit_price?.toFixed(2) || '-'}</td>
|
||||
<td className={`p-1 ${(t.pnl || 0) >= 0 ? 'text-green-alt' : 'text-red-alt'}`}>${t.pnl?.toFixed(2)}</td>
|
||||
<td className="p-1 text-text-secondary">{t.exit_reason}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
@@ -691,16 +753,44 @@ function BacktestTab() {
|
||||
// ═══════════════ MAIN ═══════════════
|
||||
export default function ProfilePage() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const user = useAppSelector(s => s.auth.user);
|
||||
const tabs = ['info', 'keys', 'sessions', 'settings', 'history', 'backtest'];
|
||||
const [tab, setTab] = useState(() => new URLSearchParams(window.location.search).get('tab') || 'info');
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<h2 style={{ color: '#f0f6fc', marginBottom: 16 }}>👤 {user?.username || 'Profile'}</h2>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||
<div className="flex cursor-pointer items-center gap-3" onClick={() => navigate('/dashboard')}>
|
||||
<span className="text-lg">👤</span>
|
||||
<span className="text-base font-bold text-text-heading">{user?.username || 'Profile'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={btnClass} onClick={() => navigate('/dashboard')}>← Dashboard</button>
|
||||
<button
|
||||
className={`${btnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="mx-auto max-w-[1100px] p-5">
|
||||
<div className="mb-5 flex flex-wrap gap-1">
|
||||
{tabs.map(tb => (
|
||||
<button key={tb} onClick={() => setTab(tb)} style={{ ...btnStyle, fontWeight: tab === tb ? 700 : 400, background: tab === tb ? '#1f6feb' : '#21262d', color: tab === tb ? '#fff' : '#8b949e', borderColor: tab === tb ? '#1f6feb' : '#30363d' }}>
|
||||
<button
|
||||
key={tb}
|
||||
onClick={() => setTab(tb)}
|
||||
className={`min-h-9 touch-manipulation rounded-md border px-4 py-1.5 text-xs ${
|
||||
tab === tb ? 'border-accent-blue bg-accent-blue font-bold text-white' : 'border-border-default bg-bg-hover font-normal text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{tb === 'info' ? 'ℹ️ Info' : tb === 'keys' ? '🔑 API Keys' : tb === 'sessions' ? '🔐 Sessions' : tb === 'settings' ? '⚙️ Settings' : tb === 'history' ? '📈 History' : '📊 Backtest'}
|
||||
</button>
|
||||
))}
|
||||
@@ -711,6 +801,7 @@ export default function ProfilePage() {
|
||||
{tab === 'settings' && <SettingsTab />}
|
||||
{tab === 'history' && <HistoryTab />}
|
||||
{tab === 'backtest' && <BacktestTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
/* ===== Trading Portal - Global Styles ===== */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* --- Tailwind theme tokens (single source of truth for the dark palette) ---
|
||||
Mirrors the :root variables below 1:1 so `bg-surface`, `text-secondary`,
|
||||
`border-default`, etc. are available as Tailwind utilities everywhere. */
|
||||
@theme {
|
||||
--color-bg-primary: #0d1117;
|
||||
--color-bg-surface: #161b22;
|
||||
--color-bg-elevated: #1c2128;
|
||||
--color-bg-hover: #21262d;
|
||||
--color-border-default: #30363d;
|
||||
--color-border-muted: #21262d;
|
||||
--color-accent: #58a6ff;
|
||||
--color-accent-hover: #79c0ff;
|
||||
--color-accent-blue: #1f6feb;
|
||||
--color-green: #3fb950;
|
||||
--color-green-alt: #26a69a;
|
||||
--color-red: #f85149;
|
||||
--color-red-alt: #ef5350;
|
||||
--color-yellow: #d29922;
|
||||
--color-text-primary: #c9d1d9;
|
||||
--color-text-heading: #f0f6fc;
|
||||
--color-text-secondary: #8b949e;
|
||||
--color-text-muted: #6e7681;
|
||||
--color-text-dim: #484f58;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
/* --- CSS Variables (Dark Theme) --- */
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
@@ -585,6 +615,18 @@ tr:hover td {
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton shimmer (loading placeholders) */
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(90deg, var(--bg-surface) 25%, var(--bg-hover) 50%, var(--bg-surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* Pulse loading for placeholders */
|
||||
.pulse {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
||||
@@ -48,6 +48,10 @@ const ACTION_OPTIONS = [
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const btnClass = 'min-h-9 touch-manipulation cursor-pointer rounded-md border border-border-default bg-bg-hover px-4 py-1.5 text-xs text-text-primary';
|
||||
const thClass = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-text-secondary';
|
||||
const tdClass = 'px-3 py-2 align-middle';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -132,17 +136,17 @@ export default function AuditLogPage() {
|
||||
permission_change: '#ff7b72',
|
||||
};
|
||||
|
||||
const getActionBadge = (action: string) => {
|
||||
const color = actionColors[action] || '#8b949e';
|
||||
return { color, fontWeight: 600 };
|
||||
};
|
||||
const getActionBadge = (action: string): React.CSSProperties => ({
|
||||
color: actionColors[action] || '#8b949e',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
const formatDetails = (details: Record<string, unknown> | null) => {
|
||||
if (!details) return <span style={{ color: '#484f58' }}>—</span>;
|
||||
if (!details) return <span className="text-text-dim">—</span>;
|
||||
const entries = Object.entries(details);
|
||||
if (entries.length === 0) return <span style={{ color: '#484f58' }}>—</span>;
|
||||
if (entries.length === 0) return <span className="text-text-dim">—</span>;
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: '#8b949e', wordBreak: 'break-all' }}>
|
||||
<span className="break-all text-[11px] text-text-secondary">
|
||||
{entries.map(([k, v]) => `${k}: ${String(v ?? '')}`).join(', ')}
|
||||
</span>
|
||||
);
|
||||
@@ -152,50 +156,40 @@ export default function AuditLogPage() {
|
||||
if (!user?.is_admin) return null;
|
||||
if (initializing || isLoading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ color: '#8b949e', fontSize: 14 }}>Loading audit logs...</div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg-primary">
|
||||
<div className="text-sm text-text-secondary">Loading audit logs...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '6px 16px', fontSize: 12, border: '1px solid #30363d',
|
||||
borderRadius: 6, background: '#21262d', color: '#c9d1d9', cursor: 'pointer',
|
||||
minHeight: 36, touchAction: 'manipulation',
|
||||
};
|
||||
const activeBtn: React.CSSProperties = { ...btnStyle, background: '#1f6feb', color: '#fff', borderColor: '#1f6feb' };
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '5px 8px', fontSize: 12, color: '#c9d1d9', background: '#0d1117',
|
||||
border: '1px solid #30363d', borderRadius: 4, outline: 'none', cursor: 'pointer',
|
||||
minWidth: 160,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#c9d1d9', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
||||
<div className="min-h-screen bg-bg-primary font-sans text-text-primary">
|
||||
{/* ═══ Navbar ═══ */}
|
||||
<nav style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', background: '#161b22', borderBottom: '1px solid #30363d', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 18 }}>📋</span>
|
||||
<span style={{ fontWeight: 700, color: '#f0f6fc', fontSize: 16 }}>Audit Log</span>
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 border-b border-border-default bg-bg-surface px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">📋</span>
|
||||
<span className="text-base font-bold text-text-heading">Audit Log</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button style={btnStyle} onClick={() => navigate('/admin')}>← Admin Panel</button>
|
||||
<button style={{ ...btnStyle, color: '#f85149', borderColor: '#f8514933' }} onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={btnClass} onClick={() => navigate('/admin')}>← Admin Panel</button>
|
||||
<button
|
||||
className={`${btnClass} border-red/20 text-red`}
|
||||
onClick={async () => {
|
||||
await dispatch(logoutThunk());
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ═══ Controls ═══ */}
|
||||
<div style={{ display: 'flex', gap: 12, padding: '12px 16px', borderBottom: '1px solid #21262d', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border-muted px-4 py-3">
|
||||
<select
|
||||
style={selectStyle}
|
||||
className="min-w-[160px] cursor-pointer rounded border border-border-default bg-bg-primary px-2 py-1.5 text-xs text-text-primary outline-none"
|
||||
value={actionFilter}
|
||||
onChange={(e) => handleFilterChange(e.target.value)}
|
||||
>
|
||||
@@ -204,92 +198,98 @@ export default function AuditLogPage() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span style={{ fontSize: 12, color: '#484f58' }}>
|
||||
<span className="text-xs text-text-dim">
|
||||
{total > 0 ? `${total} entries` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ═══ Table ═══ */}
|
||||
<div style={{ padding: 16, overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||||
{loading && <p style={{ color: '#484f58', fontSize: 13 }}>Loading audit logs...</p>}
|
||||
{error && <p style={{ color: '#f85149', fontSize: 13 }}>{error}</p>}
|
||||
<div className="scrollable-x overflow-x-auto p-4">
|
||||
{loading && <p className="text-[13px] text-text-dim">Loading audit logs...</p>}
|
||||
{error && <p className="text-[13px] text-red">{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{logs.length === 0 ? (
|
||||
<div style={{ color: '#484f58', fontSize: 13, textAlign: 'center', padding: 24 }}>
|
||||
<div className="p-6 text-center text-[13px] text-text-dim">
|
||||
No audit log entries found.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||
<th style={thStyle}>Time</th>
|
||||
<th style={thStyle}>User</th>
|
||||
<th style={thStyle}>Action</th>
|
||||
<th style={thStyle}>Resource</th>
|
||||
<th style={thStyle}>Details</th>
|
||||
<tr className="border-b border-border-default">
|
||||
<th className={thClass}>Time</th>
|
||||
<th className={thClass}>User</th>
|
||||
<th className={thClass}>Action</th>
|
||||
<th className={thClass}>Resource</th>
|
||||
<th className={thClass}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((entry) => (
|
||||
<tr key={entry.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<td style={{ ...tdStyle, color: '#484f58', whiteSpace: 'nowrap' }}>
|
||||
<tr key={entry.id} className="border-b border-border-muted">
|
||||
<td className={`${tdClass} whitespace-nowrap text-text-dim`}>
|
||||
{formatTime(entry.created_at)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<td className={tdClass}>
|
||||
{entry.user_id ? (
|
||||
<span style={{ color: '#8b949e', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
<span className="font-mono text-[11px] text-text-secondary">
|
||||
{entry.user_id.substring(0, 8)}...
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#484f58' }}>System</span>
|
||||
<span className="text-text-dim">System</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<td className={tdClass}>
|
||||
<span style={getActionBadge(entry.action)}>{entry.action}</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, color: '#f0f6fc', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
<td className={`${tdClass} font-mono text-[11px] text-text-heading`}>
|
||||
{entry.resource}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<td className={tdClass}>
|
||||
{formatDetails(entry.details)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Pagination ═══ */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', alignItems: 'center', marginTop: 16 }}>
|
||||
<div className="mt-4 flex items-center justify-center gap-1">
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
className={btnClass}
|
||||
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(1)}
|
||||
>
|
||||
««
|
||||
</button>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
className={btnClass}
|
||||
style={{ opacity: currentPage <= 1 ? 0.4 : 1 }}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: '#8b949e', padding: '0 8px' }}>
|
||||
<span className="px-2 text-xs text-text-secondary">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
className={btnClass}
|
||||
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
<button
|
||||
style={{ ...btnStyle, opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
className={btnClass}
|
||||
style={{ opacity: currentPage >= totalPages ? 0.4 : 1 }}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(totalPages)}
|
||||
>
|
||||
@@ -303,11 +303,3 @@ export default function AuditLogPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left', padding: '6px 12px', color: '#8b949e', fontWeight: 500,
|
||||
fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
};
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '8px 12px', verticalAlign: 'middle',
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons.svg'],
|
||||
|
||||
Reference in New Issue
Block a user