improve settings navigation and legal disclosure (#1105)

This commit is contained in:
ZacharyZcR
2026-07-28 02:20:56 +08:00
committed by GitHub
parent 44a4534baa
commit a9c2aec165
12 changed files with 401 additions and 27 deletions
+82 -19
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Separator } from "@/components/separator";
import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet";
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from "lucide-react";
import {
useState,
useRef,
@@ -125,6 +125,7 @@ import {
createSSHHost,
getActiveSessions,
getUserPreferences,
saveUserPreferences,
dismissDonationModal,
isElectron,
type UserPreferences,
@@ -139,7 +140,7 @@ import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { changeAppLanguage } from "@/i18n/i18n";
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
function buildHostTree(
@@ -263,6 +264,7 @@ export function AppShell({
});
const [sidebarDragging, setSidebarDragging] = useState(false);
const [sidebarEditing, setSidebarEditing] = useState(false);
const [settingsFullscreen, setSettingsFullscreen] = useState(false);
const [isAppFullscreen, setIsAppFullscreen] = useState(
() => !!document.fullscreenElement,
);
@@ -287,6 +289,21 @@ export function AppShell({
}, [paneTabIds, tabs]);
const isMobile = useIsMobile();
const isSettingsView =
railView === "user-profile" || railView === "admin-settings";
useEffect(() => {
if (!settingsFullscreen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsFullscreen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [settingsFullscreen]);
useEffect(() => {
if (!isSettingsView) setSettingsFullscreen(false);
}, [isSettingsView]);
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
useEffect(() => {
@@ -691,6 +708,7 @@ export function AppShell({
useEffect(() => {
getUserPreferences()
.then((prefs) => {
const loginLanguage = consumeLoginLanguage();
setUserPrefs(prefs);
if (prefs.storageMode === "cloud") {
// Persist the current browser values before overwriting, so any tab can restore them
@@ -724,8 +742,12 @@ export function AppShell({
localStorage.setItem("termix-accent", prefs.accentColor);
applyAccentColor(prefs.accentColor);
}
if (prefs.language && prefs.language !== i18n.language) {
void changeAppLanguage(prefs.language);
const preferredLanguage = loginLanguage ?? prefs.language;
if (preferredLanguage && preferredLanguage !== i18n.language) {
void changeAppLanguage(preferredLanguage);
}
if (loginLanguage && loginLanguage !== prefs.language) {
void saveUserPreferences({ language: loginLanguage });
}
if (
prefs.commandAutocomplete !== null &&
@@ -1510,6 +1532,7 @@ export function AppShell({
setSidebarOpen(false);
} else {
if (view !== railView) setSidebarEditing(false);
if (view !== railView) setSettingsFullscreen(false);
setRailView(view);
setSidebarOpen(true);
}
@@ -1867,12 +1890,42 @@ export function AppShell({
</Button>
</>
)}
{isSettingsView && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={
settingsFullscreen
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
: t("newUi.sidebar.userProfile.openFullscreenSettings")
}
aria-label={
settingsFullscreen
? t("newUi.sidebar.userProfile.exitFullscreenSettings")
: t("newUi.sidebar.userProfile.openFullscreenSettings")
}
onClick={() => setSettingsFullscreen((value) => !value)}
>
{settingsFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</Button>
</>
)}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => setSidebarOpen(false)}
onClick={() => {
setSettingsFullscreen(false);
setSidebarOpen(false);
}}
>
<ChevronLeft className="size-4" />
</Button>
@@ -1904,30 +1957,38 @@ export function AppShell({
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */}
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
username={username}
isAdmin={showMultiUserUI}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onLogout={onLogout}
/>
{!settingsFullscreen && (
<AppRail
railView={railView}
sidebarOpen={sidebarOpen}
splitMode={splitMode}
username={username}
isAdmin={showMultiUserUI}
onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onLogout={onLogout}
/>
)}
{/* Desktop: inline resizable sidebar */}
{!isMobile && (
<div
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
className={`${settingsFullscreen ? "fixed inset-0 z-50" : "relative"} flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
style={{
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
width: settingsFullscreen
? "100vw"
: sidebarOpen
? sidebarEditing
? 560
: sidebarWidth
: 0,
transition: sidebarDragging ? "none" : "width 0.2s",
}}
>
{sidebarHeader}
{sidebarPanelContent}
{sidebarOpen && !sidebarEditing && (
{sidebarOpen && !sidebarEditing && !settingsFullscreen && (
<div
onMouseDown={onSidebarMouseDown}
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
@@ -1942,7 +2003,7 @@ export function AppShell({
<SheetContent
side="left"
showCloseButton={false}
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${settingsFullscreen ? "w-screen" : "w-[min(85vw,360px)]"}`}
style={{ height: "100dvh" }}
>
{sidebarHeader}
@@ -1953,6 +2014,8 @@ export function AppShell({
{/* Main content area */}
<div
inert={settingsFullscreen ? true : undefined}
aria-hidden={settingsFullscreen || undefined}
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
>
{!isMobile && !sidebarOpen && (
+20 -2
View File
@@ -37,11 +37,16 @@ import {
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
import { Checkbox } from "@/components/checkbox";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import {
changeAppLanguage,
normalizeLanguageCode,
rememberLoginLanguage,
} from "@/i18n/i18n";
import {
removeSilentSigninFromSearch,
shouldTriggerSilentSignin,
} from "./silent-signin";
import { LegalDisclosureDialog } from "@/legal/LegalDisclosure";
const LANGUAGES = [
{ code: "en", label: "English" },
@@ -237,9 +242,11 @@ export function Auth({ onLogin }: AuthProps) {
const [language, setLanguage] = useState(() =>
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
);
const [legalOpen, setLegalOpen] = useState(false);
function handleLanguageChange(code: string) {
void changeAppLanguage(code)
const language = rememberLoginLanguage(code);
void changeAppLanguage(language)
.then((language) => setLanguage(language))
.catch(() => {});
}
@@ -1583,6 +1590,17 @@ export function Auth({ onLogin }: AuthProps) {
))}
</select>
</div>
<button
type="button"
onClick={() => setLegalOpen(true)}
className="self-center text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{t("newUi.sidebar.userProfile.sectionLegal")}
</button>
<LegalDisclosureDialog
open={legalOpen}
onOpenChange={setLegalOpen}
/>
</div>
)}
</div>
+13
View File
@@ -44,6 +44,7 @@ const localeLoaders = {
} satisfies Record<string, () => Promise<LocaleModule>>;
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
export function normalizeLanguageCode(language?: string | null): string {
if (!language) return "en";
@@ -123,4 +124,16 @@ export async function changeAppLanguage(language: string): Promise<string> {
return normalizedLanguage;
}
export function rememberLoginLanguage(language: string): string {
const normalizedLanguage = normalizeLanguageCode(language);
sessionStorage.setItem(PENDING_LOGIN_LANGUAGE_KEY, normalizedLanguage);
return normalizedLanguage;
}
export function consumeLoginLanguage(): string | null {
const language = sessionStorage.getItem(PENDING_LOGIN_LANGUAGE_KEY);
sessionStorage.removeItem(PENDING_LOGIN_LANGUAGE_KEY);
return language ? normalizeLanguageCode(language) : null;
}
export default i18n;
+96
View File
@@ -0,0 +1,96 @@
import { useTranslation } from "react-i18next";
import { Database, FileKey, Network, ShieldCheck } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
const accessItems = [
{ key: "connections", icon: Network },
{ key: "credentials", icon: FileKey },
{ key: "storage", icon: Database },
{ key: "services", icon: ShieldCheck },
] as const;
export function LegalDisclosure() {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-5 pt-3 text-xs">
<article aria-labelledby="terms-heading" className="flex flex-col gap-2">
<h3 id="terms-heading" className="font-bold text-foreground">
{t("newUi.sidebar.userProfile.termsTitle")}
</h3>
<p className="leading-relaxed text-muted-foreground">
{t("newUi.sidebar.userProfile.termsIntro")}
</p>
<ul className="list-disc space-y-1.5 pl-4 leading-relaxed text-muted-foreground">
<li>{t("newUi.sidebar.userProfile.termsAuthorization")}</li>
<li>{t("newUi.sidebar.userProfile.termsSecurity")}</li>
<li>{t("newUi.sidebar.userProfile.termsAcceptableUse")}</li>
<li>{t("newUi.sidebar.userProfile.termsAvailability")}</li>
</ul>
</article>
<article
aria-labelledby="access-heading"
className="flex flex-col gap-2 border-t border-border pt-4"
>
<h3 id="access-heading" className="font-bold text-foreground">
{t("newUi.sidebar.userProfile.accessDisclosureTitle")}
</h3>
<p className="leading-relaxed text-muted-foreground">
{t("newUi.sidebar.userProfile.accessDisclosureIntro")}
</p>
<div className="grid gap-2 md:grid-cols-2">
{accessItems.map(({ key, icon: Icon }) => (
<section
key={key}
className="border border-border bg-background p-3"
>
<div className="mb-1.5 flex items-center gap-2 font-semibold text-foreground">
<Icon className="size-3.5 text-accent-brand" />
{t(`newUi.sidebar.userProfile.access.${key}.title`)}
</div>
<p className="leading-relaxed text-muted-foreground">
{t(`newUi.sidebar.userProfile.access.${key}.description`)}
</p>
</section>
))}
</div>
<p className="leading-relaxed text-muted-foreground">
{t("newUi.sidebar.userProfile.telemetryDisclosure")}
</p>
</article>
</div>
);
}
export function LegalDisclosureDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.userProfile.sectionLegal")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.userProfile.legalDescription")}
</DialogDescription>
</DialogHeader>
<LegalDisclosure />
</DialogContent>
</Dialog>
);
}
+33 -1
View File
@@ -3330,6 +3330,8 @@
"sectionApiKeys": "API Keys",
"sectionData": "Data",
"sectionC2sTunnels": "C2S Tunnels",
"sectionLegal": "Terms and Access",
"legalDescription": "Review the conditions for using Termix and the resources its features can access.",
"usernameLabel": "Username",
"roleLabel": "Role",
"roleAdministrator": "Administrator",
@@ -3381,6 +3383,9 @@
"statusColorsDesc": "Use green/red for online/offline status instead of the accent color",
"pinAppRail": "Pin App Rail",
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
"unpinAppRail": "Unpin App Rail",
"openFullscreenSettings": "Open settings full screen",
"exitFullscreenSettings": "Exit full-screen settings",
"expandAppRailOnHover": "Expand App Rail on Hover",
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
"settingsNavigation": "Navigation",
@@ -3498,7 +3503,34 @@
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
"themeGruvbox": "Gruvbox",
"termsTitle": "Terms of Use",
"termsIntro": "By using Termix, you agree to use it only with systems, accounts, and data you are authorized to access.",
"termsAuthorization": "You are responsible for obtaining permission before connecting to a host or sharing a session.",
"termsSecurity": "You are responsible for protecting credentials, backups, API keys, and access to this Termix instance.",
"termsAcceptableUse": "Do not use Termix to disrupt services, bypass access controls, or perform unlawful activity.",
"termsAvailability": "Termix is provided without a service guarantee. Review backups and test recovery before relying on it for critical operations.",
"accessDisclosureTitle": "Access Disclosure",
"accessDisclosureIntro": "Termix accesses only the resources required by features you configure or actions you start.",
"access": {
"connections": {
"title": "Configured systems",
"description": "Connects to hosts and services you add, including SSH, Telnet, RDP, VNC, Docker, Proxmox, tunnels, and monitoring endpoints."
},
"credentials": {
"title": "Credentials and sessions",
"description": "Uses saved credentials, keys, tokens, and session data to authenticate and maintain the connections you request."
},
"storage": {
"title": "Files and local data",
"description": "Reads or writes remote files, clipboard data, imports, exports, and local preferences only when the related feature is used."
},
"services": {
"title": "Optional integrations",
"description": "Contacts SSO, ACME, Tailscale, webhook, update, and remote-sync services only when they are enabled or configured."
}
},
"telemetryDisclosure": "Optional analytics can be disabled in Admin Settings. Termix does not intentionally include credentials, terminal input, or file contents in analytics events."
}
}
},
+33 -1
View File
@@ -3328,6 +3328,8 @@
"sectionApiKeys": "API 密钥",
"sectionData": "数据",
"sectionC2sTunnels": "C2S 隧道",
"sectionLegal": "使用协议与访问清单",
"legalDescription": "查看 Termix 的使用条件,以及各项功能可能访问的资源。",
"usernameLabel": "用户名",
"roleLabel": "角色",
"roleAdministrator": "管理员",
@@ -3379,6 +3381,9 @@
"statusColorsDesc": "使用绿色/红色表示在线/离线状态,而非强调色",
"pinAppRail": "固定应用边栏",
"pinAppRailDesc": "保持左侧应用边栏始终展开,而非悬停展开",
"unpinAppRail": "取消固定应用边栏",
"openFullscreenSettings": "全屏打开设置",
"exitFullscreenSettings": "退出全屏设置",
"expandAppRailOnHover": "悬停展开应用边栏",
"expandAppRailOnHoverDesc": "允许鼠标悬停时展开左侧应用边栏",
"settingsNavigation": "导航",
@@ -3496,7 +3501,34 @@
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
"themeGruvbox": "Gruvbox",
"termsTitle": "使用协议",
"termsIntro": "使用 Termix 即表示您同意仅访问已获得授权的系统、账户和数据。",
"termsAuthorization": "连接主机或共享会话前,您有责任获得系统所有者的明确许可。",
"termsSecurity": "您有责任保护凭据、备份、API 密钥以及此 Termix 实例的访问权限。",
"termsAcceptableUse": "不得使用 Termix 干扰服务、绕过访问控制或从事违法活动。",
"termsAvailability": "Termix 不提供服务可用性保证。用于关键操作前,请建立备份并验证恢复流程。",
"accessDisclosureTitle": "访问清单",
"accessDisclosureIntro": "Termix 仅访问您已配置功能或主动发起操作所需的资源。",
"access": {
"connections": {
"title": "已配置的系统",
"description": "连接您添加的主机与服务,包括 SSH、Telnet、RDP、VNC、Docker、Proxmox、隧道和监控端点。"
},
"credentials": {
"title": "凭据与会话",
"description": "使用保存的凭据、密钥、令牌和会话数据,对您请求的连接进行认证并维持会话。"
},
"storage": {
"title": "文件与本地数据",
"description": "仅在使用对应功能时读取或写入远程文件、剪贴板数据、导入导出内容和本地偏好设置。"
},
"services": {
"title": "可选集成",
"description": "仅在启用或配置后访问 SSO、ACME、Tailscale、Webhook、更新检查和远程同步服务。"
}
},
"telemetryDisclosure": "可在管理员设置中关闭可选分析。Termix 不会有意在分析事件中包含凭据、终端输入或文件内容。"
}
}
},
+1 -1
View File
@@ -923,7 +923,7 @@ export function AdminSettingsPanel({
}
return (
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<AdminGeneralSettingsSection
open={openSections.has("general")}
onToggle={() => toggle("general")}
+46
View File
@@ -9,6 +9,8 @@ import {
LayoutPanelLeft,
LogOut,
Network,
Pin,
PinOff,
Play,
Plug,
ScrollText,
@@ -20,6 +22,7 @@ import {
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import { isElectron } from "@/lib/electron";
export type RailView =
@@ -287,6 +290,20 @@ export function AppRail({
: new Set([...hiddenTabs, "termix-id"]);
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
const togglePinned = () => {
const next = !pinned;
setPinned(next);
localStorage.setItem("pinAppRail", String(next));
window.dispatchEvent(new Event("pinAppRailChanged"));
void getUserPreferences()
.then((preferences) => {
if (preferences.storageMode === "cloud") {
return saveUserPreferences({ pinAppRail: next });
}
})
.catch(() => {});
};
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
@@ -356,6 +373,35 @@ export function AppRail({
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
<button
onClick={togglePinned}
style={btnStyle}
className={`${btnBase} ${
pinned
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
title={
pinned
? t("newUi.sidebar.userProfile.unpinAppRail")
: t("newUi.sidebar.userProfile.pinAppRail")
}
aria-pressed={pinned}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{pinned ? <PinOff size={16} /> : <Pin size={16} />}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{pinned
? t("newUi.sidebar.userProfile.unpinAppRail")
: t("newUi.sidebar.userProfile.pinAppRail")}
</span>
</button>
{[
{
view: "alerts" as RailView,
+14 -2
View File
@@ -85,6 +85,7 @@ import { useTheme } from "@/components/theme-provider";
import type { FontSizeId, ThemeId } from "@/types/ui-types";
import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { LegalDisclosure } from "@/legal/LegalDisclosure";
type UserProfileSection =
| "account"
@@ -92,7 +93,8 @@ type UserProfileSection =
| "security"
| "api-keys"
| "data"
| "c2s-tunnels";
| "c2s-tunnels"
| "legal";
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
@@ -1308,7 +1310,7 @@ export function UserProfilePanel({
const canChangePasword = !isOidc || isDualAuth;
return (
<div className="flex flex-col gap-2 p-3">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
<NewApiKeyDialog
open={newKeyOpen}
onOpenChange={setNewKeyOpen}
@@ -2063,6 +2065,16 @@ export function UserProfilePanel({
</div>
</AccordionSection>
<AccordionSection
id="legal"
label={t("newUi.sidebar.userProfile.sectionLegal")}
icon={<ScrollText className="size-3.5" />}
open={openSections.has("legal")}
onToggle={() => toggle("legal")}
>
<LegalDisclosure />
</AccordionSection>
{/* The embedded desktop backend auto-authenticates its machine-local
profile, so server login controls would imply protection they do
not provide. Remote Sync owns its separate account UI above. */}
+13 -1
View File
@@ -1,10 +1,16 @@
import { describe, expect, it, beforeEach } from "vitest";
import { changeAppLanguage, normalizeLanguageCode } from "../../i18n/i18n";
import {
changeAppLanguage,
consumeLoginLanguage,
normalizeLanguageCode,
rememberLoginLanguage,
} from "../../i18n/i18n";
describe("i18n language handling", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it("normalizes persisted desktop language codes", () => {
@@ -18,4 +24,10 @@ describe("i18n language handling", () => {
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
});
it("keeps an explicit login language until preferences are hydrated", () => {
expect(rememberLoginLanguage("zh_CN")).toBe("zh-CN");
expect(consumeLoginLanguage()).toBe("zh-CN");
expect(consumeLoginLanguage()).toBeNull();
});
});