This commit is contained in:
Manchik
2026-05-08 22:46:47 +03:00
parent 9b7001407f
commit 13dc22f74f
30 changed files with 4107 additions and 843 deletions
+5 -1
View File
@@ -12,12 +12,16 @@ 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);
const loadLogpassColumnSettings = useUiStore(
(s) => s.loadLogpassColumnSettings,
);
const loadImportSettings = useUiStore((s) => s.loadImportSettings);
useEffect(() => {
loadDisplaySettings();
loadColumnSettings();
loadLogpassColumnSettings();
loadImportSettings();
}, []);
return (
+240 -62
View File
@@ -1,9 +1,24 @@
import type {
Account, AccountCreate, AccountUpdate, BulkImportResult,
Proxy, Task, MafileInfo, MafileExportRequest,
ActionRequest, ValidationSettings, DisplaySettings, ColumnSettings, LogpassColumnSettings, LogEntry,
LogpassAccount, LogpassAccountCreate, LogpassAccountUpdate,
TokenAccount, TokenAccountCreate, TokenColumnSettings,
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";
@@ -29,76 +44,144 @@ export const api = {
getAccounts: () => request<Account[]>("/accounts"),
getAccount: (id: number) => request<Account>(`/accounts/${id}`),
createAccount: (data: AccountCreate) =>
request<Account>("/accounts", { method: "POST", body: JSON.stringify(data) }),
request<Account>("/accounts", {
method: "POST",
body: JSON.stringify(data),
}),
updateAccount: (id: number, data: AccountUpdate) =>
request<Account>(`/accounts/${id}`, { method: "PUT", body: JSON.stringify(data) }),
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 }) }),
request<{ deleted: number }>("/accounts/delete-bulk", {
method: "POST",
body: JSON.stringify({ ids }),
}),
assignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/assign-proxies", { method: "POST" }),
request<{ assigned: number; proxies_used: number }>(
"/accounts/assign-proxies",
{ method: "POST" },
),
reassignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/reassign-proxies", { method: "POST" }),
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 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 }) }),
getConfirmations: (account_id: number) =>
request<{ success: boolean; confirmations: import("./types").SteamConfirmation[] }>(`/actions/confirmations/${account_id}`),
respondConfirmations: (account_id: number, ids: string[], nonces: string[], accept: boolean) =>
request<{ success: boolean }>(`/actions/confirmations/${account_id}/respond`, {
request<{ task_id: string; accounts_count: number }>("/actions", {
method: "POST",
body: JSON.stringify({ ids, nonces, accept }),
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 }),
}),
getConfirmations: (account_id: number) =>
request<{
success: boolean;
confirmations: import("./types").SteamConfirmation[];
}>(`/actions/confirmations/${account_id}`),
respondConfirmations: (
account_id: number,
ids: string[],
nonces: string[],
accept: boolean,
) =>
request<{ success: boolean }>(
`/actions/confirmations/${account_id}/respond`,
{
method: "POST",
body: JSON.stringify({ ids, nonces, accept }),
},
),
// Proxies
getProxies: () => request<Proxy[]>("/proxies"),
addProxy: (address: string, protocol = "http") =>
request<Proxy>("/proxies", { method: "POST", body: JSON.stringify({ address, protocol }) }),
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) }),
request<{ added: number }>("/proxies/bulk", {
method: "POST",
body: JSON.stringify(proxies),
}),
checkProxies: () =>
request<{ total: number; alive: number; dead: number }>("/proxies/check", { method: "POST" }),
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" }),
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[] }>;
});
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"),
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",
@@ -110,80 +193,175 @@ export const api = {
}),
// Settings
getValidationSettings: () => request<ValidationSettings>("/settings/validation"),
getValidationSettings: () =>
request<ValidationSettings>("/settings/validation"),
updateValidationSettings: (data: ValidationSettings) =>
request<ValidationSettings>("/settings/validation", { method: "PUT", body: JSON.stringify(data) }),
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) }),
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"),
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) }),
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 }) }),
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 }) }),
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" }),
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) }),
request<LogpassAccount>("/logpass", {
method: "POST",
body: JSON.stringify(data),
}),
updateLogpassAccount: (id: number, data: LogpassAccountUpdate) =>
request<LogpassAccount>(`/logpass/${id}`, { method: "PUT", body: JSON.stringify(data) }),
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 }) }),
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 }) }),
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 }) }),
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 }) }),
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" }),
request<{ assigned: number; proxies_used: number }>(
"/logpass/assign-proxies",
{ method: "POST" },
),
reassignLogpassProxies: () =>
request<{ assigned: number; proxies_used: number }>("/logpass/reassign-proxies", { method: "POST" }),
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" }),
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) }),
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 }) }),
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 }) }),
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 }) }),
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" }),
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" }),
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" }),
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"),
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) }),
request<TokenColumnSettings>("/settings/token-columns", {
method: "PUT",
body: JSON.stringify(data),
}),
fullCheckTokens: (ids: number[]) =>
request<{ task_id: string; accounts_count: number }>(
"/token-accounts/full-check",
{
method: "POST",
body: JSON.stringify({ account_ids: ids }),
},
),
getTokenCheckData: (id: number) =>
request<import("./types").TokenCheckData>(
`/token-accounts/${id}/check-data`,
),
downloadTokenCookies: (id: number) => {
const a = document.createElement("a");
a.href = `${BASE}/token-accounts/${id}/cookies`;
a.download = "";
a.click();
},
};
+38 -1
View File
@@ -71,7 +71,10 @@ export interface Task {
step_label?: string;
active_count?: number;
account_ids?: string | null;
account_results?: Record<string, { status: string; error?: string }> | 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;
@@ -118,6 +121,14 @@ export interface ValidationSettings {
check_ban: boolean;
max_threads: number;
auto_revalidate_browser: boolean;
auto_proxy_on_import: boolean;
auto_validate_on_import: boolean;
auto_proxy_on_import_mafile: boolean;
auto_proxy_on_import_logpass: boolean;
auto_proxy_on_import_token: boolean;
auto_validate_on_import_mafile: boolean;
auto_validate_on_import_logpass: boolean;
auto_validate_on_import_token: boolean;
}
export interface DisplaySettings {
@@ -259,6 +270,32 @@ export interface Toast {
message: string;
}
export interface TokenCheckData {
steam_id: string | null;
display_name: string | null;
avatar_url: string | null;
steam_level: number | null;
last_online: string | null;
balance_raw: string | null;
user_country: string | null;
family_group: boolean;
playtime_2weeks: number;
inventory_cs2: number;
inventory_dota2: number;
inventory_tf2: number;
inventory_rust: number;
inventory_cs2_marketable: number;
inventory_dota2_marketable: number;
inventory_tf2_marketable: number;
inventory_rust_marketable: number;
trade_ban: string | null;
created_date: string | null;
phone_digits: string | null;
alert_status: string;
market_limited: boolean;
checked_at: string | null;
}
export interface SteamConfirmation {
id: string;
nonce: string;
+125 -27
View File
@@ -6,17 +6,45 @@ 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",
"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",
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 {
@@ -26,19 +54,47 @@ interface Props {
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;
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);
export function AccountTable({
accounts,
processingIds,
accountResults,
accountSteps,
onEdit,
onDelete,
onAction,
onToggleAutoAccept,
onOpenBrowser,
}: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds);
const setSelectedIds = useAccountStore((s) => s.setSelectedIds);
const cols = useUiStore((s) => s.columnVisibility);
const t = useT();
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const allFilteredSelected =
accounts.length > 0 && accounts.every((a) => selectedIds.has(a.id));
const toggleSelectAllFiltered = () => {
if (allFilteredSelected) {
// Deselect only the filtered accounts, keep others selected
const next = new Set(selectedIds);
accounts.forEach((a) => next.delete(a.id));
setSelectedIds(next);
} else {
// Add all filtered accounts to selection
const next = new Set(selectedIds);
accounts.forEach((a) => next.add(a.id));
setSelectedIds(next);
}
};
const visibleKeys = COLUMN_KEYS.filter((k) => cols[k]);
const BATCH = 100;
@@ -52,7 +108,10 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const toggleSort = (field: SortField) => {
if (sortField === field) {
if (sortDir === "asc") setSortDir("desc");
else { setSortField(null); setSortDir("asc"); }
else {
setSortField(null);
setSortDir("asc");
}
} else {
setSortField(field);
setSortDir("asc");
@@ -64,7 +123,9 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol();
return [...accounts].sort((a, b) => {
const parseOnline = (v: string | null | undefined): number | typeof EMPTY => {
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);
@@ -84,16 +145,25 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
});
}, [accounts, sortField, sortDir]);
useEffect(() => { setVisibleCount(BATCH); }, [accounts, sortField, sortDir]);
useEffect(() => {
setVisibleCount(BATCH);
}, [accounts, sortField, sortDir]);
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
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" });
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]);
@@ -135,11 +205,13 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={allSelected}
onChange={() => allSelected ? clearSelection() : selectAll()}
checked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
/>
{selectedIds.size > 0 && (
<span className="text-accent font-medium">{selectedIds.size}</span>
<span className="text-accent font-medium">
{selectedIds.size}
</span>
)}
</div>
</th>
@@ -147,12 +219,28 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
{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
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>;
return (
<th
key={k}
className={`px-3 py-2${k === "browser" ? " text-center" : ""}`}
>
{t(COL_LABEL_KEYS[k])}
</th>
);
})}
</tr>
</thead>
@@ -174,7 +262,17 @@ export function AccountTable({ accounts, processingIds, accountResults, accountS
/>
))}
{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>
<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>
File diff suppressed because it is too large Load Diff
+55 -14
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
import type { AccountCreate } from "@/api/types";
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";
@@ -14,7 +14,7 @@ interface Props {
export function AddModal({ onSave, onClose }: Props) {
const t = useT();
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
@@ -63,7 +63,10 @@ export function AddModal({ onSave, onClose }: Props) {
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<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
@@ -80,14 +83,39 @@ export function AddModal({ onSave, onClose }: Props) {
placeholder={t("ph.login") + " (login:pass)"}
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={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"
/>
<div className="grid grid-cols-2 gap-2">
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder={t("ph.emailPassword")} className="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="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder={t("ph.emailPassword")}
className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="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="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="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="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
</div>
<div
@@ -100,19 +128,32 @@ export function AddModal({ onSave, onClose }: Props) {
</span>
{mafile && (
<button
onClick={(e) => { e.stopPropagation(); setMafile(null); }}
onClick={(e) => {
e.stopPropagation();
setMafile(null);
}}
className="text-xs text-red-400 hover:text-red-300 ml-1"
>
</button>
)}
</div>
<input ref={fileRef} type="file" accept=".mafile" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}} />
<input
ref={fileRef}
type="file"
accept=".mafile"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}}
/>
<button onClick={handleSave} disabled={!login || !password || saving} className="btn-primary w-full">
<button
onClick={handleSave}
disabled={!login || !password || saving}
className="btn-primary w-full"
>
{saving ? t("import.uploading") : t("btn.add")}
</button>
</div>
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function ImportModal({ onClose }: Props) {
export function ImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<string>("");
@@ -28,10 +29,23 @@ export function ImportModal({ onClose }: Props) {
if (!file) return;
setUploading(true);
try {
const idsBefore = new Set(
useAccountStore.getState().accounts.map((a) => a.id),
);
const res = await api.importAccounts(file);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useAccountStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -41,13 +55,30 @@ export function ImportModal({ onClose }: Props) {
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="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:{"{"}mafile{"}"}:notes</p>
<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:{"{"}mafile{"}"}:notes
</p>
<p className="font-mono text-xs">login:pass:email:email_pass</p>
<p className="font-mono text-xs">login:pass:email:email_pass:notes</p>
</div>
@@ -59,14 +90,26 @@ export function ImportModal({ onClose }: Props) {
>
<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")}
{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">
<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>}
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function LogpassImportModal({ onClose }: Props) {
export function LogpassImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
@@ -29,12 +30,33 @@ export function LogpassImportModal({ onClose }: Props) {
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 lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) {
setResult(t("toast.fileEmpty"));
setUploading(false);
return;
}
// Snapshot IDs before import to detect new ones
const idsBefore = new Set(
useLogpassStore.getState().accounts.map((a) => a.id),
);
const res = await api.importLogpass(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useLogpassStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -44,14 +66,25 @@ export function LogpassImportModal({ onClose }: Props) {
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="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-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>
<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()}
@@ -61,14 +94,26 @@ export function LogpassImportModal({ onClose }: Props) {
>
<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")}
{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">
<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>}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
import { useEffect, useState } from "react";
import { api } from "@/api/client";
import type { TokenAccount, TokenCheckData } from "@/api/types";
import { copyText } from "@/lib/clipboard";
import { IconX, IconRefresh, IconLoader } from "@/components/shared/Icons";
interface Props {
account: TokenAccount;
onClose: () => void;
onRecheck: () => void;
}
function Row({
label,
value,
good,
bad,
}: {
label: string;
value: string;
good?: boolean;
bad?: boolean;
}) {
return (
<div className="flex text-[11px] font-mono leading-[1.7]">
<span className="w-[110px] shrink-0 text-gray-500">{label}</span>
<span
className={`min-w-0 break-words ${
good ? "text-green-400" : bad ? "text-red-400" : "text-gray-200"
}`}
>
{value}
</span>
</div>
);
}
function Card({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-dark-600 bg-dark-900/40 px-3 py-2 space-y-0">
<div className="text-[9px] font-semibold uppercase tracking-widest text-gray-600 pt-1 pb-0.5">
{title}
</div>
{children}
</div>
);
}
function fmtPlaytime(minutes: number): string {
if (minutes <= 0) return "0ч";
const h = (minutes / 60).toFixed(1);
return `${h}ч`;
}
function fmtCheckedAt(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
export function TokenCheckModal({ account, onClose, onRecheck }: Props) {
const [data, setData] = useState<TokenCheckData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getTokenCheckData(account.id)
.then((d) => {
if (!cancelled) {
setData(d);
setLoading(false);
}
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [account.id]);
const handleCopySteamId = () => {
if (data?.steam_id) copyText(data.steam_id);
};
// Close on backdrop click
const handleBackdrop = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) onClose();
};
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onClick={handleBackdrop}
>
<div className="bg-dark-800 border border-dark-600 rounded-xl w-full max-w-[640px] max-h-[85vh] flex flex-col shadow-2xl mx-4">
{/* Header */}
<div className="px-5 pt-4 pb-3 border-b border-dark-600 flex items-start justify-between shrink-0">
<div className="flex items-start gap-3 min-w-0">
{/* Avatar */}
<div className="h-9 w-9 rounded-lg bg-dark-700 shrink-0 overflow-hidden">
{(data?.avatar_url ?? account.avatar_url) ? (
<img
src={(data?.avatar_url ?? account.avatar_url)!}
alt=""
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-dark-600" />
)}
</div>
{/* Name + meta */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-gray-100 truncate">
{data?.display_name ??
account.nickname ??
account.login ??
"—"}
</span>
{(data?.steam_level ?? account.steam_level) != null && (
<span className="rounded bg-accent/15 border border-accent/30 px-1.5 py-0.5 text-[9px] font-bold text-accent">
Lv.{data?.steam_level ?? account.steam_level}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-gray-500 flex-wrap">
{(data?.steam_id ?? account.steam_id) && (
<button
onClick={handleCopySteamId}
className="font-mono hover:text-gray-300 transition"
title="Копировать SteamID"
>
{data?.steam_id ?? account.steam_id}
</button>
)}
{(data?.last_online ?? account.last_online) && (
<>
<span className="text-gray-700">·</span>
<span>{data?.last_online ?? account.last_online}</span>
</>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0 ml-3">
<button
onClick={onRecheck}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg bg-accent/10 border border-accent/30 text-accent hover:bg-accent/20 transition"
title="Запустить полную проверку"
>
<IconRefresh size={12} />
Обновить
</button>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-300 transition p-1 rounded"
title="Закрыть"
>
<IconX size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-auto min-h-0 px-5 py-4">
{loading && (
<div className="flex items-center justify-center py-16">
<IconLoader size={28} className="text-accent" />
</div>
)}
{error && !loading && (
<div className="flex items-center justify-center py-16 text-sm text-red-400">
{error}
</div>
)}
{data && !loading && (
<div className="grid grid-cols-2 gap-3">
{/* Identity */}
<Card title="Identity">
{account.login && <Row label="Логин" value={account.login} />}
{data.display_name && (
<Row label="Имя" value={data.display_name} />
)}
{data.steam_id && <Row label="SteamID" value={data.steam_id} />}
{data.user_country && (
<Row label="Страна" value={data.user_country} />
)}
<Row
label="Телефон"
value={
data.phone_digits
? `заканч. на ${data.phone_digits}`
: "Нет"
}
/>
{data.created_date && (
<Row label="Создан" value={data.created_date} />
)}
</Card>
{/* Security & Status */}
<Card title="Security & Status">
<Row
label="Trade Ban"
value={
data.trade_ban && data.trade_ban !== "None"
? data.trade_ban
: "Нет"
}
good={!data.trade_ban || data.trade_ban === "None"}
bad={!!data.trade_ban && data.trade_ban !== "None"}
/>
<Row
label="Alert"
value={
data.alert_status && data.alert_status !== "None"
? data.alert_status
: "Нет"
}
good={!data.alert_status || data.alert_status === "None"}
bad={!!data.alert_status && data.alert_status !== "None"}
/>
<Row
label="Market Lim"
value={data.market_limited ? "Да" : "Нет"}
good={!data.market_limited}
bad={data.market_limited}
/>
<Row label="Family" value={data.family_group ? "Да" : "Нет"} />
</Card>
{/* Economy & Activity */}
<Card title="Economy & Activity">
{data.balance_raw && (
<Row label="Баланс" value={data.balance_raw} />
)}
{data.steam_level != null && (
<Row label="Уровень" value={String(data.steam_level)} />
)}
{data.playtime_2weeks > 0 && (
<Row
label="Время за 2 нед"
value={fmtPlaytime(data.playtime_2weeks)}
/>
)}
</Card>
{/* Inventory */}
<Card title="Inventory">
<Row
label="CS2"
value={`${data.inventory_cs2} (${data.inventory_cs2_marketable})`}
/>
<Row
label="Dota 2"
value={`${data.inventory_dota2} (${data.inventory_dota2_marketable})`}
/>
<Row
label="TF2"
value={`${data.inventory_tf2} (${data.inventory_tf2_marketable})`}
/>
<Row
label="Rust"
value={`${data.inventory_rust} (${data.inventory_rust_marketable})`}
/>
</Card>
</div>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-5 py-2 border-t border-dark-600 text-[10px] text-gray-600">
{data?.checked_at
? `Последняя проверка: ${fmtCheckedAt(data.checked_at)}`
: "Данные ещё не получены"}
</div>
</div>
</div>
);
}
@@ -7,9 +7,10 @@ import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
onImportDone?: (newIds: number[]) => void;
}
export function TokenImportModal({ onClose }: Props) {
export function TokenImportModal({ onClose, onImportDone }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
@@ -29,12 +30,32 @@ export function TokenImportModal({ onClose }: Props) {
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 lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) {
setResult(t("toast.fileEmpty"));
setUploading(false);
return;
}
const idsBefore = new Set(
useTokenStore.getState().accounts.map((a) => a.id),
);
const res = await api.importTokens(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
setResult(
t("toast.importResult", {
imported: res.imported,
skipped: res.skipped,
}),
);
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
const newIds = useTokenStore
.getState()
.accounts.map((a) => a.id)
.filter((id) => !idsBefore.has(id));
onImportDone?.(newIds);
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
@@ -44,10 +65,17 @@ export function TokenImportModal({ onClose }: Props) {
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="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-medium text-gray-300">
{t("import.formatsColon")}
</p>
<p className="font-mono text-xs">{t("import.tokenFormat")}</p>
</div>
<div
@@ -58,14 +86,26 @@ export function TokenImportModal({ onClose }: Props) {
>
<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")}
{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">
<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>}
+549 -132
View File
@@ -8,9 +8,21 @@ import { SteamLevelBadge } from "./SteamLevelBadge";
import { TokenImportModal } from "./TokenImportModal";
import { TokenAddModal } from "./TokenAddModal";
import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown";
import { TokenCheckModal } from "./TokenCheckModal";
import {
IconDownload, IconPlus, IconTrash, IconPlay, IconGlobe, IconRefresh, IconBan,
IconCheckCircle, IconXCircle, IconCheck, IconAlertTriangle,
IconDownload,
IconPlus,
IconTrash,
IconPlay,
IconGlobe,
IconRefresh,
IconBan,
IconCheckCircle,
IconXCircle,
IconCheck,
IconAlertTriangle,
IconScrollText,
IconFileText,
} from "@/components/shared/Icons";
import type { Task } from "@/api/types";
import { useT } from "@/lib/i18n";
@@ -21,7 +33,11 @@ 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; }
try {
return JSON.parse(raw) as AccResults;
} catch {
return null;
}
}
return raw;
}
@@ -31,47 +47,63 @@ export function TokenTab() {
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 setSelectedIds = useTokenStore((s) => s.setSelectedIds);
const addToast = useUiStore((s) => s.addToast);
const autoProxyOnImport = useUiStore((s) => s.autoProxyOnImportToken);
const autoValidateOnImport = useUiStore((s) => s.autoValidateOnImportToken);
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 [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 [checkModalId, setCheckModalId] = useState<number | null>(null);
useEffect(() => { loadAccounts(); loadTokenColumnSettings(); }, [loadAccounts, loadTokenColumnSettings]);
useEffect(() => {
loadAccounts();
loadTokenColumnSettings();
}, [loadAccounts, loadTokenColumnSettings]);
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
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);
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 */
}
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]);
};
es.onerror = () => es.close();
},
[loadAccounts],
);
const handleValidate = useCallback(async () => {
const ids = [...selectedIds];
@@ -82,7 +114,17 @@ export function TokenTab() {
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: "" });
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());
@@ -90,45 +132,140 @@ export function TokenTab() {
}
}, [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; });
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 handleFullCheckSingle = useCallback(
async (id: number) => {
setProcessingIds(new Set([id]));
try {
const { task_id } = await api.fullCheckTokens([id]);
setActiveTask({
id: task_id,
type: "token_full_check",
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 handleFullCheck = useCallback(async () => {
const ids = [...selectedIds];
if (!ids.length) return;
clearSelection();
setProcessingIds(new Set(ids));
setAccountResults({});
setAccountSteps({});
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: "" });
const { task_id } = await api.fullCheckTokens(ids);
setActiveTask({
id: task_id,
type: "token_full_check",
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));
}
}, [watchTask, addToast]);
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleDownloadCookies = useCallback(
(account: (typeof accounts)[number]) => {
if (!account.has_cookies) {
addToast("warn", "Нет сохранённых куки. Сначала запустите валидацию.");
return;
}
api.downloadTokenCookies(account.id);
},
[addToast],
);
const handleDeleteSelected = () => {
const ids = [...selectedIds];
if (!ids.length) return;
confirm(t("confirm.deleteTokenSelected", { count: ids.length }), async () => {
await api.deleteTokenBulk(ids);
clearSelection();
loadAccounts();
});
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();
});
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 }));
addToast(
"success",
t("toast.proxiesAssignedShort", { count: r.assigned }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -136,9 +273,14 @@ export function TokenTab() {
confirm(t("confirm.reassignProxiesAll"), async () => {
try {
const r = await api.reassignTokenProxies();
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
addToast(
"success",
t("toast.proxiesReassignedShort", { count: r.assigned }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -146,9 +288,14 @@ export function TokenTab() {
confirm(t("confirm.clearProxies"), async () => {
try {
const r = await api.clearTokenProxies();
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
addToast(
"success",
t("toast.proxiesClearedShort", { count: r.cleared }),
);
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
});
};
@@ -159,7 +306,17 @@ export function TokenTab() {
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: "" });
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 {
@@ -170,7 +327,61 @@ export function TokenTab() {
}
};
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const handleAfterTokenImport = useCallback(
async (newIds: number[]) => {
let proxyOn = autoProxyOnImport;
let validateOn = autoValidateOnImport;
try {
const fresh = await api.getValidationSettings();
proxyOn = fresh.auto_proxy_on_import_token;
validateOn = fresh.auto_validate_on_import_token;
} catch {
/* fall back to stale store values */
}
if (proxyOn) {
try {
const r = await api.assignTokenProxies();
addToast(
"success",
t("toast.proxiesAssignedShort", { count: r.assigned }),
);
await loadAccounts();
} catch {
/* ignore */
}
}
if (validateOn && newIds.length) {
try {
const { task_id } = await api.validateTokens(newIds);
setActiveTask({
id: task_id,
type: "token_validate",
status: "running",
progress: 0,
total: newIds.length,
result: null,
error: null,
created_at: "",
updated_at: "",
});
setProcessingIds(new Set(newIds));
watchTask(task_id);
} catch (e: unknown) {
setProcessingIds(new Set());
addToast("error", e instanceof Error ? e.message : String(e));
}
}
},
[
autoProxyOnImport,
autoValidateOnImport,
addToast,
loadAccounts,
watchTask,
t,
],
);
const filtered = useMemo(() => {
if (!searchQuery.trim()) return accounts;
@@ -192,14 +403,29 @@ export function TokenTab() {
}
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)
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 allFilteredSelected =
filtered.length > 0 && filtered.every((a) => selectedIds.has(a.id));
const toggleSelectAllFiltered = () => {
if (allFilteredSelected) {
const next = new Set(selectedIds);
filtered.forEach((a) => next.delete(a.id));
setSelectedIds(next);
} else {
const next = new Set(selectedIds);
filtered.forEach((a) => next.add(a.id));
setSelectedIds(next);
}
};
const proxyLabels = useMemo(() => {
const map = new Map<number, string>();
const unique: string[] = [];
@@ -209,71 +435,80 @@ export function TokenTab() {
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 }));
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>
)}
<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>
<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>
<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"
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)}%` }}
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.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})` : "")}
: 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>}
{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>
</>
)}
@@ -281,8 +516,15 @@ export function TokenTab() {
<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>
<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>
@@ -295,12 +537,39 @@ export function TokenTab() {
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")}
<IconPlay size={12} className="inline mr-1" />
{t("btn.validate")}
</button>
<button
onClick={handleFullCheck}
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-secondary text-sm disabled:opacity-40"
>
<IconScrollText size={12} className="inline mr-1" />
{t("btn.fullCheck")}
</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>
<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"
@@ -319,47 +588,85 @@ export function TokenTab() {
<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>}
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleSelectAllFiltered}
/>
{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.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.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>}
{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>
<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">
<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)} />
<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>}
{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>
<span title={result.error}>
<IconAlertTriangle
size={16}
className="text-red-400"
/>
</span>
) : null}
</td>
{cols.browser && (
@@ -369,7 +676,9 @@ export function TokenTab() {
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>
>
{t("col.browser")}
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
@@ -378,27 +687,69 @@ export function TokenTab() {
{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="" />}
{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} />}
{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.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
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.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">
@@ -407,12 +758,56 @@ export function TokenTab() {
disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("tip.validate")}
><IconPlay size={14} /></button>
>
<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(); })}
onClick={() => handleFullCheckSingle(a.id)}
disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("btn.fullCheck")}
>
<IconScrollText size={14} />
</button>
<button
onClick={() => setCheckModalId(a.id)}
className="text-gray-400 hover:text-blue-400 transition"
title="Детали проверки"
>
<IconFileText size={14} />
</button>
<button
onClick={() => handleDownloadCookies(a)}
className={`transition ${
a.has_cookies
? "text-gray-400 hover:text-green-400"
: "text-gray-700 cursor-not-allowed"
}`}
title={
a.has_cookies
? "Скачать куки"
: "Куки отсутствуют"
}
>
<IconDownload 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>
>
<IconTrash size={14} />
</button>
</div>
</td>
)}
@@ -425,12 +820,34 @@ export function TokenTab() {
</div>
</div>
{showImport && <TokenImportModal onClose={() => setShowImport(false)} />}
{showImport && (
<TokenImportModal
onClose={() => setShowImport(false)}
onImportDone={handleAfterTokenImport}
/>
)}
{showAdd && <TokenAddModal onClose={() => setShowAdd(false)} />}
{checkModalId != null &&
(() => {
const acc = accounts.find((a) => a.id === checkModalId);
return acc ? (
<TokenCheckModal
account={acc}
onClose={() => setCheckModalId(null)}
onRecheck={() => {
setCheckModalId(null);
handleFullCheckSingle(acc.id);
}}
/>
) : null;
})()}
{confirmModal.open && (
<ConfirmModal
message={confirmModal.message}
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
onConfirm={() => {
confirmModal.onConfirm();
setConfirmModal((p) => ({ ...p, open: false }));
}}
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
/>
)}
@@ -5,16 +5,31 @@ 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 }) {
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)} />
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="toggle-track" />
</label>
);
}
function SettingRow({ label, desc, checked, onChange }: {
function SettingRow({
label,
desc,
checked,
onChange,
}: {
label: string;
desc?: string;
checked: boolean;
@@ -31,7 +46,13 @@ function SettingRow({ label, desc, checked, onChange }: {
);
}
function ThreadStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
function ThreadStepper({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -42,14 +63,32 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
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";
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>
<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>
<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}
@@ -57,18 +96,38 @@ function ThreadStepper({ value, onChange }: { value: number; onChange: (v: numbe
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
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); }}
onDoubleClick={() => {
setInput(String(value));
setEditing(true);
}}
title="Двойной клик для ручного ввода"
>{value}</span>
>
{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>
<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>
);
@@ -81,21 +140,40 @@ export function ValidationSettings() {
check_ban: true,
max_threads: 5,
auto_revalidate_browser: true,
auto_proxy_on_import: false,
auto_validate_on_import: false,
auto_proxy_on_import_mafile: true,
auto_proxy_on_import_logpass: true,
auto_proxy_on_import_token: true,
auto_validate_on_import_mafile: true,
auto_validate_on_import_logpass: true,
auto_validate_on_import_token: true,
});
const [importTab, setImportTab] = useState<"mafile" | "logpass" | "token">(
"mafile",
);
const hidePasswords = useUiStore((s) => s.hidePasswords);
const setHidePasswords = useUiStore((s) => s.setHidePasswords);
const loadImportSettings = useUiStore((s) => s.loadImportSettings);
const addToast = useUiStore((s) => s.addToast);
useEffect(() => {
api.getValidationSettings().then(setSettings).catch(() => {});
api
.getValidationSettings()
.then(setSettings)
.catch(() => {});
}, []);
const save = async () => {
try {
await api.updateValidationSettings(settings);
await loadImportSettings(); // sync stale uiStore values
addToast("success", t("toast.settingsSaved"));
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
addToast(
"error",
t("misc.error", { error: e instanceof Error ? e.message : String(e) }),
);
}
};
@@ -124,10 +202,75 @@ export function ValidationSettings() {
<SettingRow
label={t("tools.autoRevalidateBrowser")}
checked={settings.auto_revalidate_browser}
onChange={(v) => setSettings({ ...settings, auto_revalidate_browser: v })}
onChange={(v) =>
setSettings({ ...settings, auto_revalidate_browser: v })
}
/>
</div>
<ThreadStepper value={settings.max_threads} onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))} />
<ThreadStepper
value={settings.max_threads}
onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))}
/>
</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-1.5">
{t("tools.importSettings")}
</p>
{/* Tab switcher */}
<div className="flex gap-1 mb-3">
{(["mafile", "logpass", "token"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setImportTab(tab)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
importTab === tab
? "bg-accent/20 text-accent border border-accent/30"
: "text-gray-500 hover:text-gray-300 border border-transparent"
}`}
>
{t(
`tools.importTab${tab.charAt(0).toUpperCase() + tab.slice(1)}` as never,
)}
</button>
))}
</div>
{/* Per-tab toggles */}
{(["mafile", "logpass", "token"] as const).map((tab) =>
importTab === tab ? (
<div key={tab} className="divide-y divide-dark-600/60">
<SettingRow
label={t("tools.autoProxyOnImport")}
desc={t("tools.autoProxyOnImportDesc")}
checked={
settings[`auto_proxy_on_import_${tab}` as keyof VS] as boolean
}
onChange={(v) =>
setSettings({
...settings,
[`auto_proxy_on_import_${tab}`]: v,
})
}
/>
<SettingRow
label={t("tools.autoValidateOnImport")}
desc={t("tools.autoValidateOnImportDesc")}
checked={
settings[
`auto_validate_on_import_${tab}` as keyof VS
] as boolean
}
onChange={(v) =>
setSettings({
...settings,
[`auto_validate_on_import_${tab}`]: v,
})
}
/>
</div>
) : null,
)}
</div>
<div className="px-5 pt-4 pb-3">
@@ -142,7 +285,9 @@ export function ValidationSettings() {
</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>
<button onClick={save} className="btn-primary">
{t("btn.save")}
</button>
</div>
</div>
);
+62 -17
View File
@@ -7,9 +7,13 @@ 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()); }
function notify() {
_listeners.forEach((l) => l());
}
export function getLocale(): Locale { return _locale; }
export function getLocale(): Locale {
return _locale;
}
export function setLocale(l: Locale) {
_locale = l;
@@ -19,7 +23,10 @@ export function setLocale(l: Locale) {
export function useLocale(): [Locale, (l: Locale) => void] {
const locale = useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
(cb) => {
_listeners.add(cb);
return () => _listeners.delete(cb);
},
() => _locale,
);
return [locale, setLocale];
@@ -82,7 +89,8 @@ const translations: Translations = {
"prompt.newPassword": "Введите новый пароль",
"prompt.newEmail": "Введите новый email",
"prompt.newPhone": "Введите новый номер телефона",
"prompt.removePhone": "Введите номер телефона (на который переставится старый)",
"prompt.removePhone":
"Введите номер телефона (на который переставится старый)",
"prompt.enterValue": "Введите значение...",
// -- Confirm dialogs --
"confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?",
@@ -123,6 +131,7 @@ const translations: Translations = {
"btn.clearProxies": "Очистить прокси",
"btn.validate": "Валидировать",
"btn.fullParse": "Полный парс",
"btn.fullCheck": "Полная проверка",
"btn.generate": "Сгенерировать",
// -- Toast messages --
"toast.copied": "Скопировано",
@@ -142,7 +151,8 @@ const translations: Translations = {
"toast.autoAcceptOff": "Авто-принятие входа выключено",
"toast.taskCancelled": "Задача отменена",
"toast.browserOpening": "Браузер открывается...",
"toast.cookiesExpiredRevalidating": "Куки устарели. Запущена повторная валидация...",
"toast.cookiesExpiredRevalidating":
"Куки устарели. Запущена повторная валидация...",
"toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.",
"toast.proxyCleared": "Прокси убран у {name}",
"toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.",
@@ -249,7 +259,8 @@ const translations: Translations = {
"proxy.checking": "Проверка...",
"proxy.checkAll": "Проверить все",
"proxy.deleteAllBtn": "Удалить все прокси",
"proxy.confirmDeleteAll": "Удалить все прокси? Это действие нельзя отменить.",
"proxy.confirmDeleteAll":
"Удалить все прокси? Это действие нельзя отменить.",
"proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}",
"proxy.removeProxy": "Убрать прокси",
"proxy.reassignProxy": "Переназначить прокси",
@@ -262,6 +273,15 @@ const translations: Translations = {
"tools.checkBans": "Проверять баны",
"tools.maxThreads": "Макс. потоков:",
"tools.autoRevalidateBrowser": "Автовалидация при мёртвых куках (браузер)",
"tools.autoProxyOnImport": "Авто-назначение прокси при импорте",
"tools.autoValidateOnImport": "Авто-валидация при импорте",
"tools.importSettings": "Настройки импорта",
"tools.autoProxyOnImportDesc": "Авто-назначить прокси сразу после импорта",
"tools.autoValidateOnImportDesc":
"Авто-валидировать аккаунты сразу после импорта",
"tools.importTabMafile": "Mafile",
"tools.importTabLogpass": "Log:Pass",
"tools.importTabToken": "Token",
"tools.clickCopy": "Клик для копирования",
"tools.genErrorStr": "Ошибка",
// -- Logs panel --
@@ -293,7 +313,8 @@ const translations: Translations = {
"mafile.clearBtn": "Очистить",
"mafile.insert": "Вставить {value}",
"mafile.emptyTitle": "Нет выбранных аккаунтов",
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
"mafile.emptyHint":
"Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
"mafile.accountsCount": "акк.",
"mafile.withMafile": "с mafile",
"mafile.namingSection": "Шаблоны имён",
@@ -305,9 +326,12 @@ const translations: Translations = {
"settings.hidePasswords": "Скрывать пароли (спойлер)",
// -- Token disclaimer --
"token.disclaimerTitle": "Вкладка в разработке",
"token.disclaimerBody1": "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
"token.disclaimerBody2": "⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
"token.disclaimerBody3": "Импорт, редактирование и любые действия с токенами могут работать некорректно.",
"token.disclaimerBody1":
"Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
"token.disclaimerBody2":
"⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
"token.disclaimerBody3":
"Импорт, редактирование и любые действия с токенами могут работать некорректно.",
"token.acceptRisk": "Я понимаю риски и хочу продолжить",
// -- 2FA --
"twofa.copied": "2FA: {code} (скопировано)",
@@ -377,7 +401,8 @@ const translations: Translations = {
"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.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?",
@@ -418,6 +443,7 @@ const translations: Translations = {
"btn.clearProxies": "Clear proxies",
"btn.validate": "Validate",
"btn.fullParse": "Full parse",
"btn.fullCheck": "Full Check",
"btn.generate": "Generate",
// -- Toast messages --
"toast.copied": "Copied",
@@ -557,6 +583,15 @@ const translations: Translations = {
"tools.checkBans": "Check bans",
"tools.maxThreads": "Max threads:",
"tools.autoRevalidateBrowser": "Auto-revalidate on dead cookies (browser)",
"tools.autoProxyOnImport": "Auto-assign proxies on import",
"tools.autoValidateOnImport": "Auto-validate on import",
"tools.importSettings": "Import Settings",
"tools.autoProxyOnImportDesc": "Auto-assign proxies right after import",
"tools.autoValidateOnImportDesc":
"Auto-validate accounts right after import",
"tools.importTabMafile": "Mafile",
"tools.importTabLogpass": "Log:Pass",
"tools.importTabToken": "Token",
"tools.clickCopy": "Click to copy",
"tools.genErrorStr": "Error",
// -- Logs panel --
@@ -588,7 +623,8 @@ const translations: Translations = {
"mafile.clearBtn": "Clear",
"mafile.insert": "Insert {value}",
"mafile.emptyTitle": "No accounts selected",
"mafile.emptyHint": "Select accounts in the table and send them here for mafile export",
"mafile.emptyHint":
"Select accounts in the table and send them here for mafile export",
"mafile.accountsCount": "accs",
"mafile.withMafile": "with mafile",
"mafile.namingSection": "Naming templates",
@@ -600,9 +636,12 @@ const translations: Translations = {
"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.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)",
@@ -620,7 +659,10 @@ const translations: Translations = {
},
};
export function t(key: string, params?: Record<string, string | number>): string {
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) {
@@ -633,7 +675,10 @@ export function t(key: string, params?: Record<string, string | number>): string
export function useT() {
useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
(cb) => {
_listeners.add(cb);
return () => _listeners.delete(cb);
},
() => _locale,
);
return t;
+2
View File
@@ -11,6 +11,7 @@ interface LogpassState {
toggleSelect: (id: number) => void;
selectAll: () => void;
clearSelection: () => void;
setSelectedIds: (ids: Set<number>) => void;
}
export const useLogpassStore = create<LogpassState>((set) => ({
@@ -40,4 +41,5 @@ export const useLogpassStore = create<LogpassState>((set) => ({
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
clearSelection: () => set({ selectedIds: new Set() }),
setSelectedIds: (ids) => set({ selectedIds: ids }),
}));
+2
View File
@@ -11,6 +11,7 @@ interface TokenState {
toggleSelect: (id: number) => void;
selectAll: () => void;
clearSelection: () => void;
setSelectedIds: (ids: Set<number>) => void;
}
export const useTokenStore = create<TokenState>((set) => ({
@@ -40,4 +41,5 @@ export const useTokenStore = create<TokenState>((set) => ({
set((s) => ({ selectedIds: new Set(s.accounts.map((a) => a.id)) })),
clearSelection: () => set({ selectedIds: new Set() }),
setSelectedIds: (ids) => set({ selectedIds: ids }),
}));
+112 -18
View File
@@ -1,26 +1,67 @@
import { create } from "zustand";
import type { TabId, Toast, ToastType, ColumnSettings, LogpassColumnSettings, TokenColumnSettings } from "@/api/types";
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,
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,
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,
browser: true,
profile: true,
last_online: true,
steam_id: true,
login: true,
token: true,
status: true,
proxy: true,
notes: true,
actions: true,
};
interface UiState {
@@ -41,6 +82,15 @@ interface UiState {
logpassColumnVisibility: LogpassColumnSettings;
toggleLogpassColumn: (key: keyof LogpassColumnSettings) => void;
loadLogpassColumnSettings: () => Promise<void>;
autoProxyOnImport: boolean;
autoValidateOnImport: boolean;
autoProxyOnImportMafile: boolean;
autoProxyOnImportLogpass: boolean;
autoProxyOnImportToken: boolean;
autoValidateOnImportMafile: boolean;
autoValidateOnImportLogpass: boolean;
autoValidateOnImportToken: boolean;
loadImportSettings: () => Promise<void>;
tokenColumnVisibility: TokenColumnSettings;
toggleTokenColumn: (key: keyof TokenColumnSettings) => void;
loadTokenColumnSettings: () => Promise<void>;
@@ -59,7 +109,8 @@ export const useUiStore = create<UiState>((set, get) => ({
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 4000);
},
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
removeToast: (id) =>
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
hidePasswords: false,
setHidePasswords: (v) => {
@@ -70,13 +121,18 @@ export const useUiStore = create<UiState>((set, get) => ({
try {
const d = await api.getDisplaySettings();
set({ hidePasswords: d.hide_passwords });
} catch { /* ignore */ }
} catch {
/* ignore */
}
},
columnVisibility: { ...DEFAULT_COLUMNS },
setColumnVisibility: (v) => set({ columnVisibility: v }),
toggleColumn: (key) => {
const cols = { ...get().columnVisibility, [key]: !get().columnVisibility[key] };
const cols = {
...get().columnVisibility,
[key]: !get().columnVisibility[key],
};
set({ columnVisibility: cols });
api.updateColumnSettings(cols).catch(() => {});
},
@@ -84,12 +140,17 @@ export const useUiStore = create<UiState>((set, get) => ({
try {
const c = await api.getColumnSettings();
set({ columnVisibility: { ...DEFAULT_COLUMNS, ...c } });
} catch { /* ignore */ }
} catch {
/* ignore */
}
},
logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS },
toggleLogpassColumn: (key) => {
const cols = { ...get().logpassColumnVisibility, [key]: !get().logpassColumnVisibility[key] };
const cols = {
...get().logpassColumnVisibility,
[key]: !get().logpassColumnVisibility[key],
};
set({ logpassColumnVisibility: cols });
api.updateLogpassColumnSettings(cols).catch(() => {});
},
@@ -97,12 +158,43 @@ export const useUiStore = create<UiState>((set, get) => ({
try {
const c = await api.getLogpassColumnSettings();
set({ logpassColumnVisibility: { ...DEFAULT_LOGPASS_COLUMNS, ...c } });
} catch { /* ignore */ }
} catch {
/* ignore */
}
},
autoProxyOnImport: false,
autoValidateOnImport: false,
autoProxyOnImportMafile: true,
autoProxyOnImportLogpass: true,
autoProxyOnImportToken: true,
autoValidateOnImportMafile: true,
autoValidateOnImportLogpass: true,
autoValidateOnImportToken: true,
loadImportSettings: async () => {
try {
const s = await api.getValidationSettings();
set({
autoProxyOnImport: s.auto_proxy_on_import,
autoValidateOnImport: s.auto_validate_on_import,
autoProxyOnImportMafile: s.auto_proxy_on_import_mafile,
autoProxyOnImportLogpass: s.auto_proxy_on_import_logpass,
autoProxyOnImportToken: s.auto_proxy_on_import_token,
autoValidateOnImportMafile: s.auto_validate_on_import_mafile,
autoValidateOnImportLogpass: s.auto_validate_on_import_logpass,
autoValidateOnImportToken: s.auto_validate_on_import_token,
});
} catch {
/* ignore */
}
},
tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS },
toggleTokenColumn: (key) => {
const cols = { ...get().tokenColumnVisibility, [key]: !get().tokenColumnVisibility[key] };
const cols = {
...get().tokenColumnVisibility,
[key]: !get().tokenColumnVisibility[key],
};
set({ tokenColumnVisibility: cols });
api.updateTokenColumnSettings(cols).catch(() => {});
},
@@ -110,6 +202,8 @@ export const useUiStore = create<UiState>((set, get) => ({
try {
const c = await api.getTokenColumnSettings();
set({ tokenColumnVisibility: { ...DEFAULT_TOKEN_COLUMNS, ...c } });
} catch { /* ignore */ }
} catch {
/* ignore */
}
},
}));