forked from FOSS/Steam-Panel
v 3.0.3
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
/logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SteamPanel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
|
||||
<title>Steam</title>
|
||||
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from "react";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { TabNav } from "@/components/layout/TabNav";
|
||||
import { ToastContainer } from "@/components/layout/Toast";
|
||||
import { AccountsTab } from "@/components/accounts/AccountsTab";
|
||||
import { ToolsTab } from "@/components/tools/ToolsTab";
|
||||
import { MafileTab } from "@/components/mafiles/MafileTab";
|
||||
import { LogsTab } from "@/components/logs/LogsTab";
|
||||
|
||||
export default function App() {
|
||||
const activeTab = useUiStore((s) => s.activeTab);
|
||||
const loadDisplaySettings = useUiStore((s) => s.loadDisplaySettings);
|
||||
const loadColumnSettings = useUiStore((s) => s.loadColumnSettings);
|
||||
const loadLogpassColumnSettings = useUiStore((s) => s.loadLogpassColumnSettings);
|
||||
|
||||
useEffect(() => {
|
||||
loadDisplaySettings();
|
||||
loadColumnSettings();
|
||||
loadLogpassColumnSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-dvh bg-dark-900 text-gray-300 flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
<TabNav />
|
||||
<main className="flex-1 min-h-0 p-4 w-full overflow-y-auto">
|
||||
{activeTab === "accounts" && <AccountsTab />}
|
||||
{activeTab === "tools" && <ToolsTab />}
|
||||
{activeTab === "mafiles" && <MafileTab />}
|
||||
{activeTab === "logs" && <LogsTab />}
|
||||
</main>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Account, AccountCreate, AccountUpdate, BulkImportResult,
|
||||
Proxy, Task, MafileInfo, MafileExportRequest,
|
||||
ActionRequest, ValidationSettings, DisplaySettings, ColumnSettings, LogpassColumnSettings, LogEntry,
|
||||
LogpassAccount, LogpassAccountCreate, LogpassAccountUpdate,
|
||||
TokenAccount, TokenAccountCreate, TokenColumnSettings,
|
||||
} from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(body.detail ?? `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Version
|
||||
getVersion: () => request<{ version: string }>("/version"),
|
||||
|
||||
// Accounts
|
||||
getAccounts: () => request<Account[]>("/accounts"),
|
||||
getAccount: (id: number) => request<Account>(`/accounts/${id}`),
|
||||
createAccount: (data: AccountCreate) =>
|
||||
request<Account>("/accounts", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateAccount: (id: number, data: AccountUpdate) =>
|
||||
request<Account>(`/accounts/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
deleteAccount: (id: number) =>
|
||||
request<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||
deleteBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
assignProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/accounts/assign-proxies", { method: "POST" }),
|
||||
reassignProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/accounts/reassign-proxies", { method: "POST" }),
|
||||
clearProxies: () =>
|
||||
request<{ cleared: number }>("/accounts/clear-proxies", { method: "POST" }),
|
||||
importAccounts: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return fetch(`${BASE}/accounts/import`, { method: "POST", body: form }).then(async (r) => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
|
||||
return r.json() as Promise<BulkImportResult>;
|
||||
});
|
||||
},
|
||||
|
||||
// Actions
|
||||
executeAction: (data: ActionRequest) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/actions", { method: "POST", body: JSON.stringify(data) }),
|
||||
respondToPrompt: (taskId: string, value: string, login?: string) =>
|
||||
request<{ status: string }>(`/tasks/${taskId}/respond`, { method: "POST", body: JSON.stringify({ value, login: login || "" }) }),
|
||||
generate2FA: (shared_secret: string) =>
|
||||
request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }),
|
||||
generate2FAByAccount: (account_id: number) =>
|
||||
request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }),
|
||||
|
||||
// Proxies
|
||||
getProxies: () => request<Proxy[]>("/proxies"),
|
||||
addProxy: (address: string, protocol = "http") =>
|
||||
request<Proxy>("/proxies", { method: "POST", body: JSON.stringify({ address, protocol }) }),
|
||||
deleteProxy: (id: number) =>
|
||||
request<void>(`/proxies/${id}`, { method: "DELETE" }),
|
||||
deleteAllProxies: () =>
|
||||
request<{ deleted: number }>("/proxies/all", { method: "DELETE" }),
|
||||
bulkAddProxies: (proxies: { address: string; protocol: string }[]) =>
|
||||
request<{ added: number }>("/proxies/bulk", { method: "POST", body: JSON.stringify(proxies) }),
|
||||
checkProxies: () =>
|
||||
request<{ total: number; alive: number; dead: number }>("/proxies/check", { method: "POST" }),
|
||||
|
||||
// Tasks
|
||||
getTasks: () => request<Task[]>("/tasks"),
|
||||
getTask: (id: string) => request<Task>(`/tasks/${id}`),
|
||||
cancelTask: (id: string) => request<void>(`/tasks/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Mafiles
|
||||
getMafiles: () => request<MafileInfo[]>("/mafiles"),
|
||||
uploadMafiles: (files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("files", f));
|
||||
return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(async (r) => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
|
||||
return r.json() as Promise<{ uploaded: number; errors: string[] }>;
|
||||
});
|
||||
},
|
||||
deleteMafile: (filename: string) =>
|
||||
request<void>(`/mafiles/${encodeURIComponent(filename)}`, { method: "DELETE" }),
|
||||
exportSecrets: () => request<{ account_name: string; shared_secret: string; identity_secret: string; steam_id: string }[]>("/mafiles/export/all"),
|
||||
exportZip: (body: MafileExportRequest) =>
|
||||
fetch(`${BASE}/mafiles/export/zip`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => {
|
||||
if (!r.ok) throw new Error(`Export failed: ${r.status}`);
|
||||
return r.blob();
|
||||
}),
|
||||
|
||||
// Settings
|
||||
getValidationSettings: () => request<ValidationSettings>("/settings/validation"),
|
||||
updateValidationSettings: (data: ValidationSettings) =>
|
||||
request<ValidationSettings>("/settings/validation", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getDisplaySettings: () => request<DisplaySettings>("/settings/display"),
|
||||
updateDisplaySettings: (data: DisplaySettings) =>
|
||||
request<DisplaySettings>("/settings/display", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getColumnSettings: () => request<ColumnSettings>("/settings/columns"),
|
||||
updateColumnSettings: (data: ColumnSettings) =>
|
||||
request<ColumnSettings>("/settings/columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
getLogpassColumnSettings: () => request<LogpassColumnSettings>("/settings/logpass-columns"),
|
||||
updateLogpassColumnSettings: (data: LogpassColumnSettings) =>
|
||||
request<LogpassColumnSettings>("/settings/logpass-columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
|
||||
// Logs
|
||||
getLogs: () => request<LogEntry[]>("/logs"),
|
||||
|
||||
// Auto-accept
|
||||
startAutoAccept: (ids: number[]) =>
|
||||
request<{ started: number[] }>("/auto-accept/start", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
stopAutoAccept: (ids: number[]) =>
|
||||
request<{ stopped: number[] }>("/auto-accept/stop", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
getAutoAcceptStatus: () =>
|
||||
request<{ running: number[] }>("/auto-accept/status"),
|
||||
|
||||
// Browser
|
||||
openBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/accounts/${id}/browser`, { method: "POST" }),
|
||||
|
||||
// Logpass accounts
|
||||
getLogpassAccounts: () => request<LogpassAccount[]>("/logpass"),
|
||||
createLogpassAccount: (data: LogpassAccountCreate) =>
|
||||
request<LogpassAccount>("/logpass", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateLogpassAccount: (id: number, data: LogpassAccountUpdate) =>
|
||||
request<LogpassAccount>(`/logpass/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
||||
deleteLogpassAccount: (id: number) =>
|
||||
request<void>(`/logpass/${id}`, { method: "DELETE" }),
|
||||
deleteLogpassBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/logpass/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
importLogpass: (lines: string[]) =>
|
||||
request<BulkImportResult>("/logpass/import", { method: "POST", body: JSON.stringify({ lines }) }),
|
||||
validateLogpass: (account_ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/logpass/validate", { method: "POST", body: JSON.stringify({ account_ids }) }),
|
||||
fullParseLogpass: (account_ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/logpass/full-parse", { method: "POST", body: JSON.stringify({ account_ids }) }),
|
||||
assignLogpassProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/logpass/assign-proxies", { method: "POST" }),
|
||||
reassignLogpassProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/logpass/reassign-proxies", { method: "POST" }),
|
||||
clearLogpassProxies: () =>
|
||||
request<{ cleared: number }>("/logpass/clear-proxies", { method: "POST" }),
|
||||
openLogpassBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/logpass/${id}/browser`, { method: "POST" }),
|
||||
|
||||
// Token accounts
|
||||
getTokenAccounts: () => request<TokenAccount[]>("/token-accounts"),
|
||||
createTokenAccount: (data: TokenAccountCreate) =>
|
||||
request<TokenAccount>("/token-accounts", { method: "POST", body: JSON.stringify(data) }),
|
||||
deleteTokenAccount: (id: number) =>
|
||||
request<void>(`/token-accounts/${id}`, { method: "DELETE" }),
|
||||
deleteTokenBulk: (ids: number[]) =>
|
||||
request<{ deleted: number }>("/token-accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
importTokens: (lines: string[]) =>
|
||||
request<BulkImportResult>("/token-accounts/import", { method: "POST", body: JSON.stringify({ lines }) }),
|
||||
validateTokens: (ids: number[]) =>
|
||||
request<{ task_id: string; accounts_count: number }>("/token-accounts/validate", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
|
||||
openTokenBrowser: (id: number) =>
|
||||
request<{ status: string; message: string; task_id?: string }>(`/token-accounts/${id}/browser`, { method: "POST" }),
|
||||
assignTokenProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/token-accounts/assign-proxies", { method: "POST" }),
|
||||
reassignTokenProxies: () =>
|
||||
request<{ assigned: number; proxies_used: number }>("/token-accounts/reassign-proxies", { method: "POST" }),
|
||||
clearTokenProxies: () =>
|
||||
request<{ cleared: number }>("/token-accounts/clear-proxies", { method: "POST" }),
|
||||
getTokenColumnSettings: () => request<TokenColumnSettings>("/settings/token-columns"),
|
||||
updateTokenColumnSettings: (data: TokenColumnSettings) =>
|
||||
request<TokenColumnSettings>("/settings/token-columns", { method: "PUT", body: JSON.stringify(data) }),
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
export interface Account {
|
||||
id: number;
|
||||
login: string;
|
||||
password: string;
|
||||
steam_id: string | null;
|
||||
email: string | null;
|
||||
email_password: string | null;
|
||||
phone: string | null;
|
||||
mafile_path: string | null;
|
||||
shared_secret: string | null;
|
||||
identity_secret: string | null;
|
||||
proxy: string | null;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
steam_level: number | null;
|
||||
last_online: string | null;
|
||||
auto_accept: number;
|
||||
ban_status: string | null;
|
||||
has_cookies: boolean;
|
||||
has_revocation_code: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AccountCreate {
|
||||
login: string;
|
||||
password: string;
|
||||
steam_id?: string;
|
||||
email?: string;
|
||||
email_password?: string;
|
||||
phone?: string;
|
||||
proxy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AccountUpdate {
|
||||
login?: string;
|
||||
password?: string;
|
||||
steam_id?: string;
|
||||
email?: string;
|
||||
email_password?: string;
|
||||
phone?: string;
|
||||
proxy?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Proxy {
|
||||
id: number;
|
||||
address: string;
|
||||
protocol: string;
|
||||
is_alive: boolean;
|
||||
last_checked: string | null;
|
||||
fail_count: number;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
total: number;
|
||||
result: string | null;
|
||||
error: string | null;
|
||||
prompt?: string;
|
||||
prompt_login?: string;
|
||||
step?: number;
|
||||
total_steps?: number;
|
||||
step_label?: string;
|
||||
active_count?: number;
|
||||
account_ids?: string | null;
|
||||
account_results?: Record<string, { status: string; error?: string }> | string | null;
|
||||
account_steps?: Record<string, { step: number; total: number }>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MafileInfo {
|
||||
filename: string;
|
||||
account_name: string;
|
||||
steam_id: string;
|
||||
has_shared_secret: boolean;
|
||||
has_identity_secret: boolean;
|
||||
fully_enrolled: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MafileExportRequest {
|
||||
fields: string[];
|
||||
session_fields: string[];
|
||||
format: "flat_mafiles" | "per_account_folder" | "single_file";
|
||||
account_ids: number[];
|
||||
folder_name_template: string;
|
||||
mafile_name_template: string;
|
||||
txt_name_template: string;
|
||||
include_txt_per_folder: boolean;
|
||||
include_global_txt: boolean;
|
||||
skip_folders: boolean;
|
||||
txt_format: string;
|
||||
}
|
||||
|
||||
export interface BulkImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
account_ids: number[];
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationSettings {
|
||||
fetch_profile: boolean;
|
||||
check_ban: boolean;
|
||||
max_threads: number;
|
||||
auto_revalidate_browser: boolean;
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
hide_passwords: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnSettings {
|
||||
browser: boolean;
|
||||
profile: boolean;
|
||||
steam_id: boolean;
|
||||
login: boolean;
|
||||
password: boolean;
|
||||
login_pass: boolean;
|
||||
email: boolean;
|
||||
email_pass: boolean;
|
||||
email_login_pass: boolean;
|
||||
phone: boolean;
|
||||
status: boolean;
|
||||
ban: boolean;
|
||||
twofa: boolean;
|
||||
mafile: boolean;
|
||||
proxy: boolean;
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
last_online: boolean;
|
||||
}
|
||||
|
||||
export interface LogpassColumnSettings {
|
||||
browser: boolean;
|
||||
profile: boolean;
|
||||
steam_id: boolean;
|
||||
login: boolean;
|
||||
password: boolean;
|
||||
login_pass: boolean;
|
||||
status: boolean;
|
||||
ban: boolean;
|
||||
prime: boolean;
|
||||
trophy: boolean;
|
||||
behavior: boolean;
|
||||
license: boolean;
|
||||
proxy: boolean;
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
last_online: boolean;
|
||||
}
|
||||
|
||||
export interface TokenColumnSettings {
|
||||
browser: boolean;
|
||||
profile: boolean;
|
||||
last_online: boolean;
|
||||
steam_id: boolean;
|
||||
login: boolean;
|
||||
token: boolean;
|
||||
status: boolean;
|
||||
proxy: boolean;
|
||||
notes: boolean;
|
||||
actions: boolean;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
ts: string;
|
||||
level: string;
|
||||
icon: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface LogpassAccount {
|
||||
id: number;
|
||||
login: string;
|
||||
password: string;
|
||||
steam_id: string | null;
|
||||
proxy: string | null;
|
||||
status: string;
|
||||
ban_status: string | null;
|
||||
nickname: string | null;
|
||||
steam_level: number | null;
|
||||
prime: string | null;
|
||||
trophy: string | null;
|
||||
behavior: string | null;
|
||||
license: string | null;
|
||||
notes: string | null;
|
||||
avatar_url: string | null;
|
||||
last_online: string | null;
|
||||
has_cookies: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LogpassAccountCreate {
|
||||
login: string;
|
||||
password: string;
|
||||
steam_id?: string;
|
||||
proxy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LogpassAccountUpdate {
|
||||
login?: string;
|
||||
password?: string;
|
||||
steam_id?: string;
|
||||
proxy?: string;
|
||||
status?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface TokenAccount {
|
||||
id: number;
|
||||
login: string | null;
|
||||
token: string;
|
||||
steam_id: string | null;
|
||||
proxy: string | null;
|
||||
status: string;
|
||||
ban_status: string | null;
|
||||
nickname: string | null;
|
||||
steam_level: number | null;
|
||||
avatar_url: string | null;
|
||||
last_online: string | null;
|
||||
has_cookies: boolean;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TokenAccountCreate {
|
||||
login?: string;
|
||||
token: string;
|
||||
steam_id?: string;
|
||||
proxy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type TabId = "accounts" | "tools" | "mafiles" | "logs";
|
||||
|
||||
export type ToastType = "info" | "success" | "error" | "warn";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState } from "react";
|
||||
import type { Account, ColumnSettings } from "@/api/types";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { api } from "@/api/client";
|
||||
import { StatusBadge, BanBadge, MafileBadge } from "./Badges";
|
||||
import { ActionMenu } from "./ActionMenu";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { IconCheck, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
visibleColumns: ColumnSettings;
|
||||
proxyLabel: string | null;
|
||||
isProcessing: boolean;
|
||||
accountResult?: { status: string; error?: string };
|
||||
accountStep?: { step: number; total: number };
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAction: (id: number, action: string, params: Record<string, string>) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
onOpenBrowser: (id: number) => void;
|
||||
}
|
||||
|
||||
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
|
||||
const selectedIds = useAccountStore((s) => s.selectedIds);
|
||||
const toggleSelect = useAccountStore((s) => s.toggleSelect);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const hidePasswords = useUiStore((s) => s.hidePasswords);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [editingNotes, setEditingNotes] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(a.notes || "");
|
||||
const t = useT();
|
||||
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
const ok = await copyText(text);
|
||||
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
|
||||
};
|
||||
|
||||
const masked = hidePasswords && !revealed;
|
||||
const dots = "••••••••";
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-dark-700/50 transition">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(a.id)}
|
||||
onChange={() => toggleSelect(a.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 w-5">
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
{accountStep && (
|
||||
<span className="text-[10px] text-gray-500 whitespace-nowrap">[{accountStep.step}/{accountStep.total}]</span>
|
||||
)}
|
||||
</div>
|
||||
) : accountResult?.status === "ok" ? (
|
||||
<IconCheck size={16} className="text-gray-400" />
|
||||
) : accountResult?.status === "error" ? (
|
||||
<span title={accountResult.error?.toLowerCase().includes("proxy") || accountResult.error?.toLowerCase().includes("network") || accountResult.error?.includes("WinError") ? t("tip.proxyError", { error: accountResult.error }) : accountResult.error}>
|
||||
<IconAlertTriangle size={16} className="text-red-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
{v.browser && (
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center">
|
||||
{a.has_cookies ? (
|
||||
<button
|
||||
onClick={() => onOpenBrowser(a.id)}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
|
||||
title={t("tip.openBrowser")}
|
||||
>
|
||||
{t("col.browser")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{v.profile && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
|
||||
<span className="text-xs">{a.nickname || "—"}</span>
|
||||
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{v.last_online && (
|
||||
<td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">
|
||||
{a.last_online || "—"}
|
||||
</td>
|
||||
)}
|
||||
{v.steam_id && (
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{a.steam_id ? (
|
||||
<a
|
||||
href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
{a.steam_id}
|
||||
</a>
|
||||
) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{v.login && <td className="px-3 py-2 font-medium">{a.login}</td>}
|
||||
{v.password && (
|
||||
<td className="px-3 py-2 text-gray-400 select-none whitespace-nowrap">
|
||||
<span
|
||||
className="cell-copy cursor-pointer"
|
||||
onClick={() => handleCopy(a.password)}
|
||||
title={t("tip.copyPassword")}
|
||||
>
|
||||
{masked ? dots : a.password}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
|
||||
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
|
||||
title={masked ? t("tip.show") : t("tip.hide")}
|
||||
>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{v.login_pass && (
|
||||
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
|
||||
<span
|
||||
className="cell-copy cursor-pointer"
|
||||
onClick={() => handleCopy(`${a.login}:${a.password}`)}
|
||||
title={t("tip.copyLoginPass")}
|
||||
>
|
||||
{masked ? `${a.login}:${dots}` : `${a.login}:${a.password}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
|
||||
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
|
||||
title={masked ? t("tip.show") : t("tip.hide")}
|
||||
>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{v.email && <td className="px-3 py-2 text-gray-400 text-xs">{a.email || "—"}</td>}
|
||||
{v.email_pass && (
|
||||
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
|
||||
<span
|
||||
className="cell-copy cursor-pointer"
|
||||
onClick={() => { if (a.email_password) handleCopy(a.email_password); }}
|
||||
title={a.email_password ? t("tip.copyEmailPass") : ""}
|
||||
>
|
||||
{a.email_password ? (masked ? dots : a.email_password) : "—"}
|
||||
</span>
|
||||
{a.email_password && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
|
||||
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
|
||||
title={masked ? t("tip.show") : t("tip.hide")}
|
||||
>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{v.email_login_pass && (
|
||||
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
|
||||
<span
|
||||
className="cell-copy cursor-pointer"
|
||||
onClick={() => { if (a.email && a.email_password) handleCopy(`${a.email}:${a.email_password}`); }}
|
||||
title={a.email && a.email_password ? t("tip.copyEmailLoginPass") : ""}
|
||||
>
|
||||
{a.email && a.email_password ? (masked ? `${a.email}:${dots}` : `${a.email}:${a.email_password}`) : "—"}
|
||||
</span>
|
||||
{a.email && a.email_password && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
|
||||
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
|
||||
title={masked ? t("tip.show") : t("tip.hide")}
|
||||
>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{v.phone && <td className="px-3 py-2 text-gray-400 text-xs">{a.phone || "—"}</td>}
|
||||
{v.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
|
||||
{v.ban && <td className="px-3 py-2"><BanBadge status={a.ban_status} /></td>}
|
||||
{v.twofa && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
{a.shared_secret ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { code } = await api.generate2FA(a.shared_secret!);
|
||||
await copyText(code);
|
||||
addToast("success", t("twofa.copied", { code }));
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("twofa.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}}
|
||||
className="text-accent hover:underline text-xs"
|
||||
title={t("tip.gen2fa")}
|
||||
>
|
||||
<IconKey size={12} className="inline mr-0.5" /> {t("tip.code")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{v.mafile && (
|
||||
<td className="px-3 py-2">
|
||||
<MafileBadge hasMafile={!!a.mafile_path} />
|
||||
</td>
|
||||
)}
|
||||
{v.proxy && (
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{a.proxy ? (
|
||||
<span
|
||||
className="text-blue-400 cursor-pointer hover:underline"
|
||||
onClick={() => handleCopy(a.proxy!)}
|
||||
title={a.proxy}
|
||||
>
|
||||
{proxyLabel || a.proxy}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{v.notes && (
|
||||
<td className="px-3 py-2 text-xs max-w-[180px]">
|
||||
{editingNotes ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-dark-600 border border-dark-500 rounded px-1.5 py-0.5 text-xs text-gray-200 outline-none focus:border-accent"
|
||||
value={notesValue}
|
||||
onChange={(e) => setNotesValue(e.target.value)}
|
||||
onBlur={async () => {
|
||||
setEditingNotes(false);
|
||||
if (notesValue !== (a.notes || "")) {
|
||||
try {
|
||||
await api.updateAccount(a.id, { notes: notesValue });
|
||||
addToast("success", t("toast.noteSaved"));
|
||||
useAccountStore.getState().loadAccounts();
|
||||
} catch {
|
||||
addToast("error", t("toast.noteSaveError"));
|
||||
}
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setNotesValue(a.notes || ""); setEditingNotes(false); } }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="cursor-pointer text-gray-400 hover:text-gray-200 truncate block"
|
||||
onClick={() => { setNotesValue(a.notes || ""); setEditingNotes(true); }}
|
||||
title={a.notes || t("tip.addNote")}
|
||||
>
|
||||
{a.notes || "—"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{v.actions && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ActionMenu account={a} onAction={onAction} onToggleAutoAccept={onToggleAutoAccept} />
|
||||
<button onClick={() => onEdit(a.id)} className="text-gray-400 hover:text-accent" title={t("tip.edit")}><IconEdit size={14} /></button>
|
||||
<button onClick={() => onDelete(a.id)} className="text-gray-400 hover:text-red-400" title={t("tip.delete")}><IconTrash size={14} /></button>
|
||||
{a.auto_accept ? (
|
||||
<span title={t("tip.autoAcceptActive")}><IconRefresh size={14} className="text-accent" /></span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Account, ColumnSettings } from "@/api/types";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { AccountRow } from "./AccountRow";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const COLUMN_KEYS: (keyof ColumnSettings)[] = [
|
||||
"browser", "profile", "last_online", "steam_id", "login", "password", "login_pass",
|
||||
"email", "email_pass", "email_login_pass", "phone", "status", "ban", "twofa", "mafile", "proxy", "notes", "actions",
|
||||
];
|
||||
|
||||
const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
|
||||
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
|
||||
steam_id: "col.steamId", login: "col.login", password: "col.password",
|
||||
login_pass: "col.loginPass", email: "col.email", email_pass: "col.emailPass",
|
||||
email_login_pass: "col.emailLoginPass", phone: "col.phone", status: "col.status",
|
||||
ban: "col.ban", twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
|
||||
notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
accounts: Account[];
|
||||
processingIds: Set<number>;
|
||||
accountResults: Record<string, { status: string; error?: string }>;
|
||||
accountSteps: Record<string, { step: number; total: number }>;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAction: (id: number, action: string, params: Record<string, string>) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
onOpenBrowser: (id: number) => void;
|
||||
}
|
||||
|
||||
export function AccountTable({ accounts, processingIds, accountResults, accountSteps, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
|
||||
const selectAll = useAccountStore((s) => s.selectAll);
|
||||
const clearSelection = useAccountStore((s) => s.clearSelection);
|
||||
const selectedIds = useAccountStore((s) => s.selectedIds);
|
||||
const cols = useUiStore((s) => s.columnVisibility);
|
||||
const t = useT();
|
||||
|
||||
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
|
||||
const visibleKeys = COLUMN_KEYS.filter((k) => cols[k]);
|
||||
|
||||
const BATCH = 100;
|
||||
const [visibleCount, setVisibleCount] = useState(BATCH);
|
||||
const sentinelRef = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
type SortField = "last_online" | null;
|
||||
type SortDir = "asc" | "desc";
|
||||
const [sortField, setSortField] = useState<SortField>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortDir === "asc") setSortDir("desc");
|
||||
else { setSortField(null); setSortDir("asc"); }
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortField) return accounts;
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const EMPTY = Symbol();
|
||||
return [...accounts].sort((a, b) => {
|
||||
const parseOnline = (v: string | null | undefined): number | typeof EMPTY => {
|
||||
if (!v || v === "\u2014") return EMPTY;
|
||||
if (v === "online") return -1;
|
||||
const m = v.match(/^(\d+)([mhd])$/i);
|
||||
if (!m) return EMPTY;
|
||||
const n = parseInt(m[1], 10);
|
||||
if (m[2] === "m") return n;
|
||||
if (m[2] === "h") return n * 60;
|
||||
return n * 1440;
|
||||
};
|
||||
const va = parseOnline(a.last_online);
|
||||
const vb = parseOnline(b.last_online);
|
||||
if (va === EMPTY && vb === EMPTY) return 0;
|
||||
if (va === EMPTY) return 1;
|
||||
if (vb === EMPTY) return -1;
|
||||
if (va === vb) return 0;
|
||||
return (va < vb ? -1 : 1) * dir;
|
||||
});
|
||||
}, [accounts, sortField, sortDir]);
|
||||
|
||||
useEffect(() => { setVisibleCount(BATCH); }, [accounts, sortField, sortDir]);
|
||||
|
||||
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
|
||||
}, { rootMargin: "400px" });
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [sorted.length, visibleCount]);
|
||||
|
||||
// Build proxy labels: unique proxies get numbered "Прокси 1", "Прокси 2", etc.
|
||||
const proxyLabels = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
const uniqueProxies: string[] = [];
|
||||
for (const a of accounts) {
|
||||
if (a.proxy && !uniqueProxies.includes(a.proxy)) {
|
||||
uniqueProxies.push(a.proxy);
|
||||
}
|
||||
}
|
||||
const proxyToNum = new Map<string, number>();
|
||||
uniqueProxies.forEach((p, i) => proxyToNum.set(p, i + 1));
|
||||
for (const a of accounts) {
|
||||
if (a.proxy) {
|
||||
const num = proxyToNum.get(a.proxy);
|
||||
map.set(a.id, num ? t("proxy.label", { num }) : a.proxy);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [accounts]);
|
||||
|
||||
if (!accounts.length) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
{t("empty.accounts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-dark-800 z-10">
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
|
||||
<th className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => allSelected ? clearSelection() : selectAll()}
|
||||
/>
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-accent font-medium">{selectedIds.size}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="w-5"></th>
|
||||
{visibleKeys.map((k) => {
|
||||
if (k === "last_online") {
|
||||
return (
|
||||
<th key={k} className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}>
|
||||
{t(COL_LABEL_KEYS[k])}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
return <th key={k} className={`px-3 py-2${k === "browser" ? " text-center" : ""}`}>{t(COL_LABEL_KEYS[k])}</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((a) => (
|
||||
<AccountRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
visibleColumns={cols}
|
||||
proxyLabel={proxyLabels.get(a.id) ?? null}
|
||||
isProcessing={processingIds.has(a.id)}
|
||||
accountResult={accountResults[String(a.id)]}
|
||||
accountStep={accountSteps[String(a.id)]}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAction={onAction}
|
||||
onToggleAutoAccept={onToggleAutoAccept}
|
||||
onOpenBrowser={onOpenBrowser}
|
||||
/>
|
||||
))}
|
||||
{visibleCount < accounts.length && (
|
||||
<tr ref={sentinelRef}><td colSpan={visibleKeys.length + 2} className="text-center py-3 text-xs text-gray-500">{t("paging.shown", { visible: visibleCount, total: accounts.length })}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useTaskStore } from "@/stores/taskStore";
|
||||
import { useLogpassStore } from "@/stores/logpassStore";
|
||||
import { useTokenStore } from "@/stores/tokenStore";
|
||||
import { AccountTable } from "./AccountTable";
|
||||
import { EditModal } from "./EditModal";
|
||||
import { ImportModal } from "./ImportModal";
|
||||
import { AddModal } from "./AddModal";
|
||||
import { ConfirmModal } from "@/components/shared/Modals";
|
||||
import { ColumnSettingsDropdown } from "./ColumnSettingsDropdown";
|
||||
import {
|
||||
IconDownload, IconPlus, IconCheckCircle, IconXCircle,
|
||||
IconFolder, IconGlobe, IconRefresh, IconBan,
|
||||
IconPlay, IconLock, IconKey, IconZap,
|
||||
} from "@/components/shared/Icons";
|
||||
import { LogpassTab } from "./LogpassTab";
|
||||
import { TokenTab } from "./TokenTab";
|
||||
import type { AccountCreate, AccountUpdate, Task } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type AccountSection = "mafile" | "logpass" | "token";
|
||||
|
||||
type AccResults = Record<string, { status: string; error?: string }>;
|
||||
|
||||
function parseAccResults(raw: Task["account_results"]): AccResults | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw) as AccResults; } catch { return null; }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseIds(raw: Task["account_ids"]): number[] {
|
||||
if (!raw) return [];
|
||||
try { return JSON.parse(raw) as number[]; } catch { return []; }
|
||||
}
|
||||
|
||||
const BULK_ACTIONS = [
|
||||
{ value: "", labelKey: "action.selectAction" },
|
||||
{ value: "validate", labelKey: "action.validate" },
|
||||
{ value: "change_password", labelKey: "action.changePassword" },
|
||||
{ value: "random_password", labelKey: "action.randomPassword" },
|
||||
{ value: "change_email", labelKey: "action.changeEmail" },
|
||||
{ value: "remove_guard", labelKey: "action.removeGuard" },
|
||||
{ value: "enable_auto_accept", labelKey: "action.enableAutoAccept" },
|
||||
] as const;
|
||||
|
||||
function BulkActionDropdown({ value, onChange, disabledActions }: { value: string; onChange: (v: string) => void; disabledActions?: Set<string> }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const label = t(BULK_ACTIONS.find((a) => a.value === value)?.labelKey || "action.selectAction");
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-sm flex items-center gap-1 min-w-[170px] justify-between hover:border-dark-500 transition-colors"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-700 border border-dark-600 rounded shadow-lg min-w-[100px] py-1 max-h-[320px] overflow-y-auto">
|
||||
{BULK_ACTIONS.map((a) => {
|
||||
const disabled = !!(a.value && disabledActions?.has(a.value));
|
||||
return (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => { if (!disabled) { onChange(a.value); setOpen(false); } }}
|
||||
disabled={disabled}
|
||||
title={disabled ? t("action.noRevocationCode") : undefined}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-dark-600"} ${a.value === value ? "text-accent bg-dark-600/50" : ""} ${!a.value ? "text-gray-500" : ""}`}
|
||||
>
|
||||
{t(a.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionAccountCount() {
|
||||
const section = useUiStore((s) => s.accountSection);
|
||||
const mafileCount = useAccountStore((s) => s.accounts.length);
|
||||
const logpassCount = useLogpassStore((s) => s.accounts.length);
|
||||
const tokenCount = useTokenStore((s) => s.accounts.length);
|
||||
const loadLogpass = useLogpassStore((s) => s.loadAccounts);
|
||||
const loadTokens = useTokenStore((s) => s.loadAccounts);
|
||||
const [displayCount, setDisplayCount] = useState(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const displayRef = useRef(0);
|
||||
const prevSection = useRef(section);
|
||||
|
||||
useEffect(() => {
|
||||
if (section === "logpass" && logpassCount === 0) loadLogpass();
|
||||
if (section === "token" && tokenCount === 0) loadTokens();
|
||||
}, [section]);
|
||||
|
||||
const count = section === "logpass" ? logpassCount : section === "token" ? tokenCount : mafileCount;
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
|
||||
const sectionChanged = prevSection.current !== section;
|
||||
prevSection.current = section;
|
||||
|
||||
if (sectionChanged) {
|
||||
displayRef.current = count;
|
||||
setDisplayCount(count);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = displayRef.current;
|
||||
const diff = count - start;
|
||||
if (diff === 0) return;
|
||||
|
||||
const duration = Math.min(400, Math.max(150, Math.abs(diff) * 2));
|
||||
const startTime = performance.now();
|
||||
|
||||
const step = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const value = Math.max(0, Math.round(start + diff * eased));
|
||||
displayRef.current = value;
|
||||
setDisplayCount(value);
|
||||
if (progress < 1) rafRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [count, section]);
|
||||
|
||||
return (
|
||||
<div className="mt-1 px-3 py-2 border-t border-dark-600 flex items-center gap-2">
|
||||
<div className="relative shrink-0" style={{ width: 14, height: 14 }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="icon-shimmer absolute inset-0" style={{ pointerEvents: "none" }}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-gray-300 font-medium tabular-nums">{displayCount}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountsTab() {
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const selectedIds = useAccountStore((s) => s.selectedIds);
|
||||
const loadAccounts = useAccountStore((s) => s.loadAccounts);
|
||||
const clearSelection = useAccountStore((s) => s.clearSelection);
|
||||
const sendToMafileManager = useAccountStore((s) => s.sendToMafileManager);
|
||||
const setTab = useUiStore((s) => s.setTab);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadTasks = useTaskStore((s) => s.loadTasks);
|
||||
const t = useT();
|
||||
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
|
||||
const [onConfirmAction, setOnConfirmAction] = useState<(() => void) | null>(null);
|
||||
const [bulkAction, setBulkAction] = useState("");
|
||||
const section = useUiStore((s) => s.accountSection);
|
||||
const setSection = useUiStore((s) => s.setAccountSection);
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [promptData, setPromptData] = useState<{ taskId: string; message: string; login?: string } | null>(null);
|
||||
const [promptValue, setPromptValue] = useState("");
|
||||
const [accountResults, setAccountResults] = useState<Record<string, { status: string; error?: string }>>({});
|
||||
const [accountSteps, setAccountSteps] = useState<Record<string, { step: number; total: number }>>({});
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return accounts;
|
||||
const q = searchQuery.trim();
|
||||
|
||||
// lvl comparison syntax: lvl>10, lvl>=10, lvl<10, lvl<=10, lvl=10
|
||||
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
|
||||
if (lvlMatch) {
|
||||
const op = lvlMatch[1];
|
||||
const val = parseInt(lvlMatch[2], 10);
|
||||
return accounts.filter((a) => {
|
||||
const lvl = a.steam_level;
|
||||
if (lvl == null) return false;
|
||||
if (op === ">") return lvl > val;
|
||||
if (op === ">=") return lvl >= val;
|
||||
if (op === "<") return lvl < val;
|
||||
if (op === "<=") return lvl <= val;
|
||||
return lvl === val;
|
||||
});
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
const words = ql.split(/\s+/).filter(Boolean);
|
||||
return accounts.filter((a) => {
|
||||
if (a.login.toLowerCase().includes(ql)) return true;
|
||||
if (a.steam_id && a.steam_id.toLowerCase().includes(ql)) return true;
|
||||
if (a.notes) {
|
||||
const notesLower = a.notes.toLowerCase();
|
||||
return words.some((w) => notesLower.includes(w));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [accounts, searchQuery]);
|
||||
|
||||
const bulkDisabledActions = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const selected = accounts.filter((a) => selectedIds.has(a.id));
|
||||
if (!selected.some((a) => a.has_revocation_code)) {
|
||||
disabled.add("remove_guard");
|
||||
}
|
||||
return disabled;
|
||||
}, [accounts, selectedIds]);
|
||||
|
||||
useEffect(() => { loadAccounts(); }, []);
|
||||
|
||||
const confirm = (msg: string, action: () => void) => {
|
||||
setConfirmMsg(msg);
|
||||
setOnConfirmAction(() => action);
|
||||
};
|
||||
|
||||
const watchTask = useCallback((taskId: string, accountIds: number[] = [], clearResults = true) => {
|
||||
if (clearResults) {
|
||||
setAccountResults((prev) => {
|
||||
const next = { ...prev };
|
||||
accountIds.forEach((id) => delete next[String(id)]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
const es = new EventSource(`/api/tasks/${taskId}/stream`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data) as Task;
|
||||
setActiveTask(data);
|
||||
const parsed = parseAccResults(data.account_results);
|
||||
if (parsed) {
|
||||
setAccountResults((prev) => ({ ...prev, ...parsed }));
|
||||
}
|
||||
if (data.account_steps) {
|
||||
setAccountSteps((prev) => ({ ...prev, ...data.account_steps }));
|
||||
}
|
||||
if (data.prompt) {
|
||||
setPromptData({ taskId, message: data.prompt, login: data.prompt_login });
|
||||
setPromptValue("");
|
||||
}
|
||||
if (data.status === "completed" || data.status === "failed" || data.status === "cancelled") {
|
||||
if (data.status === "completed") {
|
||||
addToast("success", `${data.type}: ${data.result || t("toast.done")}`);
|
||||
} else if (data.status === "cancelled") {
|
||||
addToast("info", t("toast.taskCancelled"));
|
||||
} else {
|
||||
addToast("error", `${data.type}: ${data.error || t("toast.error")}`);
|
||||
}
|
||||
setProcessingIds((prev) => {
|
||||
const s = new Set(prev);
|
||||
accountIds.forEach((id) => s.delete(id));
|
||||
return s;
|
||||
});
|
||||
setPromptData(null);
|
||||
loadAccounts();
|
||||
loadTasks();
|
||||
es.close();
|
||||
setTimeout(() => setActiveTask(null), 3000);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
}, [addToast, loadAccounts, loadTasks]);
|
||||
|
||||
// Restore running/completed task state on page load
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const tasks = await api.getTasks();
|
||||
if (cancelled) return;
|
||||
|
||||
const running = tasks.find((t) => t.status === "running");
|
||||
if (running) {
|
||||
const ids = parseIds(running.account_ids);
|
||||
setActiveTask(running);
|
||||
setProcessingIds(new Set(ids));
|
||||
const restored = parseAccResults(running.account_results);
|
||||
if (restored) setAccountResults(restored);
|
||||
watchTask(running.id, ids, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const recent = tasks.find((t) => t.status === "completed" || t.status === "failed");
|
||||
if (recent) {
|
||||
const restored = parseAccResults(recent.account_results);
|
||||
if (restored && Object.keys(restored).length > 0) {
|
||||
setAccountResults(restored);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [watchTask]);
|
||||
|
||||
const handleAction = async (id: number, action: string, params: Record<string, string>) => {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
const login = acc?.login ?? `#${id}`;
|
||||
try {
|
||||
setProcessingIds((prev) => new Set(prev).add(id));
|
||||
const result = await api.executeAction({ account_ids: [id], action, params });
|
||||
addToast("info", t("misc.taskAction", { id: result.task_id, action, login }));
|
||||
watchTask(result.task_id, [id]);
|
||||
} catch (e: unknown) {
|
||||
setProcessingIds((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptSubmit = async () => {
|
||||
if (!promptData || !promptValue) return;
|
||||
try {
|
||||
await api.respondToPrompt(promptData.taskId, promptValue, promptData.login);
|
||||
setPromptData(null);
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptCancel = async () => {
|
||||
if (promptData) {
|
||||
try { await api.cancelTask(promptData.taskId); } catch { /* task may already be done */ }
|
||||
}
|
||||
setPromptData(null);
|
||||
};
|
||||
|
||||
const handleBulkAction = () => {
|
||||
if (!bulkAction) return addToast("warn", t("toast.selectAction"));
|
||||
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
|
||||
|
||||
if (bulkAction === "enable_auto_accept") {
|
||||
const ids = [...selectedIds];
|
||||
clearSelection();
|
||||
api.startAutoAccept(ids)
|
||||
.then(() => { addToast("success", t("toast.autoAcceptEnabled", { count: ids.length })); loadAccounts(); })
|
||||
.catch((e: unknown) => addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) })));
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedIds = [...selectedIds];
|
||||
const capturedAction = bulkAction;
|
||||
const actionLabel = t(BULK_ACTIONS.find((a) => a.value === capturedAction)?.labelKey || "action.selectAction");
|
||||
confirm(
|
||||
t("confirm.executeBulk", { action: actionLabel, count: capturedIds.length }),
|
||||
async () => {
|
||||
try {
|
||||
const result = await api.executeAction({ account_ids: capturedIds, action: capturedAction });
|
||||
clearSelection();
|
||||
setProcessingIds((prev) => { const s = new Set(prev); capturedIds.forEach((id) => s.add(id)); return s; });
|
||||
addToast("info", t("misc.taskCount", { id: result.task_id, count: result.accounts_count }));
|
||||
watchTask(result.task_id, capturedIds);
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleAutoAccept = async (id: number) => {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc) return;
|
||||
const enable = !acc.auto_accept;
|
||||
try {
|
||||
if (enable) await api.startAutoAccept([id]);
|
||||
else await api.stopAutoAccept([id]);
|
||||
addToast("info", enable ? t("toast.autoAcceptOn") : t("toast.autoAcceptOff"));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: number) => setEditId(id);
|
||||
|
||||
const handleSaveEdit = async (id: number, data: AccountUpdate) => {
|
||||
try {
|
||||
await api.updateAccount(id, data);
|
||||
setEditId(null);
|
||||
addToast("success", t("toast.saved"));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
confirm(t("confirm.deleteAccount"), async () => {
|
||||
try {
|
||||
await api.deleteAccount(id);
|
||||
addToast("success", t("toast.accountDeleted"));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
|
||||
confirm(t("confirm.deleteSelected", { count: selectedIds.size }), async () => {
|
||||
try {
|
||||
const result = await api.deleteBulk([...selectedIds]);
|
||||
clearSelection();
|
||||
addToast("success", t("toast.deleted", { count: result.deleted }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
if (!accounts.length) return addToast("warn", t("toast.noAccounts"));
|
||||
confirm(t("confirm.deleteAll", { count: accounts.length }), async () => {
|
||||
try {
|
||||
const result = await api.deleteBulk([]);
|
||||
clearSelection();
|
||||
addToast("success", t("toast.deleted", { count: result.deleted }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAccount = async (data: AccountCreate) => {
|
||||
try {
|
||||
await api.createAccount(data);
|
||||
setShowAdd(false);
|
||||
addToast("success", t("toast.accountAdded"));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendToMafiles = () => {
|
||||
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
|
||||
sendToMafileManager([...selectedIds]);
|
||||
setTab("mafiles");
|
||||
addToast("info", t("toast.sentToMafile", { count: selectedIds.size }));
|
||||
};
|
||||
|
||||
const handleOpenBrowser = async (id: number) => {
|
||||
try {
|
||||
const result = await api.openBrowser(id);
|
||||
if (result.status === "revalidating") {
|
||||
addToast("warn", t("toast.cookiesExpiredRevalidating"));
|
||||
if (result.task_id) {
|
||||
setProcessingIds((prev) => new Set(prev).add(id));
|
||||
watchTask(result.task_id, [id]);
|
||||
}
|
||||
} else {
|
||||
addToast("info", t("toast.browserOpening"));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignProxies = async () => {
|
||||
confirm(t("confirm.assignProxies"), async () => {
|
||||
try {
|
||||
const r = await api.assignProxies();
|
||||
addToast("success", t("toast.proxiesAssigned", { used: r.proxies_used, count: r.assigned }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleReassignProxies = async () => {
|
||||
confirm(t("confirm.reassignProxies"), async () => {
|
||||
try {
|
||||
const r = await api.reassignProxies();
|
||||
addToast("success", t("toast.proxiesReassigned", { used: r.proxies_used, count: r.assigned }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearProxies = async () => {
|
||||
confirm(t("confirm.clearProxies"), async () => {
|
||||
try {
|
||||
const r = await api.clearProxies();
|
||||
addToast("success", t("toast.proxiesClearedCount", { count: r.cleared }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const editAccount = editId ? accounts.find((a) => a.id === editId) : null;
|
||||
|
||||
const SECTIONS: { id: AccountSection; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "mafile", label: "Mafile", icon: <IconLock size={14} /> },
|
||||
{ id: "logpass", label: "Log:Pass", icon: <IconKey size={14} /> },
|
||||
{ id: "token", label: "Token", icon: <IconZap size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 h-full min-h-0 overflow-hidden">
|
||||
{/* Section sidebar */}
|
||||
<div className="w-36 shrink-0 flex flex-col gap-1 bg-dark-800 border border-dark-600 rounded-lg p-2 self-start">
|
||||
<p className="text-xs font-medium text-gray-500 px-2 py-1 uppercase tracking-wider">{t("section.dataType")}</p>
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setSection(s.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded text-sm text-left w-full transition-colors ${
|
||||
section === s.id ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200 hover:bg-dark-700"
|
||||
}`}
|
||||
>
|
||||
{s.icon}{s.label}
|
||||
</button>
|
||||
))}
|
||||
<SectionAccountCount />
|
||||
</div>
|
||||
|
||||
{section === "logpass" && <div className="flex-1 min-w-0 min-h-0"><LogpassTab /></div>}
|
||||
{section === "token" && <div className="flex-1 min-w-0 min-h-0"><TokenTab /></div>}
|
||||
{section === "mafile" && <div className="flex flex-col gap-4 flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
|
||||
|
||||
{/* Inline progress indicator */}
|
||||
{activeTask && (
|
||||
<>
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
{activeTask.type === "validate" ? t("task.validate") :
|
||||
activeTask.type === "change_password" ? t("task.changePassword") :
|
||||
activeTask.type === "random_password" ? t("task.randomPassword") :
|
||||
activeTask.type === "change_email" ? t("task.changeEmail") :
|
||||
activeTask.type === "change_phone" ? t("task.changePhone") :
|
||||
activeTask.type === "remove_guard" ? t("task.removeGuard") :
|
||||
activeTask.type}
|
||||
</span>
|
||||
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
|
||||
<div
|
||||
className={`h-2.5 rounded-full transition-all duration-300 ${
|
||||
activeTask.status === "completed" ? "bg-green-500" :
|
||||
activeTask.status === "failed" ? "bg-red-500" :
|
||||
"bg-accent"
|
||||
}`}
|
||||
style={{ width: `${activeTask.total > 1
|
||||
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
|
||||
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{activeTask.total > 1
|
||||
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
|
||||
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
|
||||
{activeTask.total > 1
|
||||
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
|
||||
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
|
||||
</span>
|
||||
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
|
||||
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ColumnSettingsDropdown />
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
|
||||
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table with action bar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
{/* Action bar (table header panel) */}
|
||||
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
|
||||
<BulkActionDropdown value={bulkAction} onChange={setBulkAction} disabledActions={bulkDisabledActions} />
|
||||
<button onClick={handleBulkAction} disabled={!!activeTask || processingIds.size > 0} className="btn-accent text-sm disabled:opacity-40 disabled:cursor-not-allowed"><IconPlay size={12} className="inline mr-1" />{t("btn.execute")}</button>
|
||||
{activeTask && activeTask.status === "running" && (
|
||||
<button
|
||||
onClick={async () => { try { await api.cancelTask(activeTask.id); } catch {} }}
|
||||
className="btn-danger text-sm"
|
||||
>
|
||||
<IconXCircle size={12} className="inline mr-1" />{t("btn.cancel")}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleSendToMafiles} className="btn-secondary text-sm"><IconFolder size={14} className="inline mr-1" />В Mafile Manager</button>
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
|
||||
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
|
||||
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
|
||||
<div className="ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("ph.search")}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<AccountTable
|
||||
accounts={filteredAccounts}
|
||||
processingIds={processingIds}
|
||||
accountResults={accountResults}
|
||||
accountSteps={accountSteps}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onAction={handleAction}
|
||||
onToggleAutoAccept={handleToggleAutoAccept}
|
||||
onOpenBrowser={handleOpenBrowser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Modals */}
|
||||
{editAccount && (
|
||||
<EditModal account={editAccount} onSave={handleSaveEdit} onClose={() => setEditId(null)} />
|
||||
)}
|
||||
{showImport && <ImportModal onClose={() => setShowImport(false)} />}
|
||||
{showAdd && <AddModal onSave={handleAddAccount} onClose={() => setShowAdd(false)} />}
|
||||
{confirmMsg && onConfirmAction && (
|
||||
<ConfirmModal
|
||||
message={confirmMsg}
|
||||
onConfirm={() => { onConfirmAction(); setConfirmMsg(null); setOnConfirmAction(null); }}
|
||||
onCancel={() => { setConfirmMsg(null); setOnConfirmAction(null); }}
|
||||
/>
|
||||
)}
|
||||
{promptData && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onMouseDown={handlePromptCancel}>
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-5 w-96 shadow-xl" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-3">{promptData.message}</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={promptValue}
|
||||
onChange={(e) => setPromptValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePromptSubmit()}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
|
||||
placeholder={t("prompt.enterValue")}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handlePromptSubmit} className="btn-primary flex-1">OK</button>
|
||||
<button onClick={handlePromptCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Account } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { IconZap } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const ROW_ACTION_KEYS = [
|
||||
{ action: "validate", labelKey: "action.validate" },
|
||||
{ action: "change_password", labelKey: "action.changePassword" },
|
||||
{ action: "random_password", labelKey: "action.randomPassword" },
|
||||
{ action: "change_email", labelKey: "action.changeEmail" },
|
||||
{ action: "change_phone", labelKey: "action.changePhone" },
|
||||
{ action: "remove_guard", labelKey: "action.removeGuard" },
|
||||
] as const;
|
||||
|
||||
const PARAM_ACTION_KEYS: Record<string, string> = {
|
||||
change_password: "prompt.newPassword",
|
||||
change_email: "prompt.newEmail",
|
||||
change_phone: "prompt.newPhone",
|
||||
};
|
||||
|
||||
const PARAM_KEYS: Record<string, string> = {
|
||||
change_password: "new_password",
|
||||
change_email: "new_email",
|
||||
change_phone: "new_phone",
|
||||
};
|
||||
|
||||
const CONFIRM_ACTION_KEYS: Record<string, string> = {
|
||||
remove_guard: "confirm.removeGuard",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onAction: (id: number, action: string, params: Record<string, string>) => void;
|
||||
onToggleAutoAccept: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
|
||||
const [paramValue, setParamValue] = useState("");
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadAccounts = useAccountStore((s) => s.loadAccounts);
|
||||
const t = useT();
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!btnRef.current) return;
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
const menuHeight = 320;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const top = spaceBelow < menuHeight
|
||||
? Math.max(4, rect.top - menuHeight)
|
||||
: rect.bottom + 4;
|
||||
const left = Math.max(4, rect.right - 180);
|
||||
setMenuPos({ top, left });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||
btnRef.current && !btnRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleScroll() { setOpen(false); }
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const handleAction = (action: string, _label: string) => {
|
||||
setOpen(false);
|
||||
const confirmKey = CONFIRM_ACTION_KEYS[action];
|
||||
if (confirmKey) {
|
||||
setConfirmPrompt({ action, title: t(confirmKey) });
|
||||
return;
|
||||
}
|
||||
const promptKey = PARAM_ACTION_KEYS[action];
|
||||
if (promptKey) {
|
||||
setParamPrompt({ action, title: t(promptKey) });
|
||||
setParamValue("");
|
||||
} else {
|
||||
onAction(account.id, action, {});
|
||||
}
|
||||
};
|
||||
|
||||
const submitParam = () => {
|
||||
if (!paramPrompt || !paramValue) return;
|
||||
const paramKey = PARAM_KEYS[paramPrompt.action] || "value";
|
||||
onAction(account.id, paramPrompt.action, { [paramKey]: paramValue });
|
||||
setParamPrompt(null);
|
||||
};
|
||||
|
||||
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
|
||||
|
||||
const handleRemoveProxy = async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await api.updateAccount(account.id, { proxy: "" });
|
||||
addToast("success", t("toast.proxyCleared", { name: account.login }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReassignProxy = async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
const r = await api.reassignProxies();
|
||||
addToast("success", t("toast.proxiesReassigned", { used: r.proxies_used, count: r.assigned }));
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative inline-block">
|
||||
<button ref={btnRef} onClick={() => setOpen(!open)} className="btn-secondary px-2 py-1 text-xs">
|
||||
<IconZap size={12} />
|
||||
</button>
|
||||
{open && menuPos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="action-menu-dropdown"
|
||||
style={{ position: "fixed", top: menuPos.top, left: menuPos.left }}
|
||||
>
|
||||
{ROW_ACTION_KEYS.map(({ action, labelKey }) => {
|
||||
const disabled = action === "remove_guard" && !account.has_revocation_code;
|
||||
if (disabled) {
|
||||
return (
|
||||
<span key={action} data-tooltip={t("action.noRevocationCode")} className="block">
|
||||
<button className="action-menu-item opacity-40 cursor-not-allowed" disabled>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button key={action} onClick={() => handleAction(action, t(labelKey))} className="action-menu-item">
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<hr className="border-dark-600 my-1" />
|
||||
<button onClick={handleRemoveProxy} className="action-menu-item">
|
||||
{t("proxy.removeProxy")}
|
||||
</button>
|
||||
<button onClick={handleReassignProxy} className="action-menu-item">
|
||||
{t("proxy.reassignProxy")}
|
||||
</button>
|
||||
<hr className="border-dark-600 my-1" />
|
||||
<button onClick={() => { setOpen(false); onToggleAutoAccept(account.id); }} className="action-menu-item">
|
||||
{aaLabel}
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paramPrompt && (
|
||||
<div className="confirm-overlay" onMouseDown={() => setParamPrompt(null)}>
|
||||
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-3">{paramPrompt.title}</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={paramValue}
|
||||
onChange={(e) => setParamValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitParam()}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
|
||||
placeholder={paramPrompt.action.includes("phone") ? "+7 9123456789" : undefined}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={submitParam} className="btn-primary flex-1">OK</button>
|
||||
<button onClick={() => setParamPrompt(null)} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmPrompt && (
|
||||
<div className="confirm-overlay" onMouseDown={() => setConfirmPrompt(null)}>
|
||||
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-3">{confirmPrompt.title}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => {
|
||||
onAction(account.id, confirmPrompt.action, {});
|
||||
setConfirmPrompt(null);
|
||||
}}
|
||||
className="btn-primary flex-1"
|
||||
>
|
||||
{t("confirm.yes")}
|
||||
</button>
|
||||
<button onClick={() => setConfirmPrompt(null)} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import type { AccountCreate } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onSave: (data: AccountCreate) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddModal({ onSave, onClose }: Props) {
|
||||
const t = useT();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [proxy, setProxy] = useState("");
|
||||
|
||||
const handleSave = () => {
|
||||
if (!login || !password) return;
|
||||
onSave({
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
proxy: proxy || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
|
||||
<div className="space-y-3">
|
||||
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IconLock, IconUnlock } from "@/components/shared/Icons";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
}
|
||||
|
||||
const STATUS_KEYS: Record<string, [string, string]> = {
|
||||
unknown: ["badge-unknown", "status.unknown"],
|
||||
valid: ["badge-ok", "status.valid"],
|
||||
invalid: ["badge-error", "status.invalid"],
|
||||
locked: ["badge-error", "status.locked"],
|
||||
limited: ["badge-running", "status.limited"],
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: Props) {
|
||||
const entry = STATUS_KEYS[status];
|
||||
const [cls, text] = entry ? [entry[0], t(entry[1])] : ["badge-unknown", status];
|
||||
return <span className={`badge ${cls}`}>{text}</span>;
|
||||
}
|
||||
|
||||
export function BanBadge({ status }: { status: string | null }) {
|
||||
if (status === "BANNED") return <span className="badge badge-error">{t("status.banned")}</span>;
|
||||
if (status === "NO BAN") return <span className="badge badge-ok">{t("status.ok")}</span>;
|
||||
return <span className="badge badge-unknown">—</span>;
|
||||
}
|
||||
|
||||
export function LockBadge({ status }: Props) {
|
||||
if (status === "locked") return <span className="badge badge-error"><IconLock size={12} /></span>;
|
||||
return <span className="badge badge-ok"><IconUnlock size={12} /></span>;
|
||||
}
|
||||
|
||||
export function MafileBadge({ hasMafile }: { hasMafile: boolean }) {
|
||||
return hasMafile
|
||||
? <span className="badge badge-ok">✓</span>
|
||||
: <span className="badge badge-unknown">—</span>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import type { ColumnSettings } from "@/api/types";
|
||||
import { IconSettings } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
|
||||
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
|
||||
steam_id: "col.steamId", login: "col.login", password: "col.password",
|
||||
login_pass: "col.loginPass", email: "col.email", email_pass: "col.emailPass",
|
||||
email_login_pass: "col.emailLoginPass", phone: "col.phone", status: "col.status",
|
||||
ban: "col.ban", twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
|
||||
notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
export function ColumnSettingsDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cols = useUiStore((s) => s.columnVisibility);
|
||||
const toggleColumn = useUiStore((s) => s.toggleColumn);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="btn-secondary text-sm px-2 py-2"
|
||||
title={t("tip.columnSettings")}
|
||||
>
|
||||
<IconSettings size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
|
||||
{(Object.keys(COL_LABEL_KEYS) as (keyof ColumnSettings)[]).map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cols[key]}
|
||||
onChange={() => toggleColumn(key)}
|
||||
/>
|
||||
{t(COL_LABEL_KEYS[key])}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import type { Account, AccountUpdate } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onSave: (id: number, data: AccountUpdate) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditModal({ account, onSave, onClose }: Props) {
|
||||
const t = useT();
|
||||
const [login, setLogin] = useState(account.login);
|
||||
const [password, setPassword] = useState(account.password);
|
||||
const [email, setEmail] = useState(account.email ?? "");
|
||||
const [proxy, setProxy] = useState(account.proxy ?? "");
|
||||
const [notes, setNotes] = useState(account.notes ?? "");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { ref.current?.focus(); }, []);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(account.id, {
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
proxy: proxy || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.editAccount")}</h3>
|
||||
<div className="space-y-3">
|
||||
<input ref={ref} value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notes")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<button onClick={handleSave} className="btn-primary w-full">{t("btn.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { IconUpload } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportModal({ onClose }: Props) {
|
||||
const t = useT();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<string>("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadAccounts = useAccountStore((s) => s.loadAccounts);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await api.importAccounts(file);
|
||||
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
|
||||
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
|
||||
<div className="text-sm text-gray-400 mb-3 space-y-1">
|
||||
<p className="font-medium text-gray-300">{t("import.formats")} <span className="text-gray-500 font-normal">({t("import.delimiter")} <code className="text-accent">:</code> {t("import.or")} <code className="text-accent">|</code>)</span></p>
|
||||
<p className="font-mono text-xs">login:pass:{"{"}mafile{"}"}</p>
|
||||
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}</p>
|
||||
<p className="font-mono text-xs">login:pass:email:email_pass</p>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
|
||||
>
|
||||
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
|
||||
</p>
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}} />
|
||||
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
|
||||
{uploading ? t("import.uploading") : t("import.importBtn")}
|
||||
</button>
|
||||
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useLogpassStore } from "@/stores/logpassStore";
|
||||
import type { LogpassAccountCreate } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LogpassAddModal({ onClose }: Props) {
|
||||
const t = useT();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [proxy, setProxy] = useState("");
|
||||
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!login || !password) return;
|
||||
const data: LogpassAccountCreate = {
|
||||
login,
|
||||
password,
|
||||
proxy: proxy || undefined,
|
||||
};
|
||||
await api.createLogpassAccount(data);
|
||||
await loadAccounts();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
|
||||
<div className="space-y-3">
|
||||
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import type { LogpassColumnSettings } from "@/api/types";
|
||||
import { IconSettings } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const COL_LABEL_KEYS: Record<keyof LogpassColumnSettings, string> = {
|
||||
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
|
||||
steam_id: "col.steamId", login: "col.login", password: "col.password",
|
||||
login_pass: "col.loginPass", status: "col.status", ban: "col.ban",
|
||||
prime: "col.prime", trophy: "col.trophy", behavior: "col.behavior",
|
||||
license: "col.license", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
export function LogpassColumnSettingsDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cols = useUiStore((s) => s.logpassColumnVisibility);
|
||||
const toggleColumn = useUiStore((s) => s.toggleLogpassColumn);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="btn-secondary text-sm px-2 py-2"
|
||||
title={t("tip.columnSettings")}
|
||||
>
|
||||
<IconSettings size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
|
||||
{(Object.keys(COL_LABEL_KEYS) as (keyof LogpassColumnSettings)[]).map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cols[key]}
|
||||
onChange={() => toggleColumn(key)}
|
||||
/>
|
||||
{t(COL_LABEL_KEYS[key])}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useLogpassStore } from "@/stores/logpassStore";
|
||||
import type { LogpassAccount } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
account: LogpassAccount;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LogpassEditModal({ account, onClose }: Props) {
|
||||
const t = useT();
|
||||
const [login, setLogin] = useState(account.login);
|
||||
const [password, setPassword] = useState(account.password);
|
||||
const [proxy, setProxy] = useState(account.proxy ?? "");
|
||||
const [notes, setNotes] = useState(account.notes ?? "");
|
||||
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!login || !password) return;
|
||||
await api.updateLogpassAccount(account.id, {
|
||||
login,
|
||||
password,
|
||||
proxy: proxy || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
await loadAccounts();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.editAccount")}</h3>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
autoFocus
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder={t("ph.login")}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("ph.password")}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
placeholder={t("ph.proxyOptional")}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t("ph.notes")}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} className="btn-primary flex-1">{t("btn.save")}</button>
|
||||
<button onClick={onClose} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useLogpassStore } from "@/stores/logpassStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { IconUpload } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LogpassImportModal({ onClose }: Props) {
|
||||
const t = useT();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [result, setResult] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
|
||||
const res = await api.importLogpass(lines);
|
||||
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
|
||||
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
|
||||
<div className="text-sm text-gray-400 mb-3 space-y-1">
|
||||
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
|
||||
<p className="font-mono text-xs">login:pass</p>
|
||||
<p className="font-mono text-xs">login|pass</p>
|
||||
<p className="font-mono text-xs">CSV: login,password,steam_id,ban,prime,trophy,behavior,license</p>
|
||||
<p className="text-xs text-yellow-500/80 mt-1">⚠ Mafile-файлы и JSON не принимаются. Только log:pass / log|pass.</p>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
|
||||
>
|
||||
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{file ? t("import.selected", { name: file.name }) : t("import.dragTxtCsv")}
|
||||
</p>
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept=".txt,.csv" className="hidden" onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}} />
|
||||
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
|
||||
{uploading ? t("import.uploading") : t("import.importBtn")}
|
||||
</button>
|
||||
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useLogpassStore } from "@/stores/logpassStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { ConfirmModal } from "@/components/shared/Modals";
|
||||
import { LogpassImportModal } from "./LogpassImportModal";
|
||||
import { LogpassAddModal } from "./LogpassAddModal";
|
||||
import { LogpassEditModal } from "./LogpassEditModal";
|
||||
import { LogpassColumnSettingsDropdown } from "./LogpassColumnSettingsDropdown";
|
||||
import { StatusBadge, BanBadge } from "./Badges";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import {
|
||||
IconDownload, IconPlus, IconPlay, IconTrash, IconCheckCircle, IconXCircle,
|
||||
IconEye, IconEyeOff, IconGlobe, IconRefresh, IconBan,
|
||||
IconCheck, IconAlertTriangle, IconEdit,
|
||||
} from "@/components/shared/Icons";
|
||||
import type { Task, LogpassColumnSettings } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type AccResults = Record<string, { status: string; error?: string }>;
|
||||
type AccSteps = Record<string, { step: number; total: number }>;
|
||||
|
||||
function parseAccResults(raw: Task["account_results"]): AccResults | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw) as AccResults; } catch { return null; }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function LogpassTab() {
|
||||
const accounts = useLogpassStore((s) => s.accounts);
|
||||
const selectedIds = useLogpassStore((s) => s.selectedIds);
|
||||
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
|
||||
const selectAll = useLogpassStore((s) => s.selectAll);
|
||||
const clearSelection = useLogpassStore((s) => s.clearSelection);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const cols = useUiStore((s) => s.logpassColumnVisibility);
|
||||
const t = useT();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editAccountId, setEditAccountId] = useState<number | null>(null);
|
||||
const [confirmModal, setConfirmModal] = useState<{ open: boolean; message: string; onConfirm: () => void }>({ open: false, message: "", onConfirm: () => {} });
|
||||
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
|
||||
const [accountResults, setAccountResults] = useState<AccResults>({});
|
||||
const [accountSteps, setAccountSteps] = useState<AccSteps>({});
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const BATCH = 100;
|
||||
const [visibleCount, setVisibleCount] = useState(BATCH);
|
||||
|
||||
type SortField = "last_online" | "prime" | "trophy" | "behavior" | null;
|
||||
type SortDir = "asc" | "desc";
|
||||
const [sortField, setSortField] = useState<SortField>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortDir === "asc") setSortDir("desc");
|
||||
else { setSortField(null); setSortDir("asc"); }
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
const sentinelRef = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
useEffect(() => { loadAccounts(); }, [loadAccounts]);
|
||||
|
||||
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
|
||||
|
||||
const watchTask = useCallback((taskId: string) => {
|
||||
const es = new EventSource(`/api/tasks/${taskId}/stream`);
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data) as Task;
|
||||
setActiveTask(data);
|
||||
if (data.account_results) {
|
||||
const r = parseAccResults(data.account_results);
|
||||
if (r) setAccountResults(r);
|
||||
}
|
||||
if (data.account_steps) setAccountSteps(data.account_steps as AccSteps);
|
||||
if (data.status !== "running") {
|
||||
es.close();
|
||||
setProcessingIds(new Set());
|
||||
setActiveTask(null);
|
||||
loadAccounts();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
const ids = [...selectedIds];
|
||||
if (!ids.length) return;
|
||||
clearSelection();
|
||||
setProcessingIds(new Set(ids));
|
||||
setAccountResults({});
|
||||
setAccountSteps({});
|
||||
try {
|
||||
const { task_id } = await api.validateLogpass(ids);
|
||||
setActiveTask({ id: task_id, type: "logpass_validate", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(task_id);
|
||||
} catch (err) {
|
||||
setProcessingIds(new Set());
|
||||
addToast("error", String(err));
|
||||
}
|
||||
}, [selectedIds, clearSelection, watchTask, addToast]);
|
||||
|
||||
const handleValidateSingle = useCallback(async (id: number) => {
|
||||
setProcessingIds(new Set([id]));
|
||||
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; });
|
||||
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; });
|
||||
try {
|
||||
const { task_id } = await api.validateLogpass([id]);
|
||||
setActiveTask({ id: task_id, type: "logpass_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(task_id);
|
||||
} catch (err) {
|
||||
setProcessingIds(new Set());
|
||||
addToast("error", String(err));
|
||||
}
|
||||
}, [watchTask, addToast]);
|
||||
|
||||
const handleFullParse = useCallback(async () => {
|
||||
const ids = [...selectedIds];
|
||||
if (!ids.length) return;
|
||||
clearSelection();
|
||||
setProcessingIds(new Set(ids));
|
||||
setAccountResults({});
|
||||
setAccountSteps({});
|
||||
try {
|
||||
const { task_id } = await api.fullParseLogpass(ids);
|
||||
setActiveTask({ id: task_id, type: "logpass_full_parse", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(task_id);
|
||||
} catch (err) {
|
||||
setProcessingIds(new Set());
|
||||
addToast("error", String(err));
|
||||
}
|
||||
}, [selectedIds, clearSelection, watchTask, addToast]);
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selectedIds];
|
||||
if (!ids.length) return;
|
||||
confirm(t("confirm.deleteLogpassSelected", { count: ids.length }), async () => {
|
||||
await api.deleteLogpassBulk(ids);
|
||||
clearSelection();
|
||||
loadAccounts();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
confirm(t("confirm.deleteLogpassAll", { count: accounts.length }), async () => {
|
||||
await api.deleteLogpassBulk(accounts.map((a) => a.id));
|
||||
clearSelection();
|
||||
loadAccounts();
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignProxies = () => {
|
||||
confirm(t("confirm.assignProxies"), async () => {
|
||||
try {
|
||||
const r = await api.assignLogpassProxies();
|
||||
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const handleReassignProxies = () => {
|
||||
confirm(t("confirm.reassignProxiesAll"), async () => {
|
||||
try {
|
||||
const r = await api.reassignLogpassProxies();
|
||||
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearProxies = () => {
|
||||
confirm(t("confirm.clearProxies"), async () => {
|
||||
try {
|
||||
const r = await api.clearLogpassProxies();
|
||||
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery.trim()) return accounts;
|
||||
const q = searchQuery.trim();
|
||||
|
||||
// lvl comparison syntax: lvl>10, lvl<10, lvl>=10, lvl<=10, lvl=10
|
||||
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
|
||||
if (lvlMatch) {
|
||||
const op = lvlMatch[1];
|
||||
const val = parseInt(lvlMatch[2], 10);
|
||||
return accounts.filter((a) => {
|
||||
const lvl = a.steam_level;
|
||||
if (lvl == null) return false;
|
||||
if (op === ">") return lvl > val;
|
||||
if (op === ">=") return lvl >= val;
|
||||
if (op === "<") return lvl < val;
|
||||
if (op === "<=") return lvl <= val;
|
||||
return lvl === val;
|
||||
});
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
return accounts.filter((a) =>
|
||||
a.login.toLowerCase().includes(ql) ||
|
||||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
|
||||
(a.nickname ?? "").toLowerCase().includes(ql) ||
|
||||
(a.notes ?? "").toLowerCase().includes(ql) ||
|
||||
(a.license ?? "").toLowerCase().includes(ql)
|
||||
);
|
||||
}, [accounts, searchQuery]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortField) return filtered;
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const EMPTY = Symbol();
|
||||
return [...filtered].sort((a, b) => {
|
||||
let va: number | typeof EMPTY = EMPTY;
|
||||
let vb: number | typeof EMPTY = EMPTY;
|
||||
if (sortField === "last_online") {
|
||||
const parseOnline = (v: string | null | undefined): number | typeof EMPTY => {
|
||||
if (!v || v === "\u2014") return EMPTY;
|
||||
if (v === "online") return -1;
|
||||
const m = v.match(/^(\d+)([mhd])$/i);
|
||||
if (!m) return EMPTY;
|
||||
const n = parseInt(m[1], 10);
|
||||
if (m[2] === "m") return n;
|
||||
if (m[2] === "h") return n * 60;
|
||||
return n * 1440;
|
||||
};
|
||||
va = parseOnline(a.last_online);
|
||||
vb = parseOnline(b.last_online);
|
||||
} else if (sortField === "prime") {
|
||||
va = a.prime === "Enabled" ? 0 : a.prime === "Disabled" ? 1 : EMPTY;
|
||||
vb = b.prime === "Enabled" ? 0 : b.prime === "Disabled" ? 1 : EMPTY;
|
||||
} else if (sortField === "trophy") {
|
||||
va = a.trophy != null && a.trophy !== "\u2014" ? parseInt(a.trophy, 10) || 0 : EMPTY;
|
||||
vb = b.trophy != null && b.trophy !== "\u2014" ? parseInt(b.trophy, 10) || 0 : EMPTY;
|
||||
} else if (sortField === "behavior") {
|
||||
va = a.behavior != null && a.behavior !== "\u2014" ? parseInt(a.behavior, 10) || 0 : EMPTY;
|
||||
vb = b.behavior != null && b.behavior !== "\u2014" ? parseInt(b.behavior, 10) || 0 : EMPTY;
|
||||
}
|
||||
// Empty values always sink to the bottom
|
||||
if (va === EMPTY && vb === EMPTY) return 0;
|
||||
if (va === EMPTY) return 1;
|
||||
if (vb === EMPTY) return -1;
|
||||
if (va === vb) return 0;
|
||||
return (va < vb ? -1 : 1) * dir;
|
||||
});
|
||||
}, [filtered, sortField, sortDir]);
|
||||
|
||||
useEffect(() => { setVisibleCount(BATCH); }, [searchQuery, sortField, sortDir]);
|
||||
|
||||
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
|
||||
}, { rootMargin: "400px" });
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [sorted.length, visibleCount]);
|
||||
|
||||
const proxyLabels = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
const unique: string[] = [];
|
||||
for (const a of accounts) {
|
||||
if (a.proxy && !unique.includes(a.proxy)) unique.push(a.proxy);
|
||||
}
|
||||
const proxyToNum = new Map<string, number>();
|
||||
unique.forEach((p, i) => proxyToNum.set(p, i + 1));
|
||||
for (const a of accounts) {
|
||||
if (a.proxy) map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
|
||||
}
|
||||
return map;
|
||||
}, [accounts]);
|
||||
|
||||
const handleOpenBrowser = async (id: number) => {
|
||||
try {
|
||||
const result = await api.openLogpassBrowser(id);
|
||||
if (result.status === "revalidating") {
|
||||
addToast("warn", t("toast.cookiesExpiredRevalidating"));
|
||||
if (result.task_id) {
|
||||
setProcessingIds(new Set([id]));
|
||||
setActiveTask({ id: result.task_id, type: "logpass_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(result.task_id);
|
||||
}
|
||||
} else {
|
||||
addToast("success", t("toast.browserOpening"));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
addToast("error", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full min-h-0">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
|
||||
|
||||
{/* Progress indicator — exact match to AccountsTab */}
|
||||
{activeTask && (
|
||||
<>
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{t("task.validate")}</span>
|
||||
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
|
||||
<div
|
||||
className={`h-2.5 rounded-full transition-all duration-300 ${
|
||||
activeTask.status === "completed" ? "bg-green-500" :
|
||||
activeTask.status === "failed" ? "bg-red-500" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${activeTask.total > 1
|
||||
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
|
||||
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{activeTask.total > 1
|
||||
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
|
||||
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
|
||||
{activeTask.total > 1
|
||||
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
|
||||
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
|
||||
</span>
|
||||
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
|
||||
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LogpassColumnSettingsDropdown />
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
|
||||
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table with action bar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
{/* Action bar */}
|
||||
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={selectedIds.size === 0 || processingIds.size > 0}
|
||||
className="btn-accent text-sm disabled:opacity-40"
|
||||
>
|
||||
<IconPlay size={12} className="inline mr-1" />{t("btn.validate")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFullParse}
|
||||
disabled={selectedIds.size === 0 || processingIds.size > 0}
|
||||
className="btn-secondary text-sm disabled:opacity-40"
|
||||
>
|
||||
<IconPlay size={12} className="inline mr-1" />{t("btn.fullParse")}
|
||||
</button>
|
||||
{activeTask && activeTask.status === "running" && (
|
||||
<button
|
||||
onClick={async () => { try { await api.cancelTask(activeTask.id); } catch {} }}
|
||||
className="btn-danger text-sm"
|
||||
>
|
||||
<IconXCircle size={12} className="inline mr-1" />{t("btn.cancel")}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
|
||||
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
|
||||
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
|
||||
<div className="ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("ph.searchLogpass")}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-dark-800 z-10">
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
|
||||
<th className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} />
|
||||
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th className="w-5" />
|
||||
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>}
|
||||
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>}
|
||||
{cols.last_online && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}>{t("col.lastOnline")}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
|
||||
{cols.steam_id && <th className="px-3 py-2">Steam ID</th>}
|
||||
{cols.login && <th className="px-3 py-2">{t("col.login")}</th>}
|
||||
{cols.password && <th className="px-3 py-2">{t("col.password")}</th>}
|
||||
{cols.login_pass && <th className="px-3 py-2">Login:Pass</th>}
|
||||
{cols.status && <th className="px-3 py-2">{t("col.status")}</th>}
|
||||
{cols.ban && <th className="px-3 py-2">{t("col.ban")}</th>}
|
||||
{cols.prime && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("prime")}>Prime{sortField === "prime" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
|
||||
{cols.trophy && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("trophy")}>Trophy{sortField === "trophy" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
|
||||
{cols.behavior && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("behavior")}>Behavior{sortField === "behavior" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
|
||||
{cols.license && <th className="px-3 py-2">{t("col.license")}</th>}
|
||||
{cols.proxy && <th className="px-3 py-2">{t("col.proxy")}</th>}
|
||||
{cols.notes && <th className="px-3 py-2">{t("col.notes")}</th>}
|
||||
{cols.actions && <th className="px-3 py-2">{t("col.actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan={16} className="text-center py-12 text-gray-500">{t("empty.logpass")}</td></tr>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((a) => (
|
||||
<LogpassRow
|
||||
key={a.id}
|
||||
account={a}
|
||||
cols={cols}
|
||||
isProcessing={processingIds.has(a.id)}
|
||||
accountResult={accountResults[String(a.id)]}
|
||||
accountStep={accountSteps[String(a.id)]}
|
||||
proxyLabel={proxyLabels.get(a.id) ?? null}
|
||||
onDelete={(id) => confirm(t("confirm.deleteLogpass", { name: a.login }), async () => { await api.deleteLogpassAccount(id); loadAccounts(); })}
|
||||
onOpenBrowser={handleOpenBrowser}
|
||||
onValidate={handleValidateSingle}
|
||||
onEdit={(id) => setEditAccountId(id)}
|
||||
/>
|
||||
))}
|
||||
{visibleCount < filtered.length && (
|
||||
<tr ref={sentinelRef}><td colSpan={16} className="text-center py-3 text-xs text-gray-500">{t("paging.shown", { visible: visibleCount, total: filtered.length })}</td></tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editAccountId != null && (() => {
|
||||
const acc = accounts.find((a) => a.id === editAccountId);
|
||||
return acc ? <LogpassEditModal account={acc} onClose={() => setEditAccountId(null)} /> : null;
|
||||
})()}
|
||||
{showImport && <LogpassImportModal onClose={() => setShowImport(false)} />}
|
||||
{showAdd && <LogpassAddModal onClose={() => setShowAdd(false)} />}
|
||||
{confirmModal.open && (
|
||||
<ConfirmModal
|
||||
message={confirmModal.message}
|
||||
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
|
||||
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Row component ─── */
|
||||
|
||||
function LogpassRow({ account: a, cols, isProcessing, accountResult, accountStep, proxyLabel, onDelete, onOpenBrowser, onValidate, onEdit }: {
|
||||
account: import("@/api/types").LogpassAccount;
|
||||
cols: LogpassColumnSettings;
|
||||
isProcessing: boolean;
|
||||
accountResult?: { status: string; error?: string };
|
||||
accountStep?: { step: number; total: number };
|
||||
proxyLabel: string | null;
|
||||
onDelete: (id: number) => void;
|
||||
onOpenBrowser: (id: number) => void;
|
||||
onValidate: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
}) {
|
||||
const selectedIds = useLogpassStore((s) => s.selectedIds);
|
||||
const toggleSelect = useLogpassStore((s) => s.toggleSelect);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const hidePasswords = useUiStore((s) => s.hidePasswords);
|
||||
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [editingNotes, setEditingNotes] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(a.notes || "");
|
||||
const [licExpanded, setLicExpanded] = useState(false);
|
||||
|
||||
const t = useT();
|
||||
const masked = hidePasswords && !revealed;
|
||||
const dots = "••••••••";
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
const ok = await copyText(text);
|
||||
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-dark-700/50 transition">
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => toggleSelect(a.id)} />
|
||||
</td>
|
||||
{/* Progress/status icon */}
|
||||
<td className="px-1 py-2 w-5">
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
{accountStep && <span className="text-[10px] text-gray-500 whitespace-nowrap">[{accountStep.step}/{accountStep.total}]</span>}
|
||||
</div>
|
||||
) : accountResult?.status === "ok" ? (
|
||||
<IconCheck size={16} className="text-gray-400" />
|
||||
) : accountResult?.status === "error" ? (
|
||||
<span title={accountResult.error}><IconAlertTriangle size={16} className="text-red-400" /></span>
|
||||
) : null}
|
||||
</td>
|
||||
{/* Browser login button */}
|
||||
{cols.browser && (
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center">
|
||||
{a.has_cookies ? (
|
||||
<button
|
||||
onClick={() => onOpenBrowser(a.id)}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
|
||||
title={t("tip.openBrowser")}
|
||||
>{t("col.browser")}</button>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{/* Profile — avatar + nickname + level */}
|
||||
{cols.profile && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
|
||||
<span className="text-xs">{a.nickname || "—"}</span>
|
||||
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{/* Last online */}
|
||||
{cols.last_online && (
|
||||
<td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">
|
||||
{a.last_online || "—"}
|
||||
</td>
|
||||
)}
|
||||
{/* Steam ID */}
|
||||
{cols.steam_id && (
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{a.steam_id ? (
|
||||
<a href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 hover:underline">
|
||||
{a.steam_id}
|
||||
</a>
|
||||
) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{/* Login */}
|
||||
{cols.login && <td className="px-3 py-2 font-medium">{a.login}</td>}
|
||||
{/* Password */}
|
||||
{cols.password && (
|
||||
<td className="px-3 py-2 text-gray-400 select-none whitespace-nowrap">
|
||||
<span className="cursor-pointer" onClick={() => handleCopy(a.password)} title={t("tip.copyPassword")}>
|
||||
{masked ? dots : a.password}
|
||||
</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }} className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle" title={masked ? t("tip.show") : t("tip.hide")}>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{/* Login:Pass */}
|
||||
{cols.login_pass && (
|
||||
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
|
||||
<span className="cursor-pointer" onClick={() => handleCopy(`${a.login}:${a.password}`)} title={t("tip.copyLoginPass")}>
|
||||
{masked ? `${a.login}:${dots}` : `${a.login}:${a.password}`}
|
||||
</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }} className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle" title={masked ? t("tip.show") : t("tip.hide")}>
|
||||
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
{/* Status */}
|
||||
{cols.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
|
||||
{/* Ban */}
|
||||
{cols.ban && <td className="px-3 py-2"><BanBadge status={a.ban_status} /></td>}
|
||||
{/* Prime, Trophy, Behavior */}
|
||||
{cols.prime && <td className="px-3 py-2 text-xs text-gray-400">{a.prime ?? "—"}</td>}
|
||||
{cols.trophy && <td className="px-3 py-2 text-xs text-gray-400">{a.trophy ?? "—"}</td>}
|
||||
{cols.behavior && <td className="px-3 py-2 text-xs text-gray-400">{a.behavior ?? "—"}</td>}
|
||||
{/* License — expandable on click */}
|
||||
{cols.license && (
|
||||
<td
|
||||
className={`px-3 py-2 text-xs text-gray-400 cursor-pointer select-none ${licExpanded ? "max-w-none whitespace-normal break-words" : "truncate max-w-[200px] whitespace-nowrap"}`}
|
||||
title={licExpanded ? undefined : (a.license ?? "")}
|
||||
onClick={() => setLicExpanded((v) => !v)}
|
||||
>
|
||||
{a.license ?? "—"}
|
||||
</td>
|
||||
)}
|
||||
{/* Proxy */}
|
||||
{cols.proxy && (
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{a.proxy ? (
|
||||
<span className="text-blue-400 cursor-pointer hover:underline" onClick={() => handleCopy(a.proxy!)} title={a.proxy}>
|
||||
{proxyLabel || a.proxy}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{/* Notes — inline editable */}
|
||||
{cols.notes && (
|
||||
<td className="px-3 py-2 text-xs max-w-[180px]">
|
||||
{editingNotes ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-dark-600 border border-dark-500 rounded px-1.5 py-0.5 text-xs text-gray-200 outline-none focus:border-accent"
|
||||
value={notesValue}
|
||||
onChange={(e) => setNotesValue(e.target.value)}
|
||||
onBlur={async () => {
|
||||
setEditingNotes(false);
|
||||
if (notesValue !== (a.notes || "")) {
|
||||
try {
|
||||
await api.updateLogpassAccount(a.id, { notes: notesValue });
|
||||
addToast("success", t("toast.noteSaved"));
|
||||
loadAccounts();
|
||||
} catch { addToast("error", t("toast.noteSaveError")); }
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setNotesValue(a.notes || ""); setEditingNotes(false); } }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="cursor-pointer text-gray-400 hover:text-gray-200 truncate block"
|
||||
onClick={() => { setNotesValue(a.notes || ""); setEditingNotes(true); }}
|
||||
title={a.notes || t("tip.addNote")}
|
||||
>
|
||||
{a.notes || "—"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{/* Actions */}
|
||||
{cols.actions && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => onValidate(a.id)} disabled={isProcessing} className="text-gray-400 hover:text-accent disabled:opacity-40" title={t("tip.validate")}><IconPlay size={14} /></button>
|
||||
<button onClick={() => onEdit(a.id)} className="text-gray-400 hover:text-accent" title={t("tip.edit")}><IconEdit size={14} /></button>
|
||||
<button onClick={() => onDelete(a.id)} className="text-gray-400 hover:text-red-400" title={t("tip.delete")}><IconTrash size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
interface Props {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function SteamLevelBadge({ level }: Props) {
|
||||
if (level >= 100) {
|
||||
const hundred = Math.floor(level / 100) * 100;
|
||||
return (
|
||||
<div className={`steam-lvl l100p img-${hundred}`} title={`Level ${level}`}>
|
||||
<span>{level}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ten = Math.floor(level / 10) * 10;
|
||||
return (
|
||||
<div className={`steam-lvl l${ten}`} title={`Level ${level}`}>
|
||||
<span>{level}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useTokenStore } from "@/stores/tokenStore";
|
||||
import type { TokenAccountCreate } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TokenAddModal({ onClose }: Props) {
|
||||
const t = useT();
|
||||
const [token, setToken] = useState("");
|
||||
const [login, setLogin] = useState("");
|
||||
const [proxy, setProxy] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const loadAccounts = useTokenStore((s) => s.loadAccounts);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token.trim()) return;
|
||||
const data: TokenAccountCreate = {
|
||||
token: token.trim(),
|
||||
login: login.trim() || undefined,
|
||||
proxy: proxy.trim() || undefined,
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
await api.createTokenAccount(data);
|
||||
await loadAccounts();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.addToken")}</h3>
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Refresh token"
|
||||
rows={3}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm font-mono resize-none"
|
||||
/>
|
||||
<input value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.loginOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notesOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<button onClick={handleSave} disabled={!token.trim()} className="btn-primary w-full disabled:opacity-40">{t("btn.add")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import type { TokenColumnSettings } from "@/api/types";
|
||||
import { IconSettings } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const COL_LABEL_KEYS: Record<keyof TokenColumnSettings, string> = {
|
||||
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
|
||||
steam_id: "col.steamId", login: "col.login", token: "col.token",
|
||||
status: "col.status", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
|
||||
};
|
||||
|
||||
export function TokenColumnSettingsDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cols = useUiStore((s) => s.tokenColumnVisibility);
|
||||
const toggleColumn = useUiStore((s) => s.toggleTokenColumn);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="btn-secondary text-sm px-2 py-2"
|
||||
title={t("tip.columnSettings")}
|
||||
>
|
||||
<IconSettings size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
|
||||
{(Object.keys(COL_LABEL_KEYS) as (keyof TokenColumnSettings)[]).map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cols[key]}
|
||||
onChange={() => toggleColumn(key)}
|
||||
/>
|
||||
{t(COL_LABEL_KEYS[key])}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useTokenStore } from "@/stores/tokenStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { IconUpload } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TokenImportModal({ onClose }: Props) {
|
||||
const t = useT();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [result, setResult] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadAccounts = useTokenStore((s) => s.loadAccounts);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) setFile(f);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
|
||||
const res = await api.importTokens(lines);
|
||||
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
|
||||
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
|
||||
await loadAccounts();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.importTokens")}</h3>
|
||||
<div className="text-sm text-gray-400 mb-3 space-y-1">
|
||||
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
|
||||
<p className="font-mono text-xs">{t("import.tokenFormat")}</p>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
|
||||
>
|
||||
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
|
||||
</p>
|
||||
</div>
|
||||
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setFile(f);
|
||||
}} />
|
||||
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
|
||||
{uploading ? t("import.uploading") : t("import.importBtn")}
|
||||
</button>
|
||||
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useCallback, useState, useMemo } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useTokenStore } from "@/stores/tokenStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { ConfirmModal } from "@/components/shared/Modals";
|
||||
import { StatusBadge } from "./Badges";
|
||||
import { SteamLevelBadge } from "./SteamLevelBadge";
|
||||
import { TokenImportModal } from "./TokenImportModal";
|
||||
import { TokenAddModal } from "./TokenAddModal";
|
||||
import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown";
|
||||
import {
|
||||
IconDownload, IconPlus, IconTrash, IconPlay, IconGlobe, IconRefresh, IconBan,
|
||||
IconCheckCircle, IconXCircle, IconCheck, IconAlertTriangle,
|
||||
} from "@/components/shared/Icons";
|
||||
import type { Task } from "@/api/types";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type AccResults = Record<string, { status: string; error?: string }>;
|
||||
type AccSteps = Record<string, { step: number; total: number }>;
|
||||
|
||||
function parseAccResults(raw: Task["account_results"]): AccResults | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw) as AccResults; } catch { return null; }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function TokenTab() {
|
||||
const accounts = useTokenStore((s) => s.accounts);
|
||||
const selectedIds = useTokenStore((s) => s.selectedIds);
|
||||
const loadAccounts = useTokenStore((s) => s.loadAccounts);
|
||||
const toggleSelect = useTokenStore((s) => s.toggleSelect);
|
||||
const selectAll = useTokenStore((s) => s.selectAll);
|
||||
const clearSelection = useTokenStore((s) => s.clearSelection);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const cols = useUiStore((s) => s.tokenColumnVisibility);
|
||||
const loadTokenColumnSettings = useUiStore((s) => s.loadTokenColumnSettings);
|
||||
const t = useT();
|
||||
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState<{ open: boolean; message: string; onConfirm: () => void }>({ open: false, message: "", onConfirm: () => {} });
|
||||
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
|
||||
const [accountResults, setAccountResults] = useState<AccResults>({});
|
||||
const [accountSteps, setAccountSteps] = useState<AccSteps>({});
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => { loadAccounts(); loadTokenColumnSettings(); }, [loadAccounts, loadTokenColumnSettings]);
|
||||
|
||||
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
|
||||
|
||||
const watchTask = useCallback((taskId: string) => {
|
||||
const es = new EventSource(`/api/tasks/${taskId}/stream`);
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data) as Task;
|
||||
setActiveTask(data);
|
||||
if (data.account_results) {
|
||||
const r = parseAccResults(data.account_results);
|
||||
if (r) setAccountResults(r);
|
||||
}
|
||||
if (data.account_steps) setAccountSteps(data.account_steps as AccSteps);
|
||||
if (data.status !== "running") {
|
||||
es.close();
|
||||
setProcessingIds(new Set());
|
||||
loadAccounts();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
es.onerror = () => es.close();
|
||||
}, [loadAccounts]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
const ids = [...selectedIds];
|
||||
if (!ids.length) return;
|
||||
clearSelection();
|
||||
setProcessingIds(new Set(ids));
|
||||
setAccountResults({});
|
||||
setAccountSteps({});
|
||||
try {
|
||||
const { task_id } = await api.validateTokens(ids);
|
||||
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(task_id);
|
||||
} catch (err) {
|
||||
setProcessingIds(new Set());
|
||||
addToast("error", String(err));
|
||||
}
|
||||
}, [selectedIds, clearSelection, watchTask, addToast]);
|
||||
|
||||
const handleValidateSingle = useCallback(async (id: number) => {
|
||||
setProcessingIds(new Set([id]));
|
||||
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; });
|
||||
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; });
|
||||
try {
|
||||
const { task_id } = await api.validateTokens([id]);
|
||||
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(task_id);
|
||||
} catch (err) {
|
||||
setProcessingIds(new Set());
|
||||
addToast("error", String(err));
|
||||
}
|
||||
}, [watchTask, addToast]);
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selectedIds];
|
||||
if (!ids.length) return;
|
||||
confirm(t("confirm.deleteTokenSelected", { count: ids.length }), async () => {
|
||||
await api.deleteTokenBulk(ids);
|
||||
clearSelection();
|
||||
loadAccounts();
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
confirm(t("confirm.deleteTokenAll", { count: accounts.length }), async () => {
|
||||
await api.deleteTokenBulk(accounts.map((a) => a.id));
|
||||
clearSelection();
|
||||
loadAccounts();
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignProxies = () => {
|
||||
confirm(t("confirm.assignProxies"), async () => {
|
||||
try {
|
||||
const r = await api.assignTokenProxies();
|
||||
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const handleReassignProxies = () => {
|
||||
confirm(t("confirm.reassignProxiesAll"), async () => {
|
||||
try {
|
||||
const r = await api.reassignTokenProxies();
|
||||
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearProxies = () => {
|
||||
confirm(t("confirm.clearProxies"), async () => {
|
||||
try {
|
||||
const r = await api.clearTokenProxies();
|
||||
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
|
||||
loadAccounts();
|
||||
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenBrowser = async (id: number) => {
|
||||
try {
|
||||
const result = await api.openTokenBrowser(id);
|
||||
if (result.status === "revalidating") {
|
||||
addToast("warn", t("toast.cookiesExpiredRevalidating"));
|
||||
if (result.task_id) {
|
||||
setProcessingIds(new Set([id]));
|
||||
setActiveTask({ id: result.task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
|
||||
watchTask(result.task_id);
|
||||
}
|
||||
} else {
|
||||
addToast("success", t("toast.browserOpening"));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
addToast("error", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery.trim()) return accounts;
|
||||
const q = searchQuery.trim();
|
||||
|
||||
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
|
||||
if (lvlMatch) {
|
||||
const op = lvlMatch[1];
|
||||
const val = parseInt(lvlMatch[2], 10);
|
||||
return accounts.filter((a) => {
|
||||
const lvl = a.steam_level;
|
||||
if (lvl == null) return false;
|
||||
if (op === ">") return lvl > val;
|
||||
if (op === ">=") return lvl >= val;
|
||||
if (op === "<") return lvl < val;
|
||||
if (op === "<=") return lvl <= val;
|
||||
return lvl === val;
|
||||
});
|
||||
}
|
||||
|
||||
const ql = q.toLowerCase();
|
||||
return accounts.filter((a) =>
|
||||
(a.login ?? "").toLowerCase().includes(ql) ||
|
||||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
|
||||
(a.nickname ?? "").toLowerCase().includes(ql) ||
|
||||
(a.notes ?? "").toLowerCase().includes(ql)
|
||||
);
|
||||
}, [accounts, searchQuery]);
|
||||
|
||||
const proxyLabels = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
const unique: string[] = [];
|
||||
for (const a of accounts) {
|
||||
if (a.proxy && !unique.includes(a.proxy)) unique.push(a.proxy);
|
||||
}
|
||||
const proxyToNum = new Map<string, number>();
|
||||
unique.forEach((p, i) => proxyToNum.set(p, i + 1));
|
||||
for (const a of accounts) {
|
||||
if (a.proxy) map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
|
||||
}
|
||||
return map;
|
||||
}, [accounts, t]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-4 h-full min-h-0">
|
||||
{/* Development warning gate */}
|
||||
{!disclaimerAccepted && (
|
||||
<div className="absolute inset-0 z-50 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="bg-dark-800 border border-yellow-500/40 rounded-xl p-6 max-w-lg mx-4 shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<IconAlertTriangle size={28} className="text-yellow-400 shrink-0" />
|
||||
<h3 className="text-lg font-semibold text-yellow-400">{t("token.disclaimerTitle")}</h3>
|
||||
</div>
|
||||
<div className="text-sm text-gray-300 space-y-2 mb-5">
|
||||
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody1") }} />
|
||||
<p className="text-red-400 font-medium" dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody2") }} />
|
||||
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody3") }} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disclaimerAccepted}
|
||||
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-yellow-500/50 accent-yellow-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition">{t("token.acceptRisk")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{activeTask && (
|
||||
<>
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{t("task.validate")}</span>
|
||||
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
|
||||
<div
|
||||
className={`h-2.5 rounded-full transition-all duration-300 ${
|
||||
activeTask.status === "completed" ? "bg-green-500" :
|
||||
activeTask.status === "failed" ? "bg-red-500" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${activeTask.total > 1
|
||||
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
|
||||
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{activeTask.total > 1
|
||||
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
|
||||
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
|
||||
{activeTask.total > 1
|
||||
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
|
||||
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
|
||||
</span>
|
||||
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
|
||||
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<TokenColumnSettingsDropdown />
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
|
||||
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table with action bar */}
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
{/* Action bar */}
|
||||
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={selectedIds.size === 0 || processingIds.size > 0}
|
||||
className="btn-accent text-sm disabled:opacity-40"
|
||||
>
|
||||
<IconPlay size={12} className="inline mr-1" />{t("btn.validate")}
|
||||
</button>
|
||||
<div className="h-5 border-l border-dark-500" />
|
||||
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
|
||||
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
|
||||
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
|
||||
<div className="ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("ph.search")}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1 min-h-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-dark-800 z-10">
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
|
||||
<th className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} />
|
||||
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th className="w-5" />
|
||||
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>}
|
||||
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>}
|
||||
{cols.last_online && <th className="px-3 py-2">{t("col.lastOnline")}</th>}
|
||||
{cols.steam_id && <th className="px-3 py-2">Steam ID</th>}
|
||||
{cols.login && <th className="px-3 py-2">{t("col.login")}</th>}
|
||||
{cols.token && <th className="px-3 py-2">{t("col.token")}</th>}
|
||||
{cols.status && <th className="px-3 py-2">{t("col.status")}</th>}
|
||||
{cols.proxy && <th className="px-3 py-2">{t("col.proxy")}</th>}
|
||||
{cols.notes && <th className="px-3 py-2">{t("col.notes")}</th>}
|
||||
{cols.actions && <th className="px-3 py-2">{t("col.actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan={12} className="text-center py-12 text-gray-500">{t("empty.tokens")}</td></tr>
|
||||
) : (
|
||||
filtered.map((a) => {
|
||||
const isProcessing = processingIds.has(a.id);
|
||||
const result = accountResults[String(a.id)];
|
||||
const step = accountSteps[String(a.id)];
|
||||
return (
|
||||
<tr key={a.id} className="hover:bg-dark-700/50 transition border-t border-dark-700">
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => toggleSelect(a.id)} />
|
||||
</td>
|
||||
{/* Status icon */}
|
||||
<td className="px-1 py-2 w-5">
|
||||
{isProcessing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
{step && <span className="text-[10px] text-gray-500 whitespace-nowrap">[{step.step}/{step.total}]</span>}
|
||||
</div>
|
||||
) : result?.status === "ok" ? (
|
||||
<IconCheck size={16} className="text-gray-400" />
|
||||
) : result?.status === "error" ? (
|
||||
<span title={result.error}><IconAlertTriangle size={16} className="text-red-400" /></span>
|
||||
) : null}
|
||||
</td>
|
||||
{cols.browser && (
|
||||
<td className="px-3 py-2 whitespace-nowrap text-center">
|
||||
{a.has_cookies ? (
|
||||
<button
|
||||
onClick={() => handleOpenBrowser(a.id)}
|
||||
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
|
||||
title={t("tip.openBrowser")}
|
||||
>{t("col.browser")}</button>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{cols.profile && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
|
||||
<span className="text-xs">{a.nickname || "—"}</span>
|
||||
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{cols.last_online && <td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">{a.last_online || "—"}</td>}
|
||||
{cols.steam_id && (
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{a.steam_id ? (
|
||||
<a href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 hover:underline">
|
||||
{a.steam_id}
|
||||
</a>
|
||||
) : "—"}
|
||||
</td>
|
||||
)}
|
||||
{cols.login && <td className="px-3 py-2 font-medium">{a.login ?? "—"}</td>}
|
||||
{cols.token && <td className="px-3 py-2 font-mono text-gray-400 truncate max-w-[180px]" title={a.token}>{a.token.slice(0, 24)}…</td>}
|
||||
{cols.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
|
||||
{cols.proxy && <td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{proxyLabels.get(a.id) ?? "—"}</td>}
|
||||
{cols.notes && <td className="px-3 py-2 text-xs text-gray-500">{a.notes ?? "—"}</td>}
|
||||
{cols.actions && (
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleValidateSingle(a.id)}
|
||||
disabled={isProcessing}
|
||||
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
|
||||
title={t("tip.validate")}
|
||||
><IconPlay size={14} /></button>
|
||||
<button
|
||||
onClick={() => confirm(t("confirm.deleteToken", { name: a.login ?? a.token.slice(0, 16) }), async () => { await api.deleteTokenAccount(a.id); loadAccounts(); })}
|
||||
className="text-gray-400 hover:text-red-400 transition"
|
||||
title={t("tip.delete")}
|
||||
><IconTrash size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showImport && <TokenImportModal onClose={() => setShowImport(false)} />}
|
||||
{showAdd && <TokenAddModal onClose={() => setShowAdd(false)} />}
|
||||
{confirmModal.open && (
|
||||
<ConfirmModal
|
||||
message={confirmModal.message}
|
||||
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
|
||||
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
export function Header() {
|
||||
const [locale, setLocale] = useLocale();
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.getVersion().then((r) => setVersion(r.version)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="bg-dark-800 border-b border-dark-600 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 cursor-pointer" onClick={() => window.location.reload()}>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" className="w-7 h-7 shrink-0 fill-white">
|
||||
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z"/>
|
||||
</svg>
|
||||
<h1 className="text-lg font-bold text-white tracking-wide">SteamPanel</h1>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{version && `v${version}`}</span>
|
||||
<span className="text-xs text-gray-500">by{" "}
|
||||
<a
|
||||
href="https://t.me/lolzdm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-accent transition"
|
||||
>
|
||||
@lolzdm
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setLocale(locale === "ru" ? "en" : "ru" as Locale)}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-200 transition px-2 py-1 border border-dark-500 hover:border-gray-500 rounded"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="opacity-80">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
{locale === "ru" ? "EN" : "RU"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import type { TabId } from "@/api/types";
|
||||
import { IconUsers, IconFolderOpen, IconWrench, IconScrollText } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type IconComponent = typeof IconUsers;
|
||||
|
||||
const TAB_KEYS: { id: TabId; labelKey: string; icon: IconComponent }[] = [
|
||||
{ id: "accounts", labelKey: "tab.accounts", icon: IconUsers },
|
||||
{ id: "mafiles", labelKey: "tab.mafiles", icon: IconFolderOpen },
|
||||
{ id: "tools", labelKey: "tab.tools", icon: IconWrench },
|
||||
{ id: "logs", labelKey: "tab.logs", icon: IconScrollText },
|
||||
];
|
||||
|
||||
export function TabNav() {
|
||||
const activeTab = useUiStore((s) => s.activeTab);
|
||||
const setTab = useUiStore((s) => s.setTab);
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<nav className="bg-dark-800 border-b border-dark-600 flex gap-1 px-4 overflow-x-auto">
|
||||
{TAB_KEYS.map(({ id, labelKey, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === id
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useUiStore((s) => s.toasts);
|
||||
const removeToast = useUiStore((s) => s.removeToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.type}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { LogEntry } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
import { useSSE } from "@/hooks/useSSE";
|
||||
|
||||
export function LogStream() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autoScroll = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getLogs().then(setLogs).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useSSE<LogEntry>("/api/logs/stream", (entry) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > 500) next.splice(0, next.length - 500);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll.current && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
autoScroll.current = scrollHeight - scrollTop - clientHeight < 40;
|
||||
};
|
||||
|
||||
const levelColor = (level: string): string => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
case "critical": return "text-red-400";
|
||||
case "warning": return "text-yellow-400";
|
||||
case "success": return "text-green-400";
|
||||
case "debug": return "text-gray-500";
|
||||
default: return "text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg flex flex-col" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||||
<div className="px-3 py-2 border-b border-dark-600 flex items-center justify-between shrink-0">
|
||||
<h3 className="font-semibold text-sm">📜 Лог</h3>
|
||||
<button
|
||||
onClick={() => setLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto min-h-0 p-2 font-mono text-xs space-y-px"
|
||||
>
|
||||
{logs.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="text-gray-600 shrink-0">{entry.ts}</span>
|
||||
<span className={`shrink-0 w-3 text-center ${levelColor(entry.level)}`}>{entry.icon}</span>
|
||||
<span className={`${levelColor(entry.level)} break-all min-w-0`}>{entry.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<p className="text-gray-600 text-center py-4">Нет логов</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LogStream } from "./LogStream";
|
||||
|
||||
export function LogsTab() {
|
||||
return <LogStream />;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTaskStore } from "@/stores/taskStore";
|
||||
import { usePolling } from "@/hooks/usePolling";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
export function TaskPanel() {
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const hasRunning = useTaskStore((s) => s.hasRunning);
|
||||
const loadTasks = useTaskStore((s) => s.loadTasks);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
|
||||
// Bug #5: poll every 10s, pause when no running tasks
|
||||
usePolling(loadTasks, hasRunning ? 5_000 : 30_000);
|
||||
|
||||
const cancelTask = async (id: string) => {
|
||||
try {
|
||||
await api.cancelTask(id);
|
||||
addToast("info", "Задача отменена");
|
||||
await loadTasks();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", `Ошибка: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running": return "text-blue-400";
|
||||
case "completed": return "text-green-400";
|
||||
case "failed": return "text-red-400";
|
||||
case "pending": return "text-yellow-400";
|
||||
default: return "text-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg flex flex-col h-full min-h-[300px]">
|
||||
<div className="px-3 py-2 border-b border-dark-600">
|
||||
<h3 className="font-semibold text-sm">⚡ Активные задачи</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-gray-600 text-center text-xs py-4">Нет задач</p>
|
||||
) : (
|
||||
tasks.map((t) => (
|
||||
<div key={t.id} className="bg-dark-700 rounded p-2 text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium">{t.type}</span>
|
||||
<span className={`text-xs ${statusColor(t.status)}`}>{t.status}</span>
|
||||
</div>
|
||||
{t.total > 0 && (
|
||||
<div className="progress-bar mb-1">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${Math.round((t.progress / t.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-gray-500">
|
||||
<span>{t.progress}/{t.total}</span>
|
||||
{(t.status === "running" || t.status === "pending") && (
|
||||
<button onClick={() => cancelTask(t.id)} className="text-red-400 hover:underline">Отмена</button>
|
||||
)}
|
||||
</div>
|
||||
{t.result && <p className="text-green-400 mt-1">{t.result}</p>}
|
||||
{t.error && <p className="text-red-400 mt-1">{t.error}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRef } from "react";
|
||||
import { IconUpload } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface Props {
|
||||
onFiles: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function DropZone({ onFiles }: Props) {
|
||||
const t = useT();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.name.endsWith(".mafile"));
|
||||
if (files.length) onFiles(files);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="border-2 border-dashed border-dark-500 rounded-lg p-6 text-center cursor-pointer transition-colors hover:border-accent"
|
||||
>
|
||||
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
|
||||
<p className="text-sm text-gray-400">{t("import.dragMafile")}</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".mafile"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length) onFiles(files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from "react";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { IconPackage, IconFileText, IconDownload, IconChevronRight } from "@/components/shared/Icons";
|
||||
import type { MafileExportRequest } from "@/api/types";
|
||||
|
||||
const MAFILE_FIELDS = [
|
||||
"shared_secret", "serial_number", "revocation_code", "uri",
|
||||
"account_name", "token_gid", "identity_secret", "secret_1",
|
||||
"device_id", "server_time", "fully_enrolled",
|
||||
];
|
||||
|
||||
const SESSION_FIELDS = [
|
||||
"SessionID", "AccessToken", "RefreshToken", "SteamID", "SteamLoginSecure",
|
||||
];
|
||||
|
||||
const NAME_VARS = [
|
||||
{ value: "{username}", label: "username" },
|
||||
{ value: "{steamid}", label: "steamid" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
accountIds: number[];
|
||||
onExport: (req: MafileExportRequest) => void;
|
||||
}
|
||||
|
||||
export function ExportSettings({ accountIds, onExport }: Props) {
|
||||
const t = useT();
|
||||
const [fields, setFields] = useState<Set<string>>(new Set(MAFILE_FIELDS));
|
||||
const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS));
|
||||
const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder");
|
||||
|
||||
// Naming templates
|
||||
const [folderNameTemplate, setFolderNameTemplate] = useState("{username}");
|
||||
const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile");
|
||||
const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt");
|
||||
|
||||
// .txt options
|
||||
const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false);
|
||||
const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false);
|
||||
const [skipFolders, setSkipFolders] = useState(false);
|
||||
const [txtFormat, setTxtFormat] = useState("{login}:{password}:{email}:{email_password}");
|
||||
|
||||
const toggleField = (set: Set<string>, key: string, setter: (s: Set<string>) => void) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
setter(next);
|
||||
};
|
||||
|
||||
const insertVar = (setter: (fn: (prev: string) => string) => void, varName: string) => {
|
||||
setter((prev) => prev + varName);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
onExport({
|
||||
fields: [...fields],
|
||||
session_fields: [...sessionFields],
|
||||
format,
|
||||
account_ids: accountIds,
|
||||
folder_name_template: folderNameTemplate,
|
||||
mafile_name_template: mafileNameTemplate,
|
||||
txt_name_template: txtNameTemplate,
|
||||
include_txt_per_folder: includeTxtPerFolder,
|
||||
include_global_txt: includeGlobalTxt,
|
||||
skip_folders: skipFolders,
|
||||
txt_format: txtFormat,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4 space-y-4">
|
||||
<h4 className="font-semibold text-sm"><IconPackage size={14} className="inline mr-1" />{t("mafile.exportSettings")}</h4>
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">{t("mafile.exportFormat")}</p>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as MafileExportRequest["format"];
|
||||
setFormat(val);
|
||||
if (val === "single_file") {
|
||||
setIncludeGlobalTxt(false);
|
||||
setSkipFolders(false);
|
||||
}
|
||||
}}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="flat_mafiles">{t("mafile.flatFiles")}</option>
|
||||
<option value="per_account_folder">{t("mafile.perAccountFolder")}</option>
|
||||
<option value="single_file">{t("mafile.singleFile")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Naming templates */}
|
||||
{format !== "single_file" && (
|
||||
<div className="space-y-3 border border-dark-600 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
{t("mafile.variables")} <code className="text-accent">{"{username}"}</code>, <code className="text-accent">{"{steamid}"}</code>
|
||||
</p>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.folderName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={folderNameTemplate}
|
||||
onChange={(e) => setFolderNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setFolderNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
title={t("mafile.insert", { value: v.value })}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.mafileName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={mafileNameTemplate}
|
||||
onChange={(e) => setMafileNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setMafileNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
title={t("mafile.insert", { value: v.value })}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* .txt options */}
|
||||
<div className="space-y-2 border border-dark-600 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400 font-medium"><IconFileText size={12} className="inline mr-1" />{t("mafile.txtSettings")}</p>
|
||||
|
||||
{format !== "single_file" && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeGlobalTxt}
|
||||
onChange={(e) => {
|
||||
setIncludeGlobalTxt(e.target.checked);
|
||||
if (!e.target.checked) setSkipFolders(false);
|
||||
}}
|
||||
/>
|
||||
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code> {t("mafile.withAllAccounts")}
|
||||
</label>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeTxtPerFolder}
|
||||
onChange={(e) => setIncludeTxtPerFolder(e.target.checked)}
|
||||
/>
|
||||
{t("mafile.addTxtPerFolder")}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${includeGlobalTxt ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipFolders}
|
||||
disabled={!includeGlobalTxt}
|
||||
onChange={(e) => setSkipFolders(e.target.checked)}
|
||||
/>
|
||||
{t("mafile.skipFolders")}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
|
||||
<>
|
||||
{format === "per_account_folder" && includeTxtPerFolder && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtFolderName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={txtNameTemplate}
|
||||
onChange={(e) => setTxtNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setTxtNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
{t("mafile.txtLineFormat")}
|
||||
</label>
|
||||
<input
|
||||
value={txtFormat}
|
||||
onChange={(e) => setTxtFormat(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t("mafile.variables")} <code className="text-accent">{"{login}"}</code> <code className="text-accent">{"{password}"}</code> <code className="text-accent">{"{email}"}</code> <code className="text-accent">{"{email_password}"}</code> <code className="text-accent">{"{steam_id}"}</code> <code className="text-accent">{"{proxy}"}</code> <code className="text-accent">{"{mafile}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field selection */}
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.mafileFields")} ({fields.size}/{MAFILE_FIELDS.length})
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{MAFILE_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} />
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.sessionFields")} ({sessionFields.size}/{SESSION_FIELDS.length})
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{SESSION_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} />
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={accountIds.length === 0}
|
||||
className={accountIds.length === 0 ? "btn-primary opacity-50 cursor-not-allowed" : "btn-primary"}
|
||||
>
|
||||
<IconDownload size={14} className="inline mr-1" />{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { api } from "@/api/client";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import type { MafileExportRequest } from "@/api/types";
|
||||
import { ExportSettings } from "./ExportSettings";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
import { IconClipboard } from "@/components/shared/Icons";
|
||||
|
||||
export function MafileTab() {
|
||||
const t = useT();
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds);
|
||||
const clearMafileManager = useAccountStore((s) => s.clearMafileManager);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
// Bug #3: only show explicitly sent accounts
|
||||
const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id));
|
||||
|
||||
const handleExport = async (req: MafileExportRequest) => {
|
||||
try {
|
||||
const blob = await api.exportZip(req);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "mafiles_export.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast("success", t("toast.exportDone"));
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.exportError", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 h-full overflow-y-auto pr-1">
|
||||
{/* Sent accounts list — Bug #3: only sent accounts shown */}
|
||||
{sentAccounts.length > 0 && (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold">
|
||||
<IconClipboard size={14} className="inline mr-1" />{t("mafile.sentAccounts")} ({sentAccounts.length})
|
||||
</h3>
|
||||
<button onClick={clearMafileManager} className="btn-danger-outline text-xs">{t("mafile.clearBtn")}</button>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{sentAccounts.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-xs text-gray-300 min-w-0">
|
||||
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />}
|
||||
<span className="truncate">{a.login}</span>
|
||||
<span className="text-gray-500 truncate">{a.steam_id || ""}</span>
|
||||
<span className={a.mafile_path ? "text-green-400" : "text-gray-500"}>
|
||||
{a.mafile_path ? "✓ mafile" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export settings — Bug #6: flexible export */}
|
||||
<ExportSettings
|
||||
accountIds={[...mafileManagerIds]}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type P = SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
const defaults = (size = 16): SVGProps<SVGSVGElement> => ({
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
});
|
||||
|
||||
export const IconDownload = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
|
||||
);
|
||||
export const IconUpload = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
|
||||
);
|
||||
export const IconPlus = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
);
|
||||
export const IconCheck = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polyline points="20 6 9 17 4 12" /></svg>
|
||||
);
|
||||
export const IconX = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||
);
|
||||
export const IconAlertTriangle = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
|
||||
);
|
||||
export const IconFolder = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></svg>
|
||||
);
|
||||
export const IconGlobe = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
|
||||
);
|
||||
export const IconRefresh = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /></svg>
|
||||
);
|
||||
export const IconBan = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" /></svg>
|
||||
);
|
||||
export const IconSearch = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
|
||||
);
|
||||
export const IconEye = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
);
|
||||
export const IconEyeOff = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" /><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" /><line x1="1" y1="1" x2="23" y2="23" /><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" /></svg>
|
||||
);
|
||||
export const IconKey = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" /></svg>
|
||||
);
|
||||
export const IconZap = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
|
||||
);
|
||||
export const IconSettings = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
|
||||
);
|
||||
export const IconEdit = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
|
||||
);
|
||||
export const IconTrash = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
||||
);
|
||||
export const IconLock = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
);
|
||||
export const IconUnlock = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" /></svg>
|
||||
);
|
||||
export const IconPackage = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><line x1="16.5" y1="9.4" x2="7.5" y2="4.21" /><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" /></svg>
|
||||
);
|
||||
export const IconFileText = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" /></svg>
|
||||
);
|
||||
export const IconClipboard = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><rect x="8" y="2" width="8" height="4" rx="1" ry="1" /></svg>
|
||||
);
|
||||
export const IconPlay = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polygon points="5 3 19 12 5 21 5 3" /></svg>
|
||||
);
|
||||
export const IconXCircle = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
|
||||
);
|
||||
export const IconCheckCircle = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
|
||||
);
|
||||
export const IconLoader = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p} className={`animate-spin ${p.className ?? ""}`}><line x1="12" y1="2" x2="12" y2="6" /><line x1="12" y1="18" x2="12" y2="22" /><line x1="4.93" y1="4.93" x2="7.76" y2="7.76" /><line x1="16.24" y1="16.24" x2="19.07" y2="19.07" /><line x1="2" y1="12" x2="6" y2="12" /><line x1="18" y1="12" x2="22" y2="12" /><line x1="4.93" y1="19.07" x2="7.76" y2="16.24" /><line x1="16.24" y1="7.76" x2="19.07" y2="4.93" /></svg>
|
||||
);
|
||||
export const IconShieldOff = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M19.69 14a6.9 6.9 0 0 0 .31-2V5l-8-3-3.16 1.18" /><path d="M4.73 4.73 4 5v7c0 6 8 10 8 10a20.29 20.29 0 0 0 5.62-4.38" /><line x1="1" y1="1" x2="23" y2="23" /></svg>
|
||||
);
|
||||
export const IconUsers = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>
|
||||
);
|
||||
export const IconFolderOpen = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></svg>
|
||||
);
|
||||
export const IconWrench = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg>
|
||||
);
|
||||
export const IconScrollText = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2" /></svg>
|
||||
);
|
||||
export const IconChevronRight = ({ size = 16, ...p }: P) => (
|
||||
<svg {...defaults(size)} {...p}><polyline points="9 18 15 12 9 6" /></svg>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmModal({ message, onConfirm, onCancel }: ConfirmModalProps) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onCancel}>
|
||||
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-4">{message}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onConfirm} className="btn-primary flex-1">{t("confirm.yes")}</button>
|
||||
<button onClick={onCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InputModalProps {
|
||||
title: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function InputModal({ title, onSubmit, onCancel }: InputModalProps) {
|
||||
const t = useT();
|
||||
const [value, setValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onCancel}>
|
||||
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-gray-300 mb-3">{title}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && value && onSubmit(value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => value && onSubmit(value)} className="btn-primary flex-1">OK</button>
|
||||
<button onClick={onCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function DisplaySettingsCard() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Proxy } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { IconUpload, IconGlobe } from "@/components/shared/Icons";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type ProxyFormat =
|
||||
| "login:pass@ip:port"
|
||||
| "login:pass:ip:port"
|
||||
| "ip:port@login:pass"
|
||||
| "ip:port:login:pass"
|
||||
| "custom";
|
||||
|
||||
interface ParsedProxy {
|
||||
address: string;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
const BUILTIN_FORMATS: { value: ProxyFormat; label: string }[] = [
|
||||
{ value: "login:pass@ip:port", label: "login:pass@ip:port" },
|
||||
{ value: "login:pass:ip:port", label: "login:pass:ip:port" },
|
||||
{ value: "ip:port@login:pass", label: "ip:port@login:pass" },
|
||||
{ value: "ip:port:login:pass", label: "ip:port:login:pass" },
|
||||
];
|
||||
|
||||
function parseProxyLine(raw: string, format: ProxyFormat, defaultProtocol: string, customParts?: string[]): ParsedProxy | null {
|
||||
let line = raw.trim();
|
||||
if (!line) return null;
|
||||
|
||||
// Strip protocol prefix if present, fall back to user-selected default
|
||||
let protocol = defaultProtocol;
|
||||
for (const proto of ["socks5://", "socks4://", "http://", "https://"]) {
|
||||
if (line.toLowerCase().startsWith(proto)) {
|
||||
protocol = proto.replace("://", "");
|
||||
line = line.slice(proto.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no auth separators detected, treat as plain ip:port
|
||||
if (!line.includes("@") && line.split(":").length === 2) {
|
||||
return { address: line, protocol };
|
||||
}
|
||||
|
||||
let ip = "", port = "", login = "", pass = "";
|
||||
|
||||
if (format === "custom" && customParts && customParts.length >= 4) {
|
||||
// Custom format: user defines order of [ip, port, login, pass] with separator between them
|
||||
// customParts = [part0, sep0, part1, sep1, part2, sep2, part3]
|
||||
// We rebuild a regex from it
|
||||
const result = parseCustomFormat(line, customParts);
|
||||
if (!result) return null;
|
||||
({ ip, port, login, pass } = result);
|
||||
} else if (format === "login:pass@ip:port") {
|
||||
const atIdx = line.indexOf("@");
|
||||
if (atIdx === -1) return null;
|
||||
const auth = line.slice(0, atIdx);
|
||||
const host = line.slice(atIdx + 1);
|
||||
const authParts = auth.split(":");
|
||||
const hostParts = host.split(":");
|
||||
if (authParts.length < 2 || hostParts.length < 2) return null;
|
||||
login = authParts[0]; pass = authParts.slice(1).join(":");
|
||||
ip = hostParts[0]; port = hostParts[1];
|
||||
} else if (format === "login:pass:ip:port") {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) return null;
|
||||
login = parts[0]; pass = parts[1]; ip = parts[2]; port = parts[3];
|
||||
} else if (format === "ip:port@login:pass") {
|
||||
const atIdx = line.indexOf("@");
|
||||
if (atIdx === -1) return null;
|
||||
const host = line.slice(0, atIdx);
|
||||
const auth = line.slice(atIdx + 1);
|
||||
const hostParts = host.split(":");
|
||||
const authParts = auth.split(":");
|
||||
if (hostParts.length < 2 || authParts.length < 2) return null;
|
||||
ip = hostParts[0]; port = hostParts[1];
|
||||
login = authParts[0]; pass = authParts.slice(1).join(":");
|
||||
} else if (format === "ip:port:login:pass") {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) return null;
|
||||
ip = parts[0]; port = parts[1]; login = parts[2]; pass = parts[3];
|
||||
}
|
||||
|
||||
if (!ip || !port) return null;
|
||||
const address = login && pass ? `${login}:${pass}@${ip}:${port}` : `${ip}:${port}`;
|
||||
return { address, protocol };
|
||||
}
|
||||
|
||||
function parseCustomFormat(line: string, customParts: string[]): { ip: string; port: string; login: string; pass: string } | null {
|
||||
// customParts = [field, sep, field, sep, field, sep, field]
|
||||
// fields: "ip" | "port" | "login" | "pass"
|
||||
// seps: any string like ":" or "@"
|
||||
const fields: string[] = [];
|
||||
const seps: string[] = [];
|
||||
for (let i = 0; i < customParts.length; i++) {
|
||||
if (i % 2 === 0) fields.push(customParts[i]);
|
||||
else seps.push(customParts[i]);
|
||||
}
|
||||
if (fields.length !== 4 || seps.length !== 3) return null;
|
||||
|
||||
// Split line by separators sequentially
|
||||
let remaining = line;
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < seps.length; i++) {
|
||||
const idx = remaining.indexOf(seps[i]);
|
||||
if (idx === -1) return null;
|
||||
values.push(remaining.slice(0, idx));
|
||||
remaining = remaining.slice(idx + seps[i].length);
|
||||
}
|
||||
values.push(remaining);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
result[fields[i]] = values[i];
|
||||
}
|
||||
return { ip: result.ip || "", port: result.port || "", login: result.login || "", pass: result.pass || "" };
|
||||
}
|
||||
|
||||
const CUSTOM_FIELDS = ["ip", "port", "login", "pass"];
|
||||
const CUSTOM_SEPS = [":", "@", ";", "|", "-"];
|
||||
|
||||
export function ProxyManager() {
|
||||
const [proxies, setProxies] = useState<Proxy[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<string>("");
|
||||
const [format, setFormat] = useState<ProxyFormat>("login:pass@ip:port");
|
||||
const [protocol, setProtocol] = useState<string>("http");
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customParts, setCustomParts] = useState<string[]>(["ip", ":", "port", "@", "login", ":", "pass"]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const t = useT();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setProxies(await api.getProxies());
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const parseAndAdd = async (rawText: string) => {
|
||||
const lines = rawText.split("\n").filter((l) => l.trim());
|
||||
if (!lines.length) return;
|
||||
|
||||
const activeFormat = showCustom ? "custom" : format;
|
||||
const parsed: ParsedProxy[] = [];
|
||||
for (const line of lines) {
|
||||
const p = parseProxyLine(line, activeFormat, protocol, showCustom ? customParts : undefined);
|
||||
if (p) parsed.push(p);
|
||||
}
|
||||
if (!parsed.length) {
|
||||
addToast("error", t("toast.proxyFormatError"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api.bulkAddProxies(parsed);
|
||||
addToast("success", t("toast.proxiesAdded", { count: result.added }));
|
||||
setText("");
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const addProxies = () => parseAndAdd(text);
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
const content = await file.text();
|
||||
await parseAndAdd(content);
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const content = await file.text();
|
||||
await parseAndAdd(content);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const deleteProxy = async (id: number) => {
|
||||
try {
|
||||
await api.deleteProxy(id);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAll = async () => {
|
||||
if (!proxies.length) return;
|
||||
if (!window.confirm(t("proxy.confirmDeleteAll"))) return;
|
||||
try {
|
||||
const r = await api.deleteAllProxies();
|
||||
addToast("success", t("toast.proxiesDeleted", { count: r.deleted }));
|
||||
setCheckResult("");
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const checkAll = async () => {
|
||||
setChecking(true);
|
||||
setCheckResult("");
|
||||
try {
|
||||
const r = await api.checkProxies();
|
||||
const msg = t("proxy.checkResult", { total: r.total, alive: r.alive, dead: r.dead });
|
||||
setCheckResult(msg);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.proxyErrorCheck", { error: e instanceof Error ? e.message : String(e) }));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCustomPart = (idx: number, value: string) => {
|
||||
const next = [...customParts];
|
||||
next[idx] = value;
|
||||
setCustomParts(next);
|
||||
};
|
||||
|
||||
const customPreview = customParts.join("");
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-3">
|
||||
<IconGlobe size={14} className="inline mr-1" />{t("proxy.title")} <span className="text-xs text-gray-500 ml-2">{t("proxy.count", { count: proxies.length })}</span>
|
||||
</h3>
|
||||
|
||||
{/* Format selector */}
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-xs text-gray-400">{t("proxy.protocol")}</span>
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
</select>
|
||||
<span className="text-xs text-gray-400">{t("proxy.format")}</span>
|
||||
<select
|
||||
value={showCustom ? "__custom__" : format}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "__custom__") {
|
||||
setShowCustom(true);
|
||||
} else {
|
||||
setShowCustom(false);
|
||||
setFormat(e.target.value as ProxyFormat);
|
||||
}
|
||||
}}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{BUILTIN_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
<option value="__custom__">{t("proxy.customFormat")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom format builder */}
|
||||
{showCustom && (
|
||||
<div className="bg-dark-700 border border-dark-600 rounded p-3 space-y-2">
|
||||
<p className="text-xs text-gray-400">{t("proxy.formatConstructor")}</p>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{customParts.map((part, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<select
|
||||
key={i}
|
||||
value={part}
|
||||
onChange={(e) => updateCustomPart(i, e.target.value)}
|
||||
className="bg-dark-600 border border-dark-500 rounded px-2 py-1 text-xs text-accent"
|
||||
>
|
||||
{CUSTOM_FIELDS.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
key={i}
|
||||
value={part}
|
||||
onChange={(e) => updateCustomPart(i, e.target.value)}
|
||||
className="bg-dark-600 border border-dark-500 rounded px-1 py-1 text-xs text-yellow-400 w-10 text-center"
|
||||
>
|
||||
{CUSTOM_SEPS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t("proxy.preview")} <span className="text-gray-300">{customPreview}</span></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-4 mb-2 text-center cursor-pointer transition-colors ${
|
||||
dragging ? "border-accent bg-accent/10" : "border-dark-500 hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
<IconUpload size={24} className="mx-auto mb-1 text-gray-500" />
|
||||
<p className="text-xs text-gray-400">{t("proxy.dragFile")}</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".txt"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t("proxy.perLine", { format: showCustom ? customPreview : format })}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-2 resize-y"
|
||||
/>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<button onClick={addProxies} className="btn-primary">{t("proxy.addBtn")}</button>
|
||||
<button onClick={checkAll} disabled={checking} className="btn-secondary">
|
||||
{checking ? t("proxy.checking") : t("proxy.checkAll")}
|
||||
</button>
|
||||
{proxies.length > 0 && (
|
||||
<button onClick={deleteAll} className="btn-danger text-xs">{t("proxy.deleteAllBtn")}</button>
|
||||
)}
|
||||
</div>
|
||||
{checkResult && <p className="text-xs text-gray-300 mb-3">{checkResult}</p>}
|
||||
{proxies.length > 0 && (() => {
|
||||
const alive = proxies.filter(p => p.is_alive).length;
|
||||
const dead = proxies.length - alive;
|
||||
return (
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
<span className="text-green-400">● {alive}</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-red-400">● {dead}</span>
|
||||
<span className="mx-2">/</span>
|
||||
<span>{proxies.length}</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{proxies.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between text-xs gap-2 group px-2 py-1 rounded hover:bg-dark-700">
|
||||
<span className={`${p.is_alive ? "text-green-400" : "text-red-400"} min-w-0 truncate`}>
|
||||
{p.protocol}://{p.address}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteProxy(p.id)}
|
||||
className="text-red-400 hover:bg-red-400/20 rounded px-2 py-0.5 text-sm font-medium shrink-0 transition"
|
||||
title={t("proxy.deleteProxy")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TwoFAGenerator } from "./TwoFAGenerator";
|
||||
import { ProxyManager } from "./ProxyManager";
|
||||
import { ValidationSettings } from "./ValidationSettings";
|
||||
|
||||
export function ToolsTab() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TwoFAGenerator />
|
||||
<ValidationSettings />
|
||||
</div>
|
||||
<ProxyManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { IconKey } from "@/components/shared/Icons";
|
||||
import type { Account } from "@/api/types";
|
||||
|
||||
type Mode = "secret" | "account";
|
||||
|
||||
export function TwoFAGenerator() {
|
||||
const t = useT();
|
||||
const [mode, setMode] = useState<Mode>("secret");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Account | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const loadAccounts = useAccountStore((s) => s.loadAccounts);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "account" && accounts.length === 0) loadAccounts();
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, []);
|
||||
|
||||
const filtered = accounts
|
||||
.filter((a) => {
|
||||
const q = search.toLowerCase();
|
||||
return !q || a.login.toLowerCase().includes(q) || (a.steam_id ?? "").includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
const generate = async () => {
|
||||
try {
|
||||
if (mode === "secret") {
|
||||
if (!secret.trim()) return;
|
||||
const data = await api.generate2FA(secret.trim());
|
||||
setCode(data.code);
|
||||
} else {
|
||||
if (!selected) return;
|
||||
const data = await api.generate2FAByAccount(selected.id);
|
||||
setCode(data.code);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setCode(t("tools.genErrorStr"));
|
||||
addToast("error", t("misc.genError", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!code || code === t("tools.genErrorStr")) return;
|
||||
const ok = await copyText(code);
|
||||
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
|
||||
};
|
||||
|
||||
const tabCls = (m: Mode) =>
|
||||
`flex-1 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
mode === m ? "bg-dark-600 text-gray-100" : "text-gray-500 hover:text-gray-300"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
|
||||
<IconKey size={14} className="text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-100">{t("tools.2faGenerator")}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex gap-1 bg-dark-900 rounded p-0.5 mb-3">
|
||||
<button type="button" className={tabCls("secret")} onClick={() => setMode("secret")}>Shared secret</button>
|
||||
<button type="button" className={tabCls("account")} onClick={() => setMode("account")}>Из Mafile</button>
|
||||
</div>
|
||||
|
||||
{mode === "secret" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && generate()}
|
||||
placeholder="shared_secret"
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="button" onClick={generate} className="btn-primary text-sm px-3 py-1.5">{t("btn.generate")}</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1" ref={dropdownRef}>
|
||||
<input
|
||||
value={selected ? `${selected.login}${selected.steam_id ? " · " + selected.steam_id : ""}` : search}
|
||||
onChange={(e) => { setSearch(e.target.value); setSelected(null); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Логин или SteamID..."
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute top-full mt-1 left-0 right-0 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto">
|
||||
{filtered.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-dark-600 transition-colors flex items-center justify-between gap-2"
|
||||
onMouseDown={() => { setSelected(a); setSearch(""); setOpen(false); }}
|
||||
>
|
||||
<span className="text-gray-200 truncate">{a.login}</span>
|
||||
{a.steam_id && <span className="text-gray-500 text-xs shrink-0">{a.steam_id}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={generate} disabled={!selected} className="btn-primary text-sm px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed">{t("btn.generate")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{code && (
|
||||
<div
|
||||
className="mt-3 text-2xl font-mono text-accent cursor-pointer tabular-nums tracking-widest"
|
||||
onClick={handleCopy}
|
||||
title={t("tools.clickCopy")}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { ValidationSettings as VS } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { IconSettings } from "@/components/shared/Icons";
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ label, desc, checked, onChange }: {
|
||||
label: string;
|
||||
desc?: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-200 leading-tight">{label}</p>
|
||||
{desc && <p className="text-xs text-gray-500 mt-0.5">{desc}</p>}
|
||||
</div>
|
||||
<Toggle checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const clamp = (n: number) => Math.max(1, n);
|
||||
const commit = () => {
|
||||
onChange(clamp(parseInt(input) || value));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const btnCls = "h-6 px-1.5 rounded bg-dark-700 border border-dark-600 text-gray-300 hover:bg-dark-600 text-xs flex items-center justify-center select-none leading-none";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-3 mt-0.5">
|
||||
<span className="text-sm text-gray-400 flex-1" title="Двойной клик на числе для ручного ввода">Max threads</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 10))}>-10</button>
|
||||
<button type="button" className={btnCls} onClick={() => onChange(clamp(value - 1))}>-1</button>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
|
||||
className="w-10 text-center text-sm font-mono bg-dark-900 border border-accent rounded outline-none text-gray-100 py-0.5"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="w-10 text-center text-sm font-mono text-gray-100 cursor-pointer select-none"
|
||||
onDoubleClick={() => { setInput(String(value)); setEditing(true); }}
|
||||
title="Двойной клик для ручного ввода"
|
||||
>{value}</span>
|
||||
)}
|
||||
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 1))}>+1</button>
|
||||
<button type="button" className={btnCls} onClick={() => onChange(clamp(value + 10))}>+10</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidationSettings() {
|
||||
const t = useT();
|
||||
const [settings, setSettings] = useState<VS>({
|
||||
fetch_profile: true,
|
||||
check_ban: true,
|
||||
max_threads: 5,
|
||||
auto_revalidate_browser: true,
|
||||
});
|
||||
const hidePasswords = useUiStore((s) => s.hidePasswords);
|
||||
const setHidePasswords = useUiStore((s) => s.setHidePasswords);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
|
||||
useEffect(() => {
|
||||
api.getValidationSettings().then(setSettings).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.updateValidationSettings(settings);
|
||||
addToast("success", t("toast.settingsSaved"));
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-5 py-3.5 border-b border-dark-600 flex items-center gap-2.5">
|
||||
<IconSettings size={15} className="text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-100">Settings</span>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3 border-b border-dark-600">
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-0.5">
|
||||
{t("tools.validationSettings")}
|
||||
</p>
|
||||
<div className="divide-y divide-dark-600/60">
|
||||
<SettingRow
|
||||
label={t("tools.loadProfile")}
|
||||
checked={settings.fetch_profile}
|
||||
onChange={(v) => setSettings({ ...settings, fetch_profile: v })}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t("tools.checkBans")}
|
||||
checked={settings.check_ban}
|
||||
onChange={(v) => setSettings({ ...settings, check_ban: v })}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t("tools.autoRevalidateBrowser")}
|
||||
checked={settings.auto_revalidate_browser}
|
||||
onChange={(v) => setSettings({ ...settings, auto_revalidate_browser: v })}
|
||||
/>
|
||||
</div>
|
||||
<ThreadStepper value={settings.max_threads} onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))} />
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-0.5">
|
||||
{t("settings.display")}
|
||||
</p>
|
||||
<SettingRow
|
||||
label={t("settings.hidePasswords")}
|
||||
checked={hidePasswords}
|
||||
onChange={setHidePasswords}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-dark-600 flex justify-end">
|
||||
<button onClick={save} className="btn-primary">{t("btn.save")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function usePolling(
|
||||
callback: () => void | Promise<void>,
|
||||
intervalMs: number,
|
||||
enabled = true,
|
||||
) {
|
||||
const cbRef = useRef(callback);
|
||||
cbRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
cbRef.current();
|
||||
const id = setInterval(() => cbRef.current(), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useSSE<T>(
|
||||
url: string,
|
||||
onMessage: (data: T) => void,
|
||||
enabled = true,
|
||||
) {
|
||||
const cbRef = useRef(onMessage);
|
||||
cbRef.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const es = new EventSource(url);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
cbRef.current(JSON.parse(e.data));
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, [url, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-dark-900: #0f0f0f;
|
||||
--color-dark-800: #1a1a1a;
|
||||
--color-dark-700: #252525;
|
||||
--color-dark-600: #333333;
|
||||
--color-accent: #4f8cff;
|
||||
--color-accent-hover: #6ba0ff;
|
||||
--color-accent-dim: #3a6fd8;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Steam Level Badge */
|
||||
.steam-lvl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
border-radius: 10px;
|
||||
border: solid #9b9b9b 1.5px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
color: #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.steam-lvl.l10 { border-color: #c02942; }
|
||||
.steam-lvl.l20 { border-color: #d95b43; }
|
||||
.steam-lvl.l30 { border-color: #fecc23; }
|
||||
.steam-lvl.l40 { border-color: #467a3c; }
|
||||
.steam-lvl.l50 { border-color: #4e8ddb; }
|
||||
.steam-lvl.l60 { border-color: #7652c9; }
|
||||
.steam-lvl.l70 { border-color: #c252c9; }
|
||||
.steam-lvl.l80 { border-color: #542437; }
|
||||
.steam-lvl.l90 { border-color: #997c52; }
|
||||
.steam-lvl.l100p {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 0;
|
||||
background-size: contain;
|
||||
font-size: 9px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
text-shadow: 1px 1px #1a1a1a;
|
||||
}
|
||||
.steam-lvl.l100p.img-100 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_hexagons.png'); }
|
||||
.steam-lvl.l100p.img-200 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_shields.png'); }
|
||||
.steam-lvl.l100p.img-300 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_books.png'); }
|
||||
.steam-lvl.l100p.img-400 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_chevrons.png'); }
|
||||
.steam-lvl.l100p.img-500 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_circle2.png'); }
|
||||
.steam-lvl.l100p.img-600 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_angle.png'); }
|
||||
.steam-lvl.l100p.img-700 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_flag.png'); }
|
||||
.steam-lvl.l100p.img-800 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_wings.png'); }
|
||||
.steam-lvl.l100p.img-900 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_arrows.png'); }
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent hover:bg-accent-hover text-white font-medium py-2 px-4 rounded text-sm transition-colors cursor-pointer;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-dark-700 hover:bg-dark-600 text-gray-300 font-medium py-2 px-4 rounded text-sm transition-colors border border-dark-600 cursor-pointer;
|
||||
}
|
||||
.btn-accent {
|
||||
@apply bg-accent-dim hover:bg-accent text-white font-medium py-2 px-4 rounded text-sm transition-colors cursor-pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-red-600/20 hover:bg-red-600/30 text-red-400 font-medium py-2 px-4 rounded text-sm transition-colors border border-red-600/30 cursor-pointer;
|
||||
}
|
||||
.btn-danger-outline {
|
||||
@apply bg-transparent hover:bg-red-600/10 text-red-400 font-medium py-2 px-4 rounded text-sm transition-colors border border-red-600/30 cursor-pointer;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-block px-2 py-0.5 rounded text-xs font-medium;
|
||||
}
|
||||
.badge-ok { @apply bg-green-500/20 text-green-400; }
|
||||
.badge-error { @apply bg-red-500/20 text-red-400; }
|
||||
.badge-running { @apply bg-blue-500/20 text-blue-400; }
|
||||
.badge-unknown { @apply bg-gray-500/20 text-gray-500; }
|
||||
|
||||
.progress-bar {
|
||||
@apply w-full h-1.5 bg-dark-600 rounded-full overflow-hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
@apply h-full bg-accent rounded-full transition-all duration-300;
|
||||
}
|
||||
|
||||
.cell-copy {
|
||||
@apply cursor-pointer hover:text-gray-200 transition-colors select-all;
|
||||
}
|
||||
|
||||
.copy-tooltip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -100%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
button[disabled][data-tooltip] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 6px);
|
||||
transform: translateX(-50%);
|
||||
background: #0f0f1a;
|
||||
border: 1px solid #6b6b8a;
|
||||
color: #f0f0f8;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
}
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.popup-2fa {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: var(--color-dark-700);
|
||||
border: 1px solid var(--color-dark-600);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.popup-2fa-code {
|
||||
font-size: 1.5rem;
|
||||
font-family: monospace;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.action-menu-dropdown {
|
||||
@apply bg-dark-700 border border-dark-600 rounded-lg shadow-xl py-1 z-50 min-w-[180px];
|
||||
}
|
||||
.action-menu-item {
|
||||
@apply block w-full text-left px-3 py-1.5 text-sm text-gray-300 hover:bg-dark-600 hover:text-white transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
@apply fixed inset-0 bg-black/60 flex items-center justify-center z-50;
|
||||
}
|
||||
.confirm-box {
|
||||
@apply bg-dark-800 border border-dark-600 rounded-lg p-6 w-full max-w-md shadow-xl;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 0.5rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
animation: toastIn 0.25s ease-out;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.toast-info { background: #2563eb; }
|
||||
.toast-success { background: #16a34a; }
|
||||
.toast-error { background: #dc2626; }
|
||||
.toast-warn { background: #d97706; }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(-30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { stroke-dashoffset: 80; }
|
||||
100% { stroke-dashoffset: -80; }
|
||||
}
|
||||
.icon-shimmer {
|
||||
stroke: rgba(255, 255, 255, 0.45);
|
||||
stroke-dasharray: 8 72;
|
||||
animation: shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 999px;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.toggle-track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle-switch input:checked ~ .toggle-track {
|
||||
background: #4f8cff;
|
||||
border-color: #4f8cff;
|
||||
}
|
||||
.toggle-switch input:checked ~ .toggle-track::before {
|
||||
transform: translateX(16px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-dark-900); }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-dark-600); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const entityMap: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str.replace(/[&<>"']/g, (s) => entityMap[s] ?? s);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Locale = "ru" | "en";
|
||||
|
||||
const STORAGE_KEY = "steampanel_locale";
|
||||
|
||||
let _locale: Locale = (localStorage.getItem(STORAGE_KEY) as Locale) || "ru";
|
||||
const _listeners = new Set<() => void>();
|
||||
|
||||
function notify() { _listeners.forEach((l) => l()); }
|
||||
|
||||
export function getLocale(): Locale { return _locale; }
|
||||
|
||||
export function setLocale(l: Locale) {
|
||||
_locale = l;
|
||||
localStorage.setItem(STORAGE_KEY, l);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function useLocale(): [Locale, (l: Locale) => void] {
|
||||
const locale = useSyncExternalStore(
|
||||
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
|
||||
() => _locale,
|
||||
);
|
||||
return [locale, setLocale];
|
||||
}
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
type Translations = { ru: Dict; en: Dict };
|
||||
|
||||
const translations: Translations = {
|
||||
ru: {
|
||||
// -- Layout --
|
||||
"header.accounts": "Аккаунтов",
|
||||
// -- Tabs --
|
||||
"tab.accounts": "Аккаунты",
|
||||
"tab.mafiles": "Mafile Management",
|
||||
"tab.tools": "Настройки",
|
||||
"tab.logs": "Логи",
|
||||
// -- Section labels --
|
||||
"section.dataType": "Тип данных",
|
||||
// -- Column headers (shared) --
|
||||
"col.browser": "Войти",
|
||||
"col.profile": "Профиль",
|
||||
"col.lastOnline": "Отлега",
|
||||
"col.steamId": "Steam ID",
|
||||
"col.login": "Логин",
|
||||
"col.password": "Пароль",
|
||||
"col.loginPass": "Login:Pass",
|
||||
"col.email": "Email",
|
||||
"col.emailPass": "Email Pass",
|
||||
"col.emailLoginPass": "Email:Pass",
|
||||
"col.phone": "Телефон",
|
||||
"col.status": "Статус",
|
||||
"col.ban": "Бан",
|
||||
"col.twofa": "2FA",
|
||||
"col.mafile": "Mafile",
|
||||
"col.proxy": "Прокси",
|
||||
"col.notes": "Заметки",
|
||||
"col.actions": "Действия",
|
||||
"col.prime": "Prime",
|
||||
"col.trophy": "Trophy",
|
||||
"col.behavior": "Behavior",
|
||||
"col.license": "Лицензии",
|
||||
"col.token": "Токен",
|
||||
// -- Actions --
|
||||
"action.selectAction": "— Действие",
|
||||
"action.validate": "Валидация",
|
||||
"action.changePassword": "Сменить пароль",
|
||||
"action.randomPassword": "Случайный пароль",
|
||||
"action.changeEmail": "Сменить email",
|
||||
"action.changePhone": "Сменить телефон",
|
||||
"action.removePhone": "Удалить телефон",
|
||||
"action.removeGuard": "Удалить Guard",
|
||||
"action.noRevocationCode": "Нет revocation_code в mafile",
|
||||
"action.enableAutoAccept": "Вкл. автопринятие",
|
||||
"action.disableAutoAccept": "Выкл. авто-принятие входа",
|
||||
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Введите новый пароль",
|
||||
"prompt.newEmail": "Введите новый email",
|
||||
"prompt.newPhone": "Введите новый номер телефона",
|
||||
"prompt.removePhone": "Введите номер телефона (на который переставится старый)",
|
||||
"prompt.enterValue": "Введите значение...",
|
||||
// -- Confirm dialogs --
|
||||
"confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?",
|
||||
"confirm.deleteAccount": "Удалить аккаунт?",
|
||||
"confirm.deleteSelected": "Удалить {count} выбранных аккаунтов?",
|
||||
"confirm.deleteAll": "Удалить ВСЕ {count} аккаунтов? Это необратимо!",
|
||||
"confirm.deleteLogpassSelected": "Удалить {count} аккаунт(ов)?",
|
||||
"confirm.deleteLogpassAll": "Удалить все {count} аккаунтов?",
|
||||
"confirm.deleteTokenSelected": "Удалить {count} токен(ов)?",
|
||||
"confirm.deleteTokenAll": "Удалить все {count} токенов?",
|
||||
"confirm.deleteToken": "Удалить токен {name}?",
|
||||
"confirm.deleteLogpass": "Удалить {name}?",
|
||||
"confirm.assignProxies": "Назначить прокси аккаунтам без прокси?",
|
||||
"confirm.reassignProxies": "Переназначить прокси на все аккаунты?",
|
||||
"confirm.reassignProxiesAll": "Переназначить прокси всем аккаунтам?",
|
||||
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
|
||||
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
|
||||
"confirm.yes": "Да",
|
||||
// -- Buttons --
|
||||
"btn.import": "Импорт",
|
||||
"btn.add": "Добавить",
|
||||
"btn.save": "Сохранить",
|
||||
"btn.cancel": "Отмена",
|
||||
"btn.execute": "Выполнить",
|
||||
"btn.deleteSelected": "Удалить выбранные",
|
||||
"btn.deleteAll": "Удалить все",
|
||||
"btn.assignProxies": "Назначить прокси",
|
||||
"btn.reassign": "Переназначить",
|
||||
"btn.clearProxies": "Очистить прокси",
|
||||
"btn.validate": "Валидировать",
|
||||
"btn.fullParse": "Полный парс",
|
||||
"btn.generate": "Сгенерировать",
|
||||
// -- Toast messages --
|
||||
"toast.copied": "Скопировано",
|
||||
"toast.copyFailed": "Не удалось скопировать",
|
||||
"toast.noteSaved": "Заметка сохранена",
|
||||
"toast.noteSaveError": "Ошибка сохранения",
|
||||
"toast.accountDeleted": "Аккаунт удален",
|
||||
"toast.accountAdded": "Аккаунт добавлен",
|
||||
"toast.saved": "Сохранено",
|
||||
"toast.done": "Готово",
|
||||
"toast.error": "Ошибка",
|
||||
"toast.selectAction": "Выберите действие",
|
||||
"toast.selectAccounts": "Выберите аккаунты",
|
||||
"toast.noAccounts": "Нет аккаунтов",
|
||||
"toast.autoAcceptEnabled": "Автопринятие включено для {count} акк.",
|
||||
"toast.autoAcceptOn": "Авто-принятие входа включено",
|
||||
"toast.autoAcceptOff": "Авто-принятие входа выключено",
|
||||
"toast.taskCancelled": "Задача отменена",
|
||||
"toast.browserOpening": "Браузер открывается...",
|
||||
"toast.cookiesExpiredRevalidating": "Куки устарели. Запущена повторная валидация...",
|
||||
"toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.",
|
||||
"toast.proxyCleared": "Прокси убран у {name}",
|
||||
"toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.",
|
||||
"toast.proxiesAssigned": "Назначено {used} прокси на {count} аккаунтов",
|
||||
"toast.proxiesAssignedShort": "Назначено {count} аккаунтам",
|
||||
"toast.proxiesReassignedShort": "Переназначено {count} аккаунтам",
|
||||
"toast.proxiesClearedCount": "Очищено прокси у {count} аккаунтов",
|
||||
"toast.proxiesClearedShort": "Очищено у {count} аккаунтов",
|
||||
"toast.deleted": "Удалено: {count}",
|
||||
"toast.sentToMafile": "{count} акк. отправлено в Mafile Management",
|
||||
"toast.settingsSaved": "Настройки сохранены",
|
||||
"toast.proxyFormatError": "Не удалось распознать прокси. Проверьте формат.",
|
||||
"toast.proxiesAdded": "Добавлено {count} прокси",
|
||||
"toast.proxiesDeleted": "Удалено {count} прокси",
|
||||
"toast.exportDone": "Экспорт завершён",
|
||||
"toast.uploaded": "Загружено: {count}",
|
||||
"toast.uploadedWithErrors": "Загружено: {uploaded}, ошибок: {errors}",
|
||||
"toast.importResult": "Импортировано: {imported}, Пропущено: {skipped}",
|
||||
"toast.fileEmpty": "Файл пуст",
|
||||
// -- Task types --
|
||||
"task.validate": "Валидация",
|
||||
"task.changePassword": "Смена пароля",
|
||||
"task.randomPassword": "Случ. пароль",
|
||||
"task.changeEmail": "Смена email",
|
||||
"task.changePhone": "Смена телефона",
|
||||
"task.removePhone": "Удал. телефона",
|
||||
"task.removeGuard": "Удал. Guard",
|
||||
// -- Badges / statuses --
|
||||
"status.unknown": "Неизвестен",
|
||||
"status.valid": "Валидный",
|
||||
"status.invalid": "Невалидный",
|
||||
"status.locked": "Заблокирован",
|
||||
"status.limited": "Ограничен",
|
||||
"status.banned": "БАН",
|
||||
"status.ok": "ОК",
|
||||
// -- Tooltips --
|
||||
"tip.openBrowser": "Открыть Steam в браузере",
|
||||
"tip.copyPassword": "Копировать пароль",
|
||||
"tip.copyLoginPass": "Копировать login:pass",
|
||||
"tip.copyEmailPass": "Копировать пароль email",
|
||||
"tip.copyEmailLoginPass": "Копировать email:pass",
|
||||
"tip.show": "Показать",
|
||||
"tip.hide": "Скрыть",
|
||||
"tip.gen2fa": "Сгенерировать и скопировать 2FA код",
|
||||
"tip.code": "Код",
|
||||
"tip.addNote": "Нажмите чтобы добавить заметку",
|
||||
"tip.edit": "Редактировать",
|
||||
"tip.delete": "Удалить",
|
||||
"tip.autoAcceptActive": "Авто-принятие входа",
|
||||
"tip.validate": "Валидировать",
|
||||
"tip.columnSettings": "Настройки столбцов",
|
||||
"tip.deleteProxy": "Удалить прокси",
|
||||
"tip.clickToCopy": "Клик для копирования",
|
||||
"tip.proxyError": "Ошибка прокси: {error}",
|
||||
// -- Placeholders --
|
||||
"ph.search": "Поиск: логин / Steam ID / заметка / lvl>10",
|
||||
"ph.searchLogpass": "Поиск: логин / Steam ID / заметка / игра / lvl>10",
|
||||
"ph.login": "Логин",
|
||||
"ph.password": "Пароль",
|
||||
"ph.proxy": "Прокси",
|
||||
"ph.proxyOptional": "Прокси (необязательно)",
|
||||
"ph.notes": "Заметки",
|
||||
"ph.notesOptional": "Заметки (необязательно)",
|
||||
"ph.loginOptional": "Логин (необязательно)",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "Нет аккаунтов. Импортируйте файл",
|
||||
"empty.logpass": "Аккаунтов нет. Импортируйте файл.",
|
||||
"empty.tokens": "Токенов нет. Импортируйте или добавьте токены.",
|
||||
"empty.logs": "Нет логов",
|
||||
"empty.tasks": "Нет задач",
|
||||
// -- Modals --
|
||||
"modal.addAccount": "Добавить аккаунт",
|
||||
"modal.editAccount": "Редактировать аккаунт",
|
||||
"modal.importAccounts": "Импорт аккаунтов",
|
||||
"modal.addToken": "Добавить токен",
|
||||
"modal.importTokens": "Импорт токенов",
|
||||
// -- Import --
|
||||
"import.formats": "Форматы",
|
||||
"import.formatsColon": "Форматы:",
|
||||
"import.delimiter": "разделитель",
|
||||
"import.or": "или",
|
||||
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
|
||||
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
|
||||
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
|
||||
"import.selected": "Выбран: {name}",
|
||||
"import.uploading": "Загрузка...",
|
||||
"import.importBtn": "Импортировать",
|
||||
"import.tokenFormat": "refresh_token (по одному на строку)",
|
||||
// -- Pagination --
|
||||
"paging.shown": "Показано {visible} из {total}…",
|
||||
// -- Proxy labels --
|
||||
"proxy.label": "Прокси {num}",
|
||||
"proxy.title": "Прокси",
|
||||
"proxy.count": "{count} шт.",
|
||||
"proxy.protocol": "Протокол:",
|
||||
"proxy.format": "Формат:",
|
||||
"proxy.customFormat": "Свой формат...",
|
||||
"proxy.formatConstructor": "Конструктор формата:",
|
||||
"proxy.preview": "Предпросмотр:",
|
||||
"proxy.dragFile": "Перетащите .txt файл с прокси или нажмите для выбора",
|
||||
"proxy.addBtn": "Добавить",
|
||||
"proxy.checking": "Проверка...",
|
||||
"proxy.checkAll": "Проверить все",
|
||||
"proxy.deleteAllBtn": "Удалить все прокси",
|
||||
"proxy.confirmDeleteAll": "Удалить все прокси? Это действие нельзя отменить.",
|
||||
"proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}",
|
||||
"proxy.removeProxy": "Убрать прокси",
|
||||
"proxy.reassignProxy": "Переназначить прокси",
|
||||
"proxy.perLine": "Прокси по одному на строку ({format})",
|
||||
"proxy.deleteProxy": "Удалить прокси",
|
||||
// -- Tools --
|
||||
"tools.2faGenerator": "Генератор 2FA кода",
|
||||
"tools.validationSettings": "Настройки валидации",
|
||||
"tools.loadProfile": "Загружать профиль (никнейм, аватар)",
|
||||
"tools.checkBans": "Проверять баны",
|
||||
"tools.maxThreads": "Макс. потоков:",
|
||||
"tools.autoRevalidateBrowser": "Автовалидация при мёртвых куках (браузер)",
|
||||
"tools.clickCopy": "Клик для копирования",
|
||||
"tools.genErrorStr": "Ошибка",
|
||||
// -- Logs panel --
|
||||
"logs.title": "Лог",
|
||||
"logs.clear": "Очистить",
|
||||
"logs.activeTasks": "Активные задачи",
|
||||
// -- Mafile tab --
|
||||
"mafile.upload": "Загрузка Mafile",
|
||||
"mafile.exportSettings": "Настройки экспорта",
|
||||
"mafile.exportFormat": "Формат:",
|
||||
"mafile.flatFiles": "Плоские файлы (.mafile)",
|
||||
"mafile.perAccountFolder": "Папка на аккаунт",
|
||||
"mafile.singleFile": "Одним файлом",
|
||||
"mafile.variables": "Переменные:",
|
||||
"mafile.folderName": "Название папки:",
|
||||
"mafile.mafileName": "Название .mafile:",
|
||||
"mafile.txtSettings": "Настройки .txt",
|
||||
"mafile.addGlobalTxt": "Добавить общий",
|
||||
"mafile.withAllAccounts": "со всеми аккаунтами",
|
||||
"mafile.addTxtPerFolder": "Добавить .txt в каждую папку",
|
||||
"mafile.skipFolders": "Не создавать папку для каждого аккаунта",
|
||||
"mafile.txtFolderName": "Название .txt в папке:",
|
||||
"mafile.txtLineFormat": "Формат строки .txt:",
|
||||
"mafile.mafileFields": "Поля Mafile",
|
||||
"mafile.sessionFields": "Поля Session",
|
||||
"mafile.export": "Экспорт",
|
||||
"mafile.noAccounts": "нет аккаунтов",
|
||||
"mafile.sentAccounts": "Отправленные аккаунты",
|
||||
"mafile.clearBtn": "Очистить",
|
||||
"mafile.insert": "Вставить {value}",
|
||||
// -- Settings drawer --
|
||||
"settings.display": "Отображение",
|
||||
"settings.hidePasswords": "Скрывать пароли (спойлер)",
|
||||
// -- Token disclaimer --
|
||||
"token.disclaimerTitle": "Вкладка в разработке",
|
||||
"token.disclaimerBody1": "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
|
||||
"token.disclaimerBody2": "⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
|
||||
"token.disclaimerBody3": "Импорт, редактирование и любые действия с токенами могут работать некорректно.",
|
||||
"token.acceptRisk": "Я понимаю риски и хочу продолжить",
|
||||
// -- 2FA --
|
||||
"twofa.copied": "2FA: {code} (скопировано)",
|
||||
"twofa.error": "Ошибка 2FA: {error}",
|
||||
// -- Misc --
|
||||
"misc.task": "Задача {id}",
|
||||
"misc.taskAction": "Задача {id} - {action} ({login})",
|
||||
"misc.taskCount": "Задача {id} ({count} акк.)",
|
||||
"misc.error": "Ошибка: {error}",
|
||||
"misc.proxyErrorCheck": "Ошибка проверки: {error}",
|
||||
"misc.exportError": "Ошибка экспорта: {error}",
|
||||
"misc.genError": "Ошибка генерации: {error}",
|
||||
"misc.errorStr": "Ошибка",
|
||||
"misc.insert": "Вставить",
|
||||
},
|
||||
|
||||
en: {
|
||||
// -- Layout --
|
||||
"header.accounts": "Accounts",
|
||||
// -- Tabs --
|
||||
"tab.accounts": "Accounts",
|
||||
"tab.mafiles": "Mafile Management",
|
||||
"tab.tools": "Settings",
|
||||
"tab.logs": "Logs",
|
||||
// -- Section labels --
|
||||
"section.dataType": "Data Type",
|
||||
// -- Column headers --
|
||||
"col.browser": "Login",
|
||||
"col.profile": "Profile",
|
||||
"col.lastOnline": "Last Online",
|
||||
"col.steamId": "Steam ID",
|
||||
"col.login": "Login",
|
||||
"col.password": "Password",
|
||||
"col.loginPass": "Login:Pass",
|
||||
"col.email": "Email",
|
||||
"col.emailPass": "Email Pass",
|
||||
"col.emailLoginPass": "Email:Pass",
|
||||
"col.phone": "Phone",
|
||||
"col.status": "Status",
|
||||
"col.ban": "Ban",
|
||||
"col.twofa": "2FA",
|
||||
"col.mafile": "Mafile",
|
||||
"col.proxy": "Proxy",
|
||||
"col.notes": "Notes",
|
||||
"col.actions": "Actions",
|
||||
"col.prime": "Prime",
|
||||
"col.trophy": "Trophy",
|
||||
"col.behavior": "Behavior",
|
||||
"col.license": "Licenses",
|
||||
"col.token": "Token",
|
||||
// -- Actions --
|
||||
"action.selectAction": "— Action",
|
||||
"action.validate": "Validate",
|
||||
"action.changePassword": "Change password",
|
||||
"action.randomPassword": "Random password",
|
||||
"action.changeEmail": "Change email",
|
||||
"action.changePhone": "Change phone",
|
||||
"action.removePhone": "Remove phone",
|
||||
"action.removeGuard": "Remove Guard",
|
||||
"action.noRevocationCode": "No revocation_code in mafile",
|
||||
"action.enableAutoAccept": "Enable auto-accept",
|
||||
"action.disableAutoAccept": "Disable login auto-accept",
|
||||
"action.enableAutoAcceptLogin": "Enable login auto-accept",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Enter new password",
|
||||
"prompt.newEmail": "Enter new email",
|
||||
"prompt.newPhone": "Enter new phone number",
|
||||
"prompt.removePhone": "Enter phone number (old number will be transferred to it)",
|
||||
"prompt.enterValue": "Enter value...",
|
||||
// -- Confirm dialogs --
|
||||
"confirm.removeGuard": "Remove Steam Guard for this account?",
|
||||
"confirm.deleteAccount": "Delete account?",
|
||||
"confirm.deleteSelected": "Delete {count} selected accounts?",
|
||||
"confirm.deleteAll": "Delete ALL {count} accounts? This is irreversible!",
|
||||
"confirm.deleteLogpassSelected": "Delete {count} account(s)?",
|
||||
"confirm.deleteLogpassAll": "Delete all {count} accounts?",
|
||||
"confirm.deleteTokenSelected": "Delete {count} token(s)?",
|
||||
"confirm.deleteTokenAll": "Delete all {count} tokens?",
|
||||
"confirm.deleteToken": "Delete token {name}?",
|
||||
"confirm.deleteLogpass": "Delete {name}?",
|
||||
"confirm.assignProxies": "Assign proxies to accounts without proxies?",
|
||||
"confirm.reassignProxies": "Reassign proxies to all accounts?",
|
||||
"confirm.reassignProxiesAll": "Reassign proxies to all accounts?",
|
||||
"confirm.clearProxies": "Clear proxies from all accounts?",
|
||||
"confirm.executeBulk": "Execute «{action}» for {count} accounts?",
|
||||
"confirm.yes": "Yes",
|
||||
// -- Buttons --
|
||||
"btn.import": "Import",
|
||||
"btn.add": "Add",
|
||||
"btn.save": "Save",
|
||||
"btn.cancel": "Cancel",
|
||||
"btn.execute": "Execute",
|
||||
"btn.deleteSelected": "Delete selected",
|
||||
"btn.deleteAll": "Delete all",
|
||||
"btn.assignProxies": "Assign proxies",
|
||||
"btn.reassign": "Reassign",
|
||||
"btn.clearProxies": "Clear proxies",
|
||||
"btn.validate": "Validate",
|
||||
"btn.fullParse": "Full parse",
|
||||
"btn.generate": "Generate",
|
||||
// -- Toast messages --
|
||||
"toast.copied": "Copied",
|
||||
"toast.copyFailed": "Failed to copy",
|
||||
"toast.noteSaved": "Note saved",
|
||||
"toast.noteSaveError": "Error saving",
|
||||
"toast.accountDeleted": "Account deleted",
|
||||
"toast.accountAdded": "Account added",
|
||||
"toast.saved": "Saved",
|
||||
"toast.done": "Done",
|
||||
"toast.error": "Error",
|
||||
"toast.selectAction": "Select an action",
|
||||
"toast.selectAccounts": "Select accounts",
|
||||
"toast.noAccounts": "No accounts",
|
||||
"toast.autoAcceptEnabled": "Auto-accept enabled for {count} accounts",
|
||||
"toast.autoAcceptOn": "Login auto-accept enabled",
|
||||
"toast.autoAcceptOff": "Login auto-accept disabled",
|
||||
"toast.taskCancelled": "Task cancelled",
|
||||
"toast.browserOpening": "Opening browser...",
|
||||
"toast.cookiesExpiredRevalidating": "Cookies expired. Re-validating...",
|
||||
"toast.cookiesExpired": "Cookies expired. Re-validate the account.",
|
||||
"toast.proxyCleared": "Proxy removed from {name}",
|
||||
"toast.proxiesReassigned": "Reassigned {used} proxies to {count} accounts",
|
||||
"toast.proxiesAssigned": "Assigned {used} proxies to {count} accounts",
|
||||
"toast.proxiesAssignedShort": "Assigned to {count} accounts",
|
||||
"toast.proxiesReassignedShort": "Reassigned to {count} accounts",
|
||||
"toast.proxiesClearedCount": "Cleared proxies from {count} accounts",
|
||||
"toast.proxiesClearedShort": "Cleared from {count} accounts",
|
||||
"toast.deleted": "Deleted: {count}",
|
||||
"toast.sentToMafile": "{count} accounts sent to Mafile Management",
|
||||
"toast.settingsSaved": "Settings saved",
|
||||
"toast.proxyFormatError": "Failed to parse proxies. Check the format.",
|
||||
"toast.proxiesAdded": "Added {count} proxies",
|
||||
"toast.proxiesDeleted": "Deleted {count} proxies",
|
||||
"toast.exportDone": "Export complete",
|
||||
"toast.uploaded": "Uploaded: {count}",
|
||||
"toast.uploadedWithErrors": "Uploaded: {uploaded}, errors: {errors}",
|
||||
"toast.importResult": "Imported: {imported}, Skipped: {skipped}",
|
||||
"toast.fileEmpty": "File is empty",
|
||||
// -- Task types --
|
||||
"task.validate": "Validation",
|
||||
"task.changePassword": "Password change",
|
||||
"task.randomPassword": "Random password",
|
||||
"task.changeEmail": "Email change",
|
||||
"task.changePhone": "Phone change",
|
||||
"task.removePhone": "Phone removal",
|
||||
"task.removeGuard": "Guard removal",
|
||||
// -- Badges / statuses --
|
||||
"status.unknown": "Unknown",
|
||||
"status.valid": "Valid",
|
||||
"status.invalid": "Invalid",
|
||||
"status.locked": "Locked",
|
||||
"status.limited": "Limited",
|
||||
"status.banned": "BANNED",
|
||||
"status.ok": "OK",
|
||||
// -- Tooltips --
|
||||
"tip.openBrowser": "Open Steam in browser",
|
||||
"tip.copyPassword": "Copy password",
|
||||
"tip.copyLoginPass": "Copy login:pass",
|
||||
"tip.copyEmailPass": "Copy email password",
|
||||
"tip.copyEmailLoginPass": "Copy email:pass",
|
||||
"tip.show": "Show",
|
||||
"tip.hide": "Hide",
|
||||
"tip.gen2fa": "Generate and copy 2FA code",
|
||||
"tip.code": "Code",
|
||||
"tip.addNote": "Click to add a note",
|
||||
"tip.edit": "Edit",
|
||||
"tip.delete": "Delete",
|
||||
"tip.autoAcceptActive": "Login auto-accept",
|
||||
"tip.validate": "Validate",
|
||||
"tip.columnSettings": "Column settings",
|
||||
"tip.deleteProxy": "Delete proxy",
|
||||
"tip.clickToCopy": "Click to copy",
|
||||
"tip.proxyError": "Proxy error: {error}",
|
||||
// -- Placeholders --
|
||||
"ph.search": "Search: login / Steam ID / notes / lvl>10",
|
||||
"ph.searchLogpass": "Search: login / Steam ID / notes / game / lvl>10",
|
||||
"ph.login": "Login",
|
||||
"ph.password": "Password",
|
||||
"ph.proxy": "Proxy",
|
||||
"ph.proxyOptional": "Proxy (optional)",
|
||||
"ph.notes": "Notes",
|
||||
"ph.notesOptional": "Notes (optional)",
|
||||
"ph.loginOptional": "Login (optional)",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "No accounts. Import a file",
|
||||
"empty.logpass": "No accounts. Import a file.",
|
||||
"empty.tokens": "No tokens. Import or add tokens.",
|
||||
"empty.logs": "No logs",
|
||||
"empty.tasks": "No tasks",
|
||||
// -- Modals --
|
||||
"modal.addAccount": "Add account",
|
||||
"modal.editAccount": "Edit account",
|
||||
"modal.importAccounts": "Import accounts",
|
||||
"modal.addToken": "Add token",
|
||||
"modal.importTokens": "Import tokens",
|
||||
// -- Import --
|
||||
"import.formats": "Formats",
|
||||
"import.formatsColon": "Formats:",
|
||||
"import.delimiter": "delimiter",
|
||||
"import.or": "or",
|
||||
"import.dragTxt": "Drag a .txt file or click to select",
|
||||
"import.dragTxtCsv": "Drag a .txt / .csv file or click to select",
|
||||
"import.dragMafile": "Drag .mafile files or click to select",
|
||||
"import.selected": "Selected: {name}",
|
||||
"import.uploading": "Uploading...",
|
||||
"import.importBtn": "Import",
|
||||
"import.tokenFormat": "refresh_token (one per line)",
|
||||
// -- Pagination --
|
||||
"paging.shown": "Showing {visible} of {total}…",
|
||||
// -- Proxy labels --
|
||||
"proxy.label": "Proxy {num}",
|
||||
"proxy.title": "Proxies",
|
||||
"proxy.count": "{count} total",
|
||||
"proxy.protocol": "Protocol:",
|
||||
"proxy.format": "Format:",
|
||||
"proxy.customFormat": "Custom format...",
|
||||
"proxy.formatConstructor": "Format constructor:",
|
||||
"proxy.preview": "Preview:",
|
||||
"proxy.dragFile": "Drag a .txt file with proxies or click to select",
|
||||
"proxy.addBtn": "Add",
|
||||
"proxy.checking": "Checking...",
|
||||
"proxy.checkAll": "Check all",
|
||||
"proxy.deleteAllBtn": "Delete all proxies",
|
||||
"proxy.confirmDeleteAll": "Delete all proxies? This cannot be undone.",
|
||||
"proxy.checkResult": "Total: {total} | Alive: {alive} | Dead: {dead}",
|
||||
"proxy.removeProxy": "Remove proxy",
|
||||
"proxy.reassignProxy": "Reassign proxy",
|
||||
"proxy.perLine": "Proxies one per line ({format})",
|
||||
"proxy.deleteProxy": "Delete proxy",
|
||||
// -- Tools --
|
||||
"tools.2faGenerator": "2FA Code Generator",
|
||||
"tools.validationSettings": "Validation Settings",
|
||||
"tools.loadProfile": "Load profile (nickname, avatar)",
|
||||
"tools.checkBans": "Check bans",
|
||||
"tools.maxThreads": "Max threads:",
|
||||
"tools.autoRevalidateBrowser": "Auto-revalidate on dead cookies (browser)",
|
||||
"tools.clickCopy": "Click to copy",
|
||||
"tools.genErrorStr": "Error",
|
||||
// -- Logs panel --
|
||||
"logs.title": "Log",
|
||||
"logs.clear": "Clear",
|
||||
"logs.activeTasks": "Active Tasks",
|
||||
// -- Mafile tab --
|
||||
"mafile.upload": "Upload Mafile",
|
||||
"mafile.exportSettings": "Export Settings",
|
||||
"mafile.exportFormat": "Format:",
|
||||
"mafile.flatFiles": "Flat files (.mafile)",
|
||||
"mafile.perAccountFolder": "Folder per account",
|
||||
"mafile.singleFile": "Single file",
|
||||
"mafile.variables": "Variables:",
|
||||
"mafile.folderName": "Folder name:",
|
||||
"mafile.mafileName": ".mafile name:",
|
||||
"mafile.txtSettings": ".txt Settings",
|
||||
"mafile.addGlobalTxt": "Add global",
|
||||
"mafile.withAllAccounts": "with all accounts",
|
||||
"mafile.addTxtPerFolder": "Add .txt to each folder",
|
||||
"mafile.skipFolders": "Don't create folder for each account",
|
||||
"mafile.txtFolderName": ".txt name in folder:",
|
||||
"mafile.txtLineFormat": ".txt line format:",
|
||||
"mafile.mafileFields": "Mafile Fields",
|
||||
"mafile.sessionFields": "Session Fields",
|
||||
"mafile.export": "Export",
|
||||
"mafile.noAccounts": "no accounts",
|
||||
"mafile.sentAccounts": "Sent Accounts",
|
||||
"mafile.clearBtn": "Clear",
|
||||
"mafile.insert": "Insert {value}",
|
||||
// -- Settings drawer --
|
||||
"settings.display": "Display",
|
||||
"settings.hidePasswords": "Hide passwords (spoiler)",
|
||||
// -- Token disclaimer --
|
||||
"token.disclaimerTitle": "Tab under development",
|
||||
"token.disclaimerBody1": "Token tab functionality is <strong>not complete</strong> and is under development.",
|
||||
"token.disclaimerBody2": "⚠ Token validation may invalidate (kill) tokens. Use at your own risk.",
|
||||
"token.disclaimerBody3": "Import, editing and any actions with tokens may work incorrectly.",
|
||||
"token.acceptRisk": "I understand the risks and want to continue",
|
||||
// -- 2FA --
|
||||
"twofa.copied": "2FA: {code} (copied)",
|
||||
"twofa.error": "2FA Error: {error}",
|
||||
// -- Misc --
|
||||
"misc.task": "Task {id}",
|
||||
"misc.taskAction": "Task {id} - {action} ({login})",
|
||||
"misc.taskCount": "Task {id} ({count} accounts)",
|
||||
"misc.error": "Error: {error}",
|
||||
"misc.proxyErrorCheck": "Check error: {error}",
|
||||
"misc.exportError": "Export error: {error}",
|
||||
"misc.genError": "Generation error: {error}",
|
||||
"misc.errorStr": "Error",
|
||||
"misc.insert": "Insert",
|
||||
},
|
||||
};
|
||||
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const dict = translations[_locale] || translations.ru;
|
||||
let text = dict[key] ?? translations.ru[key] ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
text = text.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
useSyncExternalStore(
|
||||
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
|
||||
() => _locale,
|
||||
);
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { StrictMode, Component } from "react";
|
||||
import type { ReactNode, ErrorInfo } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null };
|
||||
static getDerivedStateFromError(error: Error) { return { error }; }
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("React crash:", error, info);
|
||||
}
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 32, fontFamily: "monospace", background: "#0f0f0f", color: "#f87171", minHeight: "100vh" }}>
|
||||
<h2 style={{ fontSize: 18, marginBottom: 12 }}>⚠ SteamPanel failed to start</h2>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: 13, color: "#fca5a5" }}>
|
||||
{(error as Error).message}
|
||||
{"\n\n"}
|
||||
{(error as Error).stack}
|
||||
</pre>
|
||||
<p style={{ marginTop: 16, color: "#9ca3af", fontSize: 12 }}>
|
||||
Open browser console (F12) for more details, or report this error.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { create } from "zustand";
|
||||
import type { Account } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
interface AccountState {
|
||||
accounts: Account[];
|
||||
selectedIds: Set<number>;
|
||||
loading: boolean;
|
||||
|
||||
loadAccounts: () => Promise<void>;
|
||||
toggleSelect: (id: number) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
setSelectedIds: (ids: Set<number>) => void;
|
||||
|
||||
/** IDs of accounts explicitly sent to the Mafile Manager tab */
|
||||
mafileManagerIds: Set<number>;
|
||||
sendToMafileManager: (ids: number[]) => void;
|
||||
clearMafileManager: () => void;
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountState>((set) => ({
|
||||
accounts: [],
|
||||
selectedIds: new Set(),
|
||||
loading: false,
|
||||
|
||||
loadAccounts: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await api.getAccounts();
|
||||
set({ accounts: data });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelect: (id) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { selectedIds: next };
|
||||
}),
|
||||
|
||||
selectAll: () =>
|
||||
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
setSelectedIds: (ids) => set({ selectedIds: ids }),
|
||||
|
||||
mafileManagerIds: new Set(),
|
||||
sendToMafileManager: (ids) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.mafileManagerIds);
|
||||
ids.forEach((id) => next.add(id));
|
||||
return { mafileManagerIds: next };
|
||||
}),
|
||||
clearMafileManager: () => set({ mafileManagerIds: new Set() }),
|
||||
}));
|
||||
@@ -0,0 +1,43 @@
|
||||
import { create } from "zustand";
|
||||
import type { LogpassAccount } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
interface LogpassState {
|
||||
accounts: LogpassAccount[];
|
||||
selectedIds: Set<number>;
|
||||
loading: boolean;
|
||||
|
||||
loadAccounts: () => Promise<void>;
|
||||
toggleSelect: (id: number) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export const useLogpassStore = create<LogpassState>((set) => ({
|
||||
accounts: [],
|
||||
selectedIds: new Set(),
|
||||
loading: false,
|
||||
|
||||
loadAccounts: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await api.getLogpassAccounts();
|
||||
set({ accounts: data });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelect: (id) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { selectedIds: next };
|
||||
}),
|
||||
|
||||
selectAll: () =>
|
||||
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from "zustand";
|
||||
import type { Task } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
interface TaskState {
|
||||
tasks: Task[];
|
||||
hasRunning: boolean;
|
||||
loadTasks: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useTaskStore = create<TaskState>((set) => ({
|
||||
tasks: [],
|
||||
hasRunning: false,
|
||||
loadTasks: async () => {
|
||||
const data = await api.getTasks();
|
||||
set({
|
||||
tasks: data,
|
||||
hasRunning: data.some((t) => t.status === "running" || t.status === "pending"),
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,43 @@
|
||||
import { create } from "zustand";
|
||||
import type { TokenAccount } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
interface TokenState {
|
||||
accounts: TokenAccount[];
|
||||
selectedIds: Set<number>;
|
||||
loading: boolean;
|
||||
|
||||
loadAccounts: () => Promise<void>;
|
||||
toggleSelect: (id: number) => void;
|
||||
selectAll: () => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export const useTokenStore = create<TokenState>((set) => ({
|
||||
accounts: [],
|
||||
selectedIds: new Set(),
|
||||
loading: false,
|
||||
|
||||
loadAccounts: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await api.getTokenAccounts();
|
||||
set({ accounts: data });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelect: (id) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { selectedIds: next };
|
||||
}),
|
||||
|
||||
selectAll: () =>
|
||||
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
|
||||
|
||||
clearSelection: () => set({ selectedIds: new Set() }),
|
||||
}));
|
||||
@@ -0,0 +1,115 @@
|
||||
import { create } from "zustand";
|
||||
import type { TabId, Toast, ToastType, ColumnSettings, LogpassColumnSettings, TokenColumnSettings } from "@/api/types";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
let _toastId = 0;
|
||||
|
||||
const DEFAULT_COLUMNS: ColumnSettings = {
|
||||
browser: true, profile: true, steam_id: true, login: true, password: true,
|
||||
login_pass: true, email: true, email_pass: false, email_login_pass: false,
|
||||
phone: true, status: true,
|
||||
ban: true, twofa: true, mafile: true, proxy: true, notes: true, actions: true,
|
||||
last_online: true,
|
||||
};
|
||||
|
||||
const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
|
||||
browser: true, profile: true, steam_id: true, login: true, password: true, login_pass: true,
|
||||
status: true, ban: true, prime: true, trophy: true, behavior: true,
|
||||
license: true, proxy: true, notes: true, actions: true, last_online: true,
|
||||
};
|
||||
|
||||
const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
|
||||
browser: true, profile: true, last_online: true, steam_id: true, login: true,
|
||||
token: true, status: true, proxy: true, notes: true, actions: true,
|
||||
};
|
||||
|
||||
interface UiState {
|
||||
activeTab: TabId;
|
||||
setTab: (tab: TabId) => void;
|
||||
accountSection: "mafile" | "logpass" | "token";
|
||||
setAccountSection: (s: "mafile" | "logpass" | "token") => void;
|
||||
toasts: Toast[];
|
||||
addToast: (type: ToastType, message: string) => void;
|
||||
removeToast: (id: number) => void;
|
||||
hidePasswords: boolean;
|
||||
setHidePasswords: (v: boolean) => void;
|
||||
loadDisplaySettings: () => Promise<void>;
|
||||
columnVisibility: ColumnSettings;
|
||||
setColumnVisibility: (v: ColumnSettings) => void;
|
||||
toggleColumn: (key: keyof ColumnSettings) => void;
|
||||
loadColumnSettings: () => Promise<void>;
|
||||
logpassColumnVisibility: LogpassColumnSettings;
|
||||
toggleLogpassColumn: (key: keyof LogpassColumnSettings) => void;
|
||||
loadLogpassColumnSettings: () => Promise<void>;
|
||||
tokenColumnVisibility: TokenColumnSettings;
|
||||
toggleTokenColumn: (key: keyof TokenColumnSettings) => void;
|
||||
loadTokenColumnSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set, get) => ({
|
||||
activeTab: "accounts",
|
||||
setTab: (tab) => set({ activeTab: tab }),
|
||||
accountSection: "mafile",
|
||||
setAccountSection: (s) => set({ accountSection: s }),
|
||||
toasts: [],
|
||||
addToast: (type, message) => {
|
||||
const id = ++_toastId;
|
||||
set((s) => ({ toasts: [...s.toasts, { id, type, message }] }));
|
||||
setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, 4000);
|
||||
},
|
||||
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
|
||||
hidePasswords: false,
|
||||
setHidePasswords: (v) => {
|
||||
set({ hidePasswords: v });
|
||||
api.updateDisplaySettings({ hide_passwords: v }).catch(() => {});
|
||||
},
|
||||
loadDisplaySettings: async () => {
|
||||
try {
|
||||
const d = await api.getDisplaySettings();
|
||||
set({ hidePasswords: d.hide_passwords });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
columnVisibility: { ...DEFAULT_COLUMNS },
|
||||
setColumnVisibility: (v) => set({ columnVisibility: v }),
|
||||
toggleColumn: (key) => {
|
||||
const cols = { ...get().columnVisibility, [key]: !get().columnVisibility[key] };
|
||||
set({ columnVisibility: cols });
|
||||
api.updateColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
loadColumnSettings: async () => {
|
||||
try {
|
||||
const c = await api.getColumnSettings();
|
||||
set({ columnVisibility: { ...DEFAULT_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS },
|
||||
toggleLogpassColumn: (key) => {
|
||||
const cols = { ...get().logpassColumnVisibility, [key]: !get().logpassColumnVisibility[key] };
|
||||
set({ logpassColumnVisibility: cols });
|
||||
api.updateLogpassColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
loadLogpassColumnSettings: async () => {
|
||||
try {
|
||||
const c = await api.getLogpassColumnSettings();
|
||||
set({ logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS },
|
||||
toggleTokenColumn: (key) => {
|
||||
const cols = { ...get().tokenColumnVisibility, [key]: !get().tokenColumnVisibility[key] };
|
||||
set({ tokenColumnVisibility: cols });
|
||||
api.updateTokenColumnSettings(cols).catch(() => {});
|
||||
},
|
||||
loadTokenColumnSettings: async () => {
|
||||
try {
|
||||
const c = await api.getTokenColumnSettings();
|
||||
set({ tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS, ...c } });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../app/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user