mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 11:44:28 +00:00
v3.0.4
This commit is contained in:
@@ -60,6 +60,13 @@ export const api = {
|
||||
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"),
|
||||
|
||||
@@ -258,3 +258,16 @@ export interface Toast {
|
||||
type: ToastType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SteamConfirmation {
|
||||
id: string;
|
||||
nonce: string;
|
||||
type: number;
|
||||
type_name: string;
|
||||
creator_id: string;
|
||||
headline: string;
|
||||
summary: string[];
|
||||
accept: string;
|
||||
cancel: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { api } from "@/api/client";
|
||||
import { useAccountStore } from "@/stores/accountStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { IconZap } from "@/components/shared/Icons";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { ConfirmationsModal } from "./ConfirmationsModal";
|
||||
|
||||
const ROW_ACTION_KEYS = [
|
||||
{ action: "validate", labelKey: "action.validate" },
|
||||
@@ -43,6 +45,7 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
|
||||
const [paramValue, setParamValue] = useState("");
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null);
|
||||
const [showConfirmations, setShowConfirmations] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -107,6 +110,26 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
|
||||
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
|
||||
|
||||
const handleGenerate2FA = async () => {
|
||||
setOpen(false);
|
||||
if (!account.shared_secret) {
|
||||
addToast("error", t("twofa.error", { error: "No shared_secret" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { code } = await api.generate2FA(account.shared_secret);
|
||||
await copyText(code);
|
||||
addToast("success", t("twofa.copied", { code }));
|
||||
} catch (e: unknown) {
|
||||
addToast("error", t("twofa.error", { error: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfirmations = () => {
|
||||
setOpen(false);
|
||||
setShowConfirmations(true);
|
||||
};
|
||||
|
||||
const handleRemoveProxy = async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
@@ -159,6 +182,21 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
);
|
||||
})}
|
||||
<hr className="border-dark-600 my-1" />
|
||||
<button
|
||||
onClick={handleGenerate2FA}
|
||||
disabled={!account.shared_secret}
|
||||
className={`action-menu-item ${!account.shared_secret ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{t("action.generate2fa")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenConfirmations}
|
||||
disabled={!account.identity_secret}
|
||||
className={`action-menu-item ${!account.identity_secret ? "opacity-40 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{t("action.confirmations")}
|
||||
</button>
|
||||
<hr className="border-dark-600 my-1" />
|
||||
<button onClick={handleRemoveProxy} className="action-menu-item">
|
||||
{t("proxy.removeProxy")}
|
||||
</button>
|
||||
@@ -214,6 +252,10 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmations && (
|
||||
<ConfirmationsModal account={account} onClose={() => setShowConfirmations(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from "react";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -9,19 +13,52 @@ 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("");
|
||||
const [emailPassword, setEmailPassword] = useState("");
|
||||
const [proxy, setProxy] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [mafile, setMafile] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSave = () => {
|
||||
const parseLoginPass = (value: string) => {
|
||||
const sep = value.includes("|") ? "|" : ":";
|
||||
const parts = value.split(sep);
|
||||
if (parts.length >= 2) {
|
||||
setLogin(parts[0]);
|
||||
setPassword(parts[1]);
|
||||
if (parts.length >= 3) setEmail(parts[2]);
|
||||
if (parts.length >= 4) setEmailPassword(parts[3]);
|
||||
} else {
|
||||
setLogin(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!login || !password) return;
|
||||
onSave({
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
proxy: proxy || undefined,
|
||||
});
|
||||
setSaving(true);
|
||||
try {
|
||||
onSave({
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
email_password: emailPassword || undefined,
|
||||
proxy: proxy || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
|
||||
if (mafile) {
|
||||
await api.uploadMafiles([mafile]);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
addToast("error", e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -29,11 +66,55 @@ export function AddModal({ onSave, onClose }: Props) {
|
||||
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
|
||||
<div className="space-y-3">
|
||||
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input
|
||||
autoFocus
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData("text").trim();
|
||||
if (text.includes(":") || text.includes("|")) {
|
||||
e.preventDefault();
|
||||
parseLoginPass(text);
|
||||
}
|
||||
}}
|
||||
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={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
|
||||
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
|
||||
<div 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" />
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="border border-dashed border-dark-500 rounded p-3 text-center cursor-pointer transition-colors hover:border-accent flex items-center justify-center gap-2"
|
||||
>
|
||||
<IconUpload size={14} className="text-gray-500" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{mafile ? mafile.name : t("import.attachMafile")}
|
||||
</span>
|
||||
{mafile && (
|
||||
<button
|
||||
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);
|
||||
}} />
|
||||
|
||||
<button onClick={handleSave} disabled={!login || !password || saving} className="btn-primary w-full">
|
||||
{saving ? t("import.uploading") : t("btn.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/api/client";
|
||||
import type { Account, SteamConfirmation } from "@/api/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { IconRefresh, IconCheck, IconX } from "@/components/shared/Icons";
|
||||
|
||||
const TYPE_ICONS: Record<number, string> = {
|
||||
0: "❓",
|
||||
1: "🧪",
|
||||
2: "🔄",
|
||||
3: "🏪",
|
||||
4: "⚙️",
|
||||
5: "📱",
|
||||
6: "🔑",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
account: Account;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmationsModal({ account, onClose }: Props) {
|
||||
const t = useT();
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const [confirmations, setConfirmations] = useState<SteamConfirmation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [responding, setResponding] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchConfs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getConfirmations(account.id);
|
||||
setConfirmations(data.confirmations);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [account.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfs();
|
||||
}, [fetchConfs]);
|
||||
|
||||
const handleRespond = async (ids: string[], nonces: string[], accept: boolean) => {
|
||||
const newSet = new Set(responding);
|
||||
ids.forEach((id) => newSet.add(id));
|
||||
setResponding(newSet);
|
||||
|
||||
try {
|
||||
const { success } = await api.respondConfirmations(account.id, ids, nonces, accept);
|
||||
if (success) {
|
||||
const action = accept ? t("confirm.accepted") : t("confirm.denied");
|
||||
addToast("success", `${action} (${ids.length})`);
|
||||
setConfirmations((prev) => prev.filter((c) => !ids.includes(c.id)));
|
||||
} else {
|
||||
addToast("error", t("confirm.respondFailed"));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
addToast("error", e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setResponding((prev) => {
|
||||
const s = new Set(prev);
|
||||
ids.forEach((id) => s.delete(id));
|
||||
return s;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
if (confirmations.length === 0) return;
|
||||
handleRespond(
|
||||
confirmations.map((c) => c.id),
|
||||
confirmations.map((c) => c.nonce),
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDenyAll = () => {
|
||||
if (confirmations.length === 0) return;
|
||||
handleRespond(
|
||||
confirmations.map((c) => c.id),
|
||||
confirmations.map((c) => c.nonce),
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-overlay" onMouseDown={onClose}>
|
||||
<div
|
||||
className="bg-dark-800 border border-dark-600 rounded-lg shadow-xl p-5 w-[560px] max-h-[80vh] flex flex-col"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-200">
|
||||
{t("confirm.title")} — {account.login}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||
<IconX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">
|
||||
<IconRefresh className="animate-spin mr-2 w-4 h-4" />
|
||||
{t("confirm.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-700 rounded p-3 text-sm text-red-300 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && confirmations.length === 0 && (
|
||||
<p className="text-gray-400 text-sm text-center py-8">{t("confirm.empty")}</p>
|
||||
)}
|
||||
|
||||
{!loading && confirmations.length > 0 && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button onClick={handleAcceptAll} className="btn-primary text-xs flex items-center gap-1">
|
||||
<IconCheck className="w-3 h-3" />
|
||||
{t("confirm.acceptAll")} ({confirmations.length})
|
||||
</button>
|
||||
<button onClick={handleDenyAll} className="btn-secondary text-xs flex items-center gap-1">
|
||||
<IconX className="w-3 h-3" />
|
||||
{t("confirm.denyAll")}
|
||||
</button>
|
||||
<button onClick={fetchConfs} className="btn-secondary text-xs flex items-center gap-1 ml-auto">
|
||||
<IconRefresh className="w-3 h-3" />
|
||||
{t("confirm.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto space-y-2 flex-1 min-h-0">
|
||||
{confirmations.map((conf) => {
|
||||
const busy = responding.has(conf.id);
|
||||
return (
|
||||
<div
|
||||
key={conf.id}
|
||||
className={`bg-dark-700 border border-dark-600 rounded p-3 flex items-start gap-3 ${busy ? "opacity-50" : ""}`}
|
||||
>
|
||||
{conf.icon ? (
|
||||
<img src={conf.icon} alt="" className="w-8 h-8 rounded flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<span className="text-xl flex-shrink-0 mt-0.5">{TYPE_ICONS[conf.type] ?? "❓"}</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-dark-600 text-gray-400">
|
||||
{conf.type_name}
|
||||
</span>
|
||||
<span className="text-sm text-gray-200 truncate">{conf.headline}</span>
|
||||
</div>
|
||||
{conf.summary.length > 0 && (
|
||||
<p className="text-xs text-gray-400 truncate">{conf.summary.join(" • ")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => handleRespond([conf.id], [conf.nonce], true)}
|
||||
className="p-1.5 rounded bg-green-700/60 hover:bg-green-600/80 text-green-200 transition-colors"
|
||||
title={conf.accept}
|
||||
>
|
||||
<IconCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => handleRespond([conf.id], [conf.nonce], false)}
|
||||
className="p-1.5 rounded bg-red-700/60 hover:bg-red-600/80 text-red-200 transition-colors"
|
||||
title={conf.cancel}
|
||||
>
|
||||
<IconX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,9 @@ export function ImportModal({ onClose }: Props) {
|
||||
<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>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
|
||||
@@ -18,6 +18,12 @@ const NAME_VARS = [
|
||||
{ value: "{steamid}", label: "steamid" },
|
||||
];
|
||||
|
||||
const FORMAT_OPTIONS: { value: MafileExportRequest["format"]; icon: string }[] = [
|
||||
{ value: "flat_mafiles", icon: "📄" },
|
||||
{ value: "per_account_folder", icon: "📁" },
|
||||
{ value: "single_file", icon: "📦" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
accountIds: number[];
|
||||
onExport: (req: MafileExportRequest) => void;
|
||||
@@ -29,12 +35,10 @@ export function ExportSettings({ accountIds, onExport }: Props) {
|
||||
const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS));
|
||||
const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder");
|
||||
|
||||
// Naming templates
|
||||
const [folderNameTemplate, setFolderNameTemplate] = useState("{username}");
|
||||
const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile");
|
||||
const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt");
|
||||
|
||||
// .txt options
|
||||
const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false);
|
||||
const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false);
|
||||
const [skipFolders, setSkipFolders] = useState(false);
|
||||
@@ -68,203 +72,222 @@ export function ExportSettings({ accountIds, onExport }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4 space-y-4">
|
||||
<h4 className="font-semibold text-sm"><IconPackage size={14} className="inline mr-1" />{t("mafile.exportSettings")}</h4>
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-1">{t("mafile.exportFormat")}</p>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value as MafileExportRequest["format"];
|
||||
setFormat(val);
|
||||
if (val === "single_file") {
|
||||
setIncludeGlobalTxt(false);
|
||||
setSkipFolders(false);
|
||||
}
|
||||
}}
|
||||
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="flat_mafiles">{t("mafile.flatFiles")}</option>
|
||||
<option value="per_account_folder">{t("mafile.perAccountFolder")}</option>
|
||||
<option value="single_file">{t("mafile.singleFile")}</option>
|
||||
</select>
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
|
||||
<IconPackage size={14} className="text-accent" />
|
||||
<h4 className="font-semibold text-sm">{t("mafile.exportSettings")}</h4>
|
||||
</div>
|
||||
|
||||
{/* Naming templates */}
|
||||
{format !== "single_file" && (
|
||||
<div className="space-y-3 border border-dark-600 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400 font-medium">
|
||||
{t("mafile.variables")} <code className="text-accent">{"{username}"}</code>, <code className="text-accent">{"{steamid}"}</code>
|
||||
</p>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.folderName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={folderNameTemplate}
|
||||
onChange={(e) => setFolderNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setFolderNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
title={t("mafile.insert", { value: v.value })}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.mafileName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={mafileNameTemplate}
|
||||
onChange={(e) => setMafileNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setMafileNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
title={t("mafile.insert", { value: v.value })}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Format selector — card-style buttons */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">{t("mafile.exportFormat")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setFormat(opt.value);
|
||||
if (opt.value === "single_file") {
|
||||
setIncludeGlobalTxt(false);
|
||||
setSkipFolders(false);
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border text-xs transition-all cursor-pointer ${
|
||||
format === opt.value
|
||||
? "border-accent bg-accent/10 text-white"
|
||||
: "border-dark-600 bg-dark-700 text-gray-400 hover:border-dark-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{opt.icon}</span>
|
||||
<span>{t(`mafile.${opt.value}` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* .txt options */}
|
||||
<div className="space-y-2 border border-dark-600 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-400 font-medium"><IconFileText size={12} className="inline mr-1" />{t("mafile.txtSettings")}</p>
|
||||
|
||||
{/* Naming templates */}
|
||||
{format !== "single_file" && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeGlobalTxt}
|
||||
onChange={(e) => {
|
||||
setIncludeGlobalTxt(e.target.checked);
|
||||
if (!e.target.checked) setSkipFolders(false);
|
||||
}}
|
||||
/>
|
||||
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code> {t("mafile.withAllAccounts")}
|
||||
</label>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeTxtPerFolder}
|
||||
onChange={(e) => setIncludeTxtPerFolder(e.target.checked)}
|
||||
<details className="group" open>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.namingSection")}
|
||||
<span className="text-gray-500 ml-1">
|
||||
<code className="text-accent/70">{"{username}"}</code> <code className="text-accent/70">{"{steamid}"}</code>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3 pl-4 border-l border-dark-600">
|
||||
{format === "per_account_folder" && (
|
||||
<TemplateInput
|
||||
label={t("mafile.folderName")}
|
||||
value={folderNameTemplate}
|
||||
onChange={setFolderNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{t("mafile.addTxtPerFolder")}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${includeGlobalTxt ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipFolders}
|
||||
disabled={!includeGlobalTxt}
|
||||
onChange={(e) => setSkipFolders(e.target.checked)}
|
||||
)}
|
||||
<TemplateInput
|
||||
label={t("mafile.mafileName")}
|
||||
value={mafileNameTemplate}
|
||||
onChange={setMafileNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{t("mafile.skipFolders")}
|
||||
</label>
|
||||
</>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
|
||||
<>
|
||||
{format === "per_account_folder" && includeTxtPerFolder && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtFolderName")}</label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
{/* .txt options */}
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
<IconFileText size={12} className="inline" />
|
||||
{t("mafile.txtSettings")}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2.5 pl-4 border-l border-dark-600">
|
||||
{format !== "single_file" && (
|
||||
<>
|
||||
<ToggleOption checked={includeGlobalTxt} onChange={(v) => { setIncludeGlobalTxt(v); if (!v) setSkipFolders(false); }}>
|
||||
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code>
|
||||
</ToggleOption>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<ToggleOption checked={includeTxtPerFolder} onChange={setIncludeTxtPerFolder}>
|
||||
{t("mafile.addTxtPerFolder")}
|
||||
</ToggleOption>
|
||||
)}
|
||||
|
||||
<ToggleOption checked={skipFolders} onChange={setSkipFolders} disabled={!includeGlobalTxt}>
|
||||
{t("mafile.skipFolders")}
|
||||
</ToggleOption>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{format === "per_account_folder" && includeTxtPerFolder && (
|
||||
<TemplateInput
|
||||
label={t("mafile.txtFolderName")}
|
||||
value={txtNameTemplate}
|
||||
onChange={(e) => setTxtNameTemplate(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
onChange={setTxtNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{NAME_VARS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar(setTxtNameTemplate, v.value)}
|
||||
className="btn-secondary px-2 py-1 text-xs"
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtLineFormat")}</label>
|
||||
<input
|
||||
value={txtFormat}
|
||||
onChange={(e) => setTxtFormat(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 mt-1.5 leading-relaxed">
|
||||
{t("mafile.variables")} <code className="text-accent/70">{"{login}"}</code> <code className="text-accent/70">{"{password}"}</code> <code className="text-accent/70">{"{email}"}</code> <code className="text-accent/70">{"{email_password}"}</code> <code className="text-accent/70">{"{steam_id}"}</code> <code className="text-accent/70">{"{proxy}"}</code> <code className="text-accent/70">{"{mafile}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
{t("mafile.txtLineFormat")}
|
||||
{/* Field selection */}
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.mafileFields")}
|
||||
<span className="text-gray-500 text-[11px]">{fields.size}/{MAFILE_FIELDS.length}</span>
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
|
||||
{MAFILE_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
|
||||
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} className="accent-accent" />
|
||||
<span className="font-mono text-[11px]">{f}</span>
|
||||
</label>
|
||||
<input
|
||||
value={txtFormat}
|
||||
onChange={(e) => setTxtFormat(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t("mafile.variables")} <code className="text-accent">{"{login}"}</code> <code className="text-accent">{"{password}"}</code> <code className="text-accent">{"{email}"}</code> <code className="text-accent">{"{email_password}"}</code> <code className="text-accent">{"{steam_id}"}</code> <code className="text-accent">{"{proxy}"}</code> <code className="text-accent">{"{mafile}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.sessionFields")}
|
||||
<span className="text-gray-500 text-[11px]">{sessionFields.size}/{SESSION_FIELDS.length}</span>
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
|
||||
{SESSION_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
|
||||
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} className="accent-accent" />
|
||||
<span className="font-mono text-[11px]">{f}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Export button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={accountIds.length === 0}
|
||||
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all ${
|
||||
accountIds.length === 0
|
||||
? "bg-dark-700 text-gray-500 cursor-not-allowed"
|
||||
: "bg-accent hover:bg-accent-hover text-white cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Field selection */}
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.mafileFields")} ({fields.size}/{MAFILE_FIELDS.length})
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{MAFILE_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} />
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className="group">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
|
||||
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
|
||||
{t("mafile.sessionFields")} ({sessionFields.size}/{SESSION_FIELDS.length})
|
||||
</summary>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{SESSION_FIELDS.map((f) => (
|
||||
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} />
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={accountIds.length === 0}
|
||||
className={accountIds.length === 0 ? "btn-primary opacity-50 cursor-not-allowed" : "btn-primary"}
|
||||
>
|
||||
<IconDownload size={14} className="inline mr-1" />{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateInput({ label, value, onChange, vars, insertVar }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
vars: { value: string; label: string }[];
|
||||
insertVar: (setter: (fn: (prev: string) => string) => void, varName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
{vars.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => insertVar((fn) => onChange(fn(value)), v.value)}
|
||||
className="px-2 py-1 text-[11px] rounded bg-dark-700 border border-dark-600 text-gray-400 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleOption({ checked, onChange, disabled, children }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={`flex items-center gap-2.5 text-sm cursor-pointer select-none ${disabled ? "opacity-40 cursor-not-allowed" : "hover:text-gray-200"} transition-colors`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-xs">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MafileExportRequest } from "@/api/types";
|
||||
import { ExportSettings } from "./ExportSettings";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
import { IconClipboard } from "@/components/shared/Icons";
|
||||
import { IconClipboard, IconPackage } from "@/components/shared/Icons";
|
||||
|
||||
export function MafileTab() {
|
||||
const t = useT();
|
||||
@@ -13,7 +13,6 @@ export function MafileTab() {
|
||||
const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds);
|
||||
const clearMafileManager = useAccountStore((s) => s.clearMafileManager);
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
// Bug #3: only show explicitly sent accounts
|
||||
const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id));
|
||||
|
||||
const handleExport = async (req: MafileExportRequest) => {
|
||||
@@ -31,37 +30,65 @@ export function MafileTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const withMafile = sentAccounts.filter((a) => a.mafile_path).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 h-full overflow-y-auto pr-1">
|
||||
{/* Sent accounts list — Bug #3: only sent accounts shown */}
|
||||
{sentAccounts.length > 0 && (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold">
|
||||
<IconClipboard size={14} className="inline mr-1" />{t("mafile.sentAccounts")} ({sentAccounts.length})
|
||||
</h3>
|
||||
<button onClick={clearMafileManager} className="btn-danger-outline text-xs">{t("mafile.clearBtn")}</button>
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
<div className="max-w-2xl mx-auto space-y-5 py-2">
|
||||
|
||||
{/* Empty state */}
|
||||
{sentAccounts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-dark-700 flex items-center justify-center mb-4">
|
||||
<IconPackage size={24} className="text-gray-500" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-1">{t("mafile.emptyTitle")}</p>
|
||||
<p className="text-gray-500 text-xs max-w-xs">{t("mafile.emptyHint")}</p>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{sentAccounts.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-xs text-gray-300 min-w-0">
|
||||
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />}
|
||||
<span className="truncate">{a.login}</span>
|
||||
<span className="text-gray-500 truncate">{a.steam_id || ""}</span>
|
||||
<span className={a.mafile_path ? "text-green-400" : "text-gray-500"}>
|
||||
{a.mafile_path ? "✓ mafile" : "—"}
|
||||
)}
|
||||
|
||||
{/* Account list */}
|
||||
{sentAccounts.length > 0 && (
|
||||
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClipboard size={14} className="text-accent" />
|
||||
<h3 className="font-semibold text-sm">{t("mafile.sentAccounts")}</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{sentAccounts.length} {t("mafile.accountsCount")} · {withMafile} {t("mafile.withMafile")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={clearMafileManager} className="text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer">
|
||||
{t("mafile.clearBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto divide-y divide-dark-700">
|
||||
{sentAccounts.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 px-4 py-2 hover:bg-dark-700/50 transition-colors">
|
||||
{a.avatar_url ? (
|
||||
<img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded bg-dark-600 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm truncate flex-1 min-w-0">{a.login}</span>
|
||||
{a.steam_id && <span className="text-xs text-gray-500 font-mono hidden sm:block">{a.steam_id}</span>}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${a.mafile_path ? "bg-green-500/10 text-green-400" : "bg-dark-600 text-gray-500"}`}>
|
||||
{a.mafile_path ? "mafile" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Export settings — Bug #6: flexible export */}
|
||||
<ExportSettings
|
||||
accountIds={[...mafileManagerIds]}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
{/* Export settings */}
|
||||
{sentAccounts.length > 0 && (
|
||||
<ExportSettings
|
||||
accountIds={[...mafileManagerIds]}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Вкл. автопринятие",
|
||||
"action.disableAutoAccept": "Выкл. авто-принятие входа",
|
||||
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
|
||||
"action.generate2fa": "Сгенерировать 2FA код",
|
||||
"action.confirmations": "Подтверждения",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Введите новый пароль",
|
||||
"prompt.newEmail": "Введите новый email",
|
||||
@@ -99,6 +101,15 @@ const translations: Translations = {
|
||||
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
|
||||
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
|
||||
"confirm.yes": "Да",
|
||||
"confirm.title": "Подтверждения",
|
||||
"confirm.loading": "Загрузка подтверждений...",
|
||||
"confirm.empty": "Нет ожидающих подтверждений",
|
||||
"confirm.acceptAll": "Принять все",
|
||||
"confirm.denyAll": "Отклонить все",
|
||||
"confirm.refresh": "Обновить",
|
||||
"confirm.accepted": "Принято",
|
||||
"confirm.denied": "Отклонено",
|
||||
"confirm.respondFailed": "Не удалось обработать подтверждение",
|
||||
// -- Buttons --
|
||||
"btn.import": "Импорт",
|
||||
"btn.add": "Добавить",
|
||||
@@ -196,6 +207,7 @@ const translations: Translations = {
|
||||
"ph.notes": "Заметки",
|
||||
"ph.notesOptional": "Заметки (необязательно)",
|
||||
"ph.loginOptional": "Логин (необязательно)",
|
||||
"ph.emailPassword": "Пароль от email",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "Нет аккаунтов. Импортируйте файл",
|
||||
"empty.logpass": "Аккаунтов нет. Импортируйте файл.",
|
||||
@@ -216,6 +228,7 @@ const translations: Translations = {
|
||||
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
|
||||
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
|
||||
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
|
||||
"import.attachMafile": "Прикрепить .mafile (необязательно)",
|
||||
"import.selected": "Выбран: {name}",
|
||||
"import.uploading": "Загрузка...",
|
||||
"import.importBtn": "Импортировать",
|
||||
@@ -279,6 +292,14 @@ const translations: Translations = {
|
||||
"mafile.sentAccounts": "Отправленные аккаунты",
|
||||
"mafile.clearBtn": "Очистить",
|
||||
"mafile.insert": "Вставить {value}",
|
||||
"mafile.emptyTitle": "Нет выбранных аккаунтов",
|
||||
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
|
||||
"mafile.accountsCount": "акк.",
|
||||
"mafile.withMafile": "с mafile",
|
||||
"mafile.namingSection": "Шаблоны имён",
|
||||
"mafile.flat_mafiles": "Файлы",
|
||||
"mafile.per_account_folder": "Папки",
|
||||
"mafile.single_file": "Один файл",
|
||||
// -- Settings drawer --
|
||||
"settings.display": "Отображение",
|
||||
"settings.hidePasswords": "Скрывать пароли (спойлер)",
|
||||
@@ -350,6 +371,8 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Enable auto-accept",
|
||||
"action.disableAutoAccept": "Disable login auto-accept",
|
||||
"action.enableAutoAcceptLogin": "Enable login auto-accept",
|
||||
"action.generate2fa": "Generate 2FA code",
|
||||
"action.confirmations": "Confirmations",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Enter new password",
|
||||
"prompt.newEmail": "Enter new email",
|
||||
@@ -373,6 +396,15 @@ const translations: Translations = {
|
||||
"confirm.clearProxies": "Clear proxies from all accounts?",
|
||||
"confirm.executeBulk": "Execute «{action}» for {count} accounts?",
|
||||
"confirm.yes": "Yes",
|
||||
"confirm.title": "Confirmations",
|
||||
"confirm.loading": "Loading confirmations...",
|
||||
"confirm.empty": "No pending confirmations",
|
||||
"confirm.acceptAll": "Accept all",
|
||||
"confirm.denyAll": "Deny all",
|
||||
"confirm.refresh": "Refresh",
|
||||
"confirm.accepted": "Accepted",
|
||||
"confirm.denied": "Denied",
|
||||
"confirm.respondFailed": "Failed to process confirmation",
|
||||
// -- Buttons --
|
||||
"btn.import": "Import",
|
||||
"btn.add": "Add",
|
||||
@@ -470,6 +502,7 @@ const translations: Translations = {
|
||||
"ph.notes": "Notes",
|
||||
"ph.notesOptional": "Notes (optional)",
|
||||
"ph.loginOptional": "Login (optional)",
|
||||
"ph.emailPassword": "Email password",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "No accounts. Import a file",
|
||||
"empty.logpass": "No accounts. Import a file.",
|
||||
@@ -490,6 +523,7 @@ const translations: Translations = {
|
||||
"import.dragTxt": "Drag a .txt file or click to select",
|
||||
"import.dragTxtCsv": "Drag a .txt / .csv file or click to select",
|
||||
"import.dragMafile": "Drag .mafile files or click to select",
|
||||
"import.attachMafile": "Attach .mafile (optional)",
|
||||
"import.selected": "Selected: {name}",
|
||||
"import.uploading": "Uploading...",
|
||||
"import.importBtn": "Import",
|
||||
@@ -553,6 +587,14 @@ const translations: Translations = {
|
||||
"mafile.sentAccounts": "Sent Accounts",
|
||||
"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.accountsCount": "accs",
|
||||
"mafile.withMafile": "with mafile",
|
||||
"mafile.namingSection": "Naming templates",
|
||||
"mafile.flat_mafiles": "Files",
|
||||
"mafile.per_account_folder": "Folders",
|
||||
"mafile.single_file": "Single file",
|
||||
// -- Settings drawer --
|
||||
"settings.display": "Display",
|
||||
"settings.hidePasswords": "Hide passwords (spoiler)",
|
||||
|
||||
Reference in New Issue
Block a user