This commit is contained in:
Manchik
2026-04-17 22:22:40 +03:00
commit c82866b58a
135 changed files with 18493 additions and 0 deletions
+24
View File
@@ -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?
+73
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -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>
+3619
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
+4
View File
@@ -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

+36
View File
@@ -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>
);
}
+182
View File
@@ -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) }),
};
+260
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+5
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
+58
View File
@@ -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>
);
}
+17
View File
@@ -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]);
}
+26
View File
@@ -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]);
}
+262
View File
@@ -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; }
+8
View File
@@ -0,0 +1,8 @@
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+11
View File
@@ -0,0 +1,11 @@
const entityMap: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (s) => entityMap[s] ?? s);
}
+598
View File
@@ -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;
}
+40
View File
@@ -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>,
);
+59
View File
@@ -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() }),
}));
+43
View File
@@ -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() }),
}));
+21
View File
@@ -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"),
});
},
}));
+43
View File
@@ -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() }),
}));
+115
View File
@@ -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 */ }
},
}));
+29
View File
@@ -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"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"moduleResolution": "bundler",
"paths": { "@/*": ["./src/*"] }
}
}
+26
View File
@@ -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"]
}
+19
View File
@@ -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,
},
})