Internationalize UI (EN default) + 5-language switcher

English is now the default language. Added src/i18n.ts (t() helper +
dictionary) covering all UI strings across App, Updater and ModelViewer,
plus a language selector in Settings for English, Русский, Deutsch,
Français, Español (persisted in localStorage). Backend error strings
translated to English. Also removed the purple highlight on the resize
handles (cursor-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 14:10:35 +03:00
parent fdcc9efb31
commit 46a3be4489
6 changed files with 402 additions and 156 deletions

View File

@ -6,15 +6,16 @@ import ModelViewer from "./ModelViewer";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
/* ---------------- window controls (frameless) ---------------- */
function WinControls() {
const w = getCurrentWindow();
return (
<div className="wctrls">
<button className="wbtn" onClick={() => w.minimize()} title="Свернуть"><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title="Развернуть"><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title="Закрыть"><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
<button className="wbtn" onClick={() => w.minimize()} title={t("Minimize")}><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title={t("Maximize")}><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title={t("Close")}><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
</div>
);
}
@ -49,12 +50,19 @@ function useTheme(): [boolean, () => void] {
return [light, () => setLight((l) => !l)];
}
/* ---------------- language hook (whole tree re-renders on change) ---------------- */
function useLang(): [Lang, (l: Lang) => void] {
const [lang, setLang] = useState<Lang>(LANG);
return [lang, (l) => { setLangGlobal(l); setLang(l); }];
}
/* ============================================================= */
export default function App() {
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
const [info, setInfo] = useState<P4Info | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [light, toggleTheme] = useTheme();
const [lang, setLang] = useLang();
const saved = useMemo(() => loadSession(), []);
// try to restore the last session from the on-disk p4 ticket
@ -81,7 +89,7 @@ export default function App() {
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme}
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
@ -121,26 +129,26 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="card">
<div className="clogo">{I.cube}</div>
<h2>Connect to Perforce</h2>
<div className="sub">Exbyte Depot sign in to your P4 server</div>
<h2>{t("Connect to Perforce")}</h2>
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
<div className="fld"><label>Server</label>
<div className="fld"><label>{t("Server")}</label>
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
<div className="fld"><label>User</label>
<div className="fld"><label>{t("User")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
<div className="fld"><label>Password</label>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
<div className="fld"><label>Workspace</label>
<div className="fld"><label>{t("Workspace")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
{clients.length ? (
@ -148,13 +156,13 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
{clients.map((c) => <option key={c.client} value={c.client}>{c.client}{c.Root ? `${c.Root}` : ""}</option>)}
</select>
) : (
<input placeholder="имя workspace (не путь к папке!)" value={client} onChange={(e) => setClient(e.target.value)} />
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
)}
</div></div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<button className="connect" disabled={busy || !password} onClick={connect}>
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? "Подключение…" : "Connect"}
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
</button>
</div>
</div>
@ -173,8 +181,8 @@ type ModalState =
| { kind: "settings" }
| { kind: "about" };
function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
const [tab, setTab] = useState<Tab>("changes");
const [modal, setModal] = useState<ModalState | null>(null);
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
@ -271,10 +279,10 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
let unlisten: (() => void) | undefined;
getCurrentWebview()
.onDragDropEvent(async (e) => {
const t = (e.payload as { type: string }).type;
if (t === "over") { setDragging(true); return; }
if (t === "leave") { setDragging(false); return; }
if (t === "drop") {
const dt = (e.payload as { type: string }).type;
if (dt === "over") { setDragging(true); return; }
if (dt === "leave") { setDragging(false); return; }
if (dt === "drop") {
setDragging(false);
const paths = (e.payload as { paths: string[] }).paths || [];
if (!paths.length) return;
@ -283,7 +291,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
for (const p of paths) {
try { n += (await p4.reconcile(p)).length; } catch (err) { flash(String(err), true); }
}
flash(n ? `В changelist добавлено/обновлено: ${n}` : "Нет изменений для добавления");
flash(n ? t("Added/updated in changelist: {n}", { n }) : t("No changes to add"));
await refresh();
}
})
@ -295,9 +303,9 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function revertAll() {
const list = files.map((f) => f.depotFile || "").filter(Boolean);
if (!list.length) return;
if (!(await confirm({ title: "Откатить все изменения?", body: `Будут отменены правки во всех ${list.length} файлах. Локальные изменения потеряются безвозвратно.`, confirm: "Откатить всё", danger: true }))) return;
if (!(await confirm({ title: t("Revert all changes?"), body: t("Edits in all {n} files will be undone. Local changes are lost permanently.", { n: list.length }), confirm: t("Revert all"), danger: true }))) return;
setBusy(true);
try { await p4.revert(list); flash("Все изменения откачены."); await refresh(); }
try { await p4.revert(list); flash(t("All changes reverted.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -312,7 +320,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
async function openWorkspaces() {
if (!session) { flash("Нет данных сессии для списка workspace", true); return; }
if (!session) { flash(t("No session data for the workspace list"), true); return; }
try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); }
catch (e) { flash(String(e), true); }
}
@ -325,7 +333,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
const scope = loadScope(client); // restore this workspace's saved working folder
setActivePath(scope);
await refresh(scope);
flash("Активный workspace: " + client);
flash(t("Active workspace: {c}", { c: client }));
} catch (e) { flash(String(e), true); setBusy(false); }
}
async function browseTo(path: string) {
@ -356,7 +364,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function getLatest() {
setBusy(true);
try { flash("Get Latest: " + await p4.sync(activePath)); await refresh(); }
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -367,23 +375,23 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
// reveal a depot path / workspace root in Windows Explorer
async function reveal(target: string) {
if (!target) { flash("Путь не определён", true); return; }
if (!target) { flash(t("Path not set"), true); return; }
try { await p4.openInExplorer(target); }
catch (e) { flash(String(e), true); }
}
async function copyText(text: string, what: string) {
try { await navigator.clipboard.writeText(text); flash(`${what} скопировано`); }
catch { flash("Не удалось скопировать", true); }
try { await navigator.clipboard.writeText(text); flash(t("{what} copied", { what })); }
catch { flash(t("Copy failed"), true); }
}
// roll back an already-submitted changelist (Perforce `p4 undo` → new revert change)
async function revertChange(c: Change) {
const n = c.change || "";
if (!(await confirm({ title: `Откатить changelist #${n}?`, body: `Perforce создаст НОВЫЙ changelist, отменяющий изменения из #${n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\${(c.desc || "").trim() || "без описания"}»`, confirm: `Откатить #${n}`, danger: true }))) return;
if (!(await confirm({ title: t("Revert changelist #{n}?", { n }), body: t("Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”", { n, desc: (c.desc || "").trim() || t("no description") }), confirm: t("Revert #{n}", { n }), danger: true }))) return;
setBusy(true);
try {
await p4.undoChange(n);
flash(`Changelist #${n} откачен новым changelist'ом.`);
flash(t("Changelist #{n} reverted by a new changelist.", { n }));
await loadHistory();
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
@ -401,7 +409,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
setBusy(true);
try {
const cl = await p4.commit(message, list);
flash(`Закоммичено локально · changelist ${cl}. Нажми Submit вверху, чтобы отправить на сервер.`);
flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
setDesc(""); setDescBody("");
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
@ -411,7 +419,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
async function pushAll() {
if (!pending.length) return;
const n = pending.length;
if (!(await confirm({ title: `Submit — отправить на сервер`, body: `${n} changelist(ов) уйдут в депо Perforce и станут доступны команде. Это push — отменить нельзя.`, confirm: `Отправить ${n}` }))) return;
if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
setBusy(true);
setPushing({ done: 0, total: n });
const errors: string[] = [];
@ -423,15 +431,15 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
setPushing(null);
if (errors.length) flash(errors.join(" · "), true);
else flash(`Отправлено на сервер: ${n} changelist(ов).`);
else flash(t("Sent to the server: {n} changelist(s).", { n }));
await refresh();
}
async function doRevert(file: OpenedFile) {
const dp = file.depotFile || "";
if (!(await confirm({ title: "Откатить файл?", body: `${splitPath(dp).name}\n\окальные правки этого файла будут потеряны.`, confirm: "Откатить", danger: true }))) return;
if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
setBusy(true);
try { await p4.revert([dp]); flash("Откачено: " + splitPath(dp).name); await refresh(); }
try { await p4.revert([dp]); flash(t("Reverted: {name}", { name: splitPath(dp).name })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
@ -453,11 +461,11 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
File: [{ label: "Сменить workspace…", act: openWorkspaces }, { label: "Выбрать рабочую папку…", act: () => browseTo("") }, { label: "Отключиться", act: onDisconnect }],
Connection: [{ label: "Обновить", kb: "F5", act: () => refresh() }, { label: "Выбрать рабочую папку…", act: () => browseTo("") }],
Actions: [{ label: "Найти изменения заново", kb: "Ctrl+R", act: rescan }, { label: "Get Latest", kb: "Ctrl+G", act: getLatest }, { label: "Commit (локально)", act: doCommit }, { label: "Submit на сервер", kb: "Ctrl+S", act: pushAll }, { label: "Revert All…", act: revertAll }, { label: "Обновить", kb: "F5", act: () => refresh() }],
Tools: [{ label: "Настройки…", act: () => setModal({ kind: "settings" }) }],
Help: [{ label: "О программе", act: () => setModal({ kind: "about" }) }],
File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
Tools: [{ label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
};
return (
@ -467,7 +475,7 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
{name}
{t(name)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
@ -479,85 +487,85 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<button className="theme-btn" onClick={toggleTheme} title="Тема">{light ? I.sun : I.moon}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title="Сменить workspace · ПКМ — ещё"
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: "Открыть в проводнике", act: () => reveal(String(info?.clientRoot || "")) },
{ label: "Сменить workspace…", act: openWorkspaces },
{ label: "Скопировать имя", act: () => copyText(info?.clientName || "", "Имя workspace") },
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), act: openWorkspaces },
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">Workspace</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="chev">{I.chev}</span>
</div>
<div className="tgroup">
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title="Тянуть — изменить ширину" />
<div className="tzone" onClick={() => browseTo("")} title="Выбрать рабочую папку проекта · ПКМ — ещё"
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: "Открыть в проводнике", act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: "Выбрать другую папку…", act: () => browseTo("") },
{ label: "Скопировать путь", act: () => copyText(activePath || "//…", "Путь") },
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), act: () => browseTo("") },
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
])}>
<span className="ic">{I.branch}</span>
<span className="tzmeta"><span className="lbl">Рабочая папка</span><span className="val">{activePath || "весь workspace (//…)"}</span></span>
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
<span className="chev">{I.chev}</span>
</div>
{pending.length > 0 ? (
<div className="tzone push" onClick={pushAll} title="Отправить закоммиченное на сервер (push)">
<div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="tzmeta"><span className="val">Submit на сервер</span><span className="sub">{pending.length} changelist(ов) push</span></span>
<span className="tzmeta"><span className="val">{t("Submit to server")}</span><span className="sub">{t("{n} changelist(s) → push", { n: pending.length })}</span></span>
</div>
) : (
<div className={"tzone sync" + (busy ? " busy" : "")} onClick={getLatest}>
<span className="ic">{I.sync}</span>
<span className="tzmeta"><span className="val">Get Latest</span><span className="sub">{busy ? "Работаю…" : "забрать с сервера (pull)"}</span></span>
<span className="tzmeta"><span className="val">{t("Get Latest")}</span><span className="sub">{busy ? t("Working…") : t("pull from server")}</span></span>
</div>
)}
</div>
</div>
<div className="main">
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title="Тянуть — изменить ширину" />
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
<div className="left">
<div className="tabs">
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>Changes <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>History</button>
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
</div>
{tab === "changes" ? (
<>
<div className="filter">
<div className="box">{I.search}<input placeholder={`Filter ${uncommitted.length} files…`} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
</div>
{pending.length > 0 && (
<div className="pushbar" onClick={pushAll} title="Отправить на сервер">
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
<span className="pushdot" />
<span>{pending.length} changelist(ов) закоммичено готово к <b>Submit</b></span>
<span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
<span className="pusharr"></span>
</div>
)}
{scanning && (
<div className="scanbar">
<span className="ldr" />
<span>Ищу изменения на диске{!activePath && " для всего workspace это долго — выбери рабочую папку проекта сверху"}</span>
<span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? "Снять выделение" : "Выделить все"}>{allChecked ? I.check : null}</span>
<span>{checked.size} из {view.length} выбрано</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title="Перечитать открытые файлы (p4 opened)">
{I.sync}<span>{scanning ? "Сканирую…" : "Обновить"}</span>
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
</button>
</div>
{view.length === 0 ? (
<div className="empty">
{scanning ? <><span className="ldr" />Ищу изменения на диске</> : <>
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
{busy ? "Загрузка…" : "Пока нет открытых файлов.\nМеняй ассеты в Unreal (Source Control → Perforce) — они появятся тут сами.\n\nПравил файлы мимо Perforce? Actions → «Найти изменения заново»."}
{busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
</>}
</div>
) : (
@ -565,23 +573,23 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
)}
<div className="commit">
<input className="summary" placeholder="Кратко: что за изменение…" value={desc} onChange={(e) => setDesc(e.target.value)}
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<textarea className="desc" placeholder="Описание (необязательно)…" value={descBody} onChange={(e) => setDescBody(e.target.value)}
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
{I.check}Commit changelist <b>· {checked.size} files</b>
{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
</button>
<div className="addhint">Commit = локально (revertable). Отправка на сервер <b>Submit</b> вверху справа.</div>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
</>
) : (
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: "Открыть этот changelist", act: () => openChange(c) },
{ label: `Откатить (revert) #${c.change}`, danger: true, act: () => revertChange(c) },
{ label: `Скопировать номер #${c.change}`, act: () => copyText(c.change || "", "Номер") },
{ label: "Скопировать описание", act: () => copyText((c.desc || "").trim(), "Описание") },
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
)}
</div>
@ -595,20 +603,20 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
<div className="dropzone">
<div className="dropinner">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
<span>Отпусти, чтобы добавить в Perforce</span>
<span>{t("Drop to add to Perforce")}</span>
</div>
</div>
)}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{pushing && (
<div className="modal-back">
<div className="push-modal">
<div className="push-title"><span className="ldr" />Отправка на сервер</div>
<div className="push-count">Changelist {pushing.done} из {pushing.total}</div>
<div className="push-title"><span className="ldr" />{t("Submit — send to the server")}</div>
<div className="push-count">{t("Changelist {a} of {b}", { a: pushing.done, b: pushing.total })}</div>
<div className="push-track"><div className="push-fill" style={{ width: `${Math.round((pushing.done / pushing.total) * 100)}%` }} /></div>
<div className="push-hint">Крупные ассеты заливаются дольше не закрывай окно.</div>
<div className="push-hint">{t("Large assets take longer to upload — dont close the window.")}</div>
</div>
</div>
)}
@ -619,8 +627,8 @@ function Workbench({ info, session, light, toggleTheme, onInfo, onSession, onDis
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
@ -634,9 +642,9 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
{modal.kind === "workspaces" && (<>
<div className="picker-head">{folder}<h3>Выбор workspace</h3>{X}</div>
<div className="picker-head">{folder}<h3>{t("Choose workspace")}</h3>{X}</div>
<div className="picker-list">
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>Нет доступных workspace</div>}
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>{t("No workspaces available")}</div>}
{modal.items.map((c) => (
<div key={c.client} className={"picker-item" + (c.client === modal.current ? " on" : "")} onClick={() => onPickWorkspace(c.client || "")}>
<span className="pi-ic">{folder}</span>
@ -644,7 +652,7 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
</div>
))}
</div>
<div className="picker-foot">Активный: {modal.current || "—"}</div>
<div className="picker-foot">{t("Active: {c}", { c: modal.current || "—" })}</div>
</>)}
{modal.kind === "scope" && (() => {
@ -653,7 +661,7 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
const up = parts.length > 0;
return (<>
<div className="picker-head">{depotIc}<h3>Рабочая папка</h3>{X}</div>
<div className="picker-head">{depotIc}<h3>{t("Working folder")}</h3>{X}</div>
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--border-soft)" }}>
<div className="crumb">
<span className="seg" onClick={() => onBrowse("")}>//</span>
@ -666,10 +674,10 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
{up && (
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="pi-body"><span className="pi-name up">..&nbsp; вверх</span></span>
<span className="pi-body"><span className="pi-name up">{t(".. up")}</span></span>
</div>
)}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>Вложенных папок нет</div>}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>{t("No subfolders")}</div>}
{modal.dirs.map((d) => (
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
<span className="pi-ic">{folder}</span>
@ -679,20 +687,29 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
))}
</div>
<button className="scope-choose" onClick={() => onChoose(path)}>
{path ? `Работать в: ${path}` : "Работать во всём workspace (//…)"}
{path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
</button>
</>);
})()}
{modal.kind === "settings" && (<>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>Настройки</h3>{X}</div>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
<div className="info-body">
<div className="info-row"><span className="rk">Тема</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? "Светлая ☀" : "Тёмная ☾"}</button></div>
<div className="info-row"><span className="rk">Сервер</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">Пользователь</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">Workspace</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">Версия сервера</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>Отключиться</button></div>
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</div>
</div>
<div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
</div>
</>)}
@ -702,8 +719,8 @@ function AppModal({ modal, info, light, toggleTheme, onClose, onPickWorkspace, o
<div style={{ width: 60, height: 60, margin: "6px auto 14px", borderRadius: 16, background: "linear-gradient(145deg,var(--accent-2),var(--accent-deep))", display: "grid", placeItems: "center", boxShadow: "0 0 30px rgba(124,110,246,.5)" }}>
<svg viewBox="0 0 24 24" width="30" height="30" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round" /><path d="m3 7 9 5 9-5M12 12v10" stroke="#fff" strokeWidth="1.5" /></svg>
</div>
<p style={{ marginBottom: 8 }}><b>Exbyte Depot</b> нативный клиент Perforce</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />Собственная разработка Exbyte Studios</p>
<p style={{ marginBottom: 8 }}>{t("Exbyte Depot — native Perforce client")}</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />{t("Built by Exbyte Studios")}</p>
</div>
</>)}
</div>
@ -749,7 +766,7 @@ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string
<h3>{title}</h3>
<p>{body}</p>
<div className="modal-actions">
<button className="mbtn ghost" onClick={() => onClose(false)}>Отмена</button>
<button className="mbtn ghost" onClick={() => onClose(false)}>{t("Cancel")}</button>
<button className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)} autoFocus>{confirm}</button>
</div>
</div>
@ -775,8 +792,8 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
// jump back to top whenever a different changelist loads
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>Загрузка списка файлов</span></div></div>;
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>Выберите changelist в History</span></div></div>;
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
@ -797,8 +814,8 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
<div className="diffhead">
<span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
<span className="ttl">
<span className="n">{(detail.desc || "").trim() || "(без описания)"}</span>
<span className="m">{detail.user} · {fmtTime(detail.time)} · {fileRows.length} файл(ов)</span>
<span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
<span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
</span>
</div>
<div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
@ -808,15 +825,15 @@ function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolea
/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return <div className="empty">Загрузка истории</div>;
if (!history.length) return <div className="empty">Нет засабмиченных изменений.</div>;
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
return (
<div className="files" style={{ position: "relative" }}>
{history.map((c) => (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<span className="hnum">#{c.change}</span>
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || "(без описания)"}</span>
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
</span>
</div>
@ -895,7 +912,7 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void }) {
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>Выберите файл слева</span></div></div>;
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
const dp = file.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
@ -911,7 +928,7 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
</span>
<span className="tools">
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}Revert</span>
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
</span>
</div>
@ -933,13 +950,13 @@ function DiffView({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
useEffect(() => {
let live = true; setText(null); setErr("");
p4.diff(dp).then((t) => live && setText(t)).catch((e) => live && setErr(String(e)));
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (text === null) return <div className="nofile">Загрузка diff</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>Нет текстовых отличий (или бинарный файл).</span></div>;
if (text === null) return <div className="nofile">{t("Loading diff…")}</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>{t("No text differences (or binary file).")}</span></div>;
const lines = text.split("\n");
return (
@ -974,14 +991,14 @@ function ImageViewer({ depot, name }: { depot: string; name: string }) {
return (
<div className="asset">
<div className="stage">
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>Image</span>
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>{t("Image")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: url ? <img src={url} alt={name} style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 10, boxShadow: "0 20px 50px -20px #000" }}
onLoad={(e) => setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
: <div className="viewer-msg"><span className="ldr" />Загрузка</div>}
: <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>}
</div>
<div className="assetmeta">
<Mi k="Файл" v={name} /><Mi k="Разрешение" v={dim || "—"} /><Mi k="Формат" v={(name.split(".").pop() || "").toUpperCase()} /><Mi k="Источник" v="working copy" acc />
<Mi k={t("File")} v={name} /><Mi k={t("Resolution")} v={dim || "—"} /><Mi k={t("Format")} v={(name.split(".").pop() || "").toUpperCase()} /><Mi k={t("Source")} v={t("working copy")} acc />
</div>
</div>
);
@ -1009,18 +1026,18 @@ function UassetPreview({ file }: { file: OpenedFile }) {
<div className="asset">
<div className="stage">
{thumb ? (<>
<span className="lbl2">{I.cube}Unreal · вшитая миниатюра</span>
<span className="lbl2">{I.cube}{t("Unreal · embedded thumbnail")}</span>
<img src={thumb} alt={name} style={{ maxWidth: "82%", maxHeight: "82%", borderRadius: 12, boxShadow: "0 20px 50px -20px #000" }} />
</>) : (
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!tried ? "Читаю ассет…" : "В этом ассете нет вшитой миниатюры. Интерактивный 3D работает для FBX / OBJ / GLB / GLTF / STL — экспортни модель в один из этих форматов."}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}</div>
</div>
)}
</div>
<div className="assetmeta">
<Mi k="Тип" v={file.type || "binary"} /><Mi k="Превью" v={thumb ? "миниатюра" : (tried ? "нет" : "…")} /><Mi k="Ревизия" v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
</div>
</div>
);