mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: improve all connection types
This commit is contained in:
@@ -32,8 +32,11 @@ import {
|
||||
updateOIDCConfig,
|
||||
disableOIDCConfig,
|
||||
isElectron,
|
||||
getUserRoles,
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
} from "@/main-axios";
|
||||
import type { ApiKey, CreatedApiKey } from "@/main-axios";
|
||||
import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -154,6 +157,8 @@ export function AdminSettingsPanel() {
|
||||
const [editUserOpen, setEditUserOpen] = useState(false);
|
||||
const [editUserTarget, setEditUserTarget] = useState<any | null>(null);
|
||||
const [editUserLoading, setEditUserLoading] = useState(false);
|
||||
const [editUserRoles, setEditUserRoles] = useState<UserRole[]>([]);
|
||||
const [editUserRolesLoading, setEditUserRolesLoading] = useState(false);
|
||||
|
||||
// Link account dialog
|
||||
const [linkAccountOpen, setLinkAccountOpen] = useState(false);
|
||||
@@ -196,6 +201,17 @@ export function AdminSettingsPanel() {
|
||||
loadOidcConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editUserOpen && editUserTarget) {
|
||||
setEditUserRoles([]);
|
||||
setEditUserRolesLoading(true);
|
||||
getUserRoles(editUserTarget.id)
|
||||
.then(({ roles: r }) => setEditUserRoles(r))
|
||||
.catch(() => {})
|
||||
.finally(() => setEditUserRolesLoading(false));
|
||||
}
|
||||
}, [editUserOpen, editUserTarget?.id]);
|
||||
|
||||
function loadUsers() {
|
||||
getUserList()
|
||||
.then(({ users: u }) => setUsers(u))
|
||||
@@ -257,18 +273,21 @@ export function AdminSettingsPanel() {
|
||||
try {
|
||||
const config = await getAdminOIDCConfig();
|
||||
if (!config) return;
|
||||
setOidcClientId((config.clientId as string) ?? "");
|
||||
setOidcClientSecret((config.clientSecret as string) ?? "");
|
||||
setOidcAuthUrl((config.authorizationUrl as string) ?? "");
|
||||
setOidcIssuerUrl((config.issuerUrl as string) ?? "");
|
||||
setOidcTokenUrl((config.tokenUrl as string) ?? "");
|
||||
setOidcUserIdentifier((config.userIdentifierPath as string) ?? "sub");
|
||||
setOidcDisplayName((config.displayNamePath as string) ?? "name");
|
||||
setOidcClientId((config.client_id as string) ?? "");
|
||||
setOidcClientSecret((config.client_secret as string) ?? "");
|
||||
setOidcAuthUrl((config.authorization_url as string) ?? "");
|
||||
setOidcIssuerUrl((config.issuer_url as string) ?? "");
|
||||
setOidcTokenUrl((config.token_url as string) ?? "");
|
||||
setOidcUserIdentifier((config.identifier_path as string) ?? "sub");
|
||||
setOidcDisplayName((config.name_path as string) ?? "name");
|
||||
setOidcScopes((config.scopes as string) ?? "openid email profile");
|
||||
setOidcUserinfoUrl((config.overrideUserinfoUrl as string) ?? "");
|
||||
setOidcUserinfoUrl((config.userinfo_url as string) ?? "");
|
||||
setOidcAllowedUsers(
|
||||
Array.isArray(config.allowedUsers)
|
||||
? (config.allowedUsers as string[]).join("\n")
|
||||
typeof config.allowed_users === "string"
|
||||
? (config.allowed_users as string)
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "",
|
||||
);
|
||||
} catch {
|
||||
@@ -378,18 +397,18 @@ export function AdminSettingsPanel() {
|
||||
setOidcSaving(true);
|
||||
try {
|
||||
await updateOIDCConfig({
|
||||
clientId: oidcClientId,
|
||||
clientSecret: oidcClientSecret,
|
||||
authorizationUrl: oidcAuthUrl,
|
||||
issuerUrl: oidcIssuerUrl,
|
||||
tokenUrl: oidcTokenUrl,
|
||||
userIdentifierPath: oidcUserIdentifier,
|
||||
displayNamePath: oidcDisplayName,
|
||||
client_id: oidcClientId,
|
||||
client_secret: oidcClientSecret,
|
||||
authorization_url: oidcAuthUrl,
|
||||
issuer_url: oidcIssuerUrl,
|
||||
token_url: oidcTokenUrl,
|
||||
identifier_path: oidcUserIdentifier,
|
||||
name_path: oidcDisplayName,
|
||||
scopes: oidcScopes,
|
||||
overrideUserinfoUrl: oidcUserinfoUrl || null,
|
||||
allowedUsers: oidcAllowedUsers
|
||||
? oidcAllowedUsers.split("\n").filter(Boolean)
|
||||
: [],
|
||||
userinfo_url: oidcUserinfoUrl || "",
|
||||
allowed_users: oidcAllowedUsers
|
||||
? oidcAllowedUsers.split("\n").filter(Boolean).join(",")
|
||||
: "",
|
||||
});
|
||||
toast.success("OIDC configuration saved");
|
||||
} catch (e: any) {
|
||||
@@ -497,18 +516,19 @@ export function AdminSettingsPanel() {
|
||||
return;
|
||||
}
|
||||
setCreateRoleLoading(true);
|
||||
const displayName = newRoleDisplayName.trim();
|
||||
try {
|
||||
const { role } = await createRole({
|
||||
await createRole({
|
||||
name: newRoleName.trim(),
|
||||
displayName: newRoleDisplayName.trim(),
|
||||
displayName,
|
||||
description: newRoleDescription.trim() || null,
|
||||
});
|
||||
setRoles((prev) => [...prev, role]);
|
||||
setShowCreateRole(false);
|
||||
setNewRoleName("");
|
||||
setNewRoleDisplayName("");
|
||||
setNewRoleDescription("");
|
||||
toast.success(`Role "${role.displayName}" created`);
|
||||
toast.success(`Role "${displayName}" created`);
|
||||
loadRoles();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.error || "Failed to create role");
|
||||
} finally {
|
||||
@@ -532,7 +552,7 @@ export function AdminSettingsPanel() {
|
||||
newKeyUserId.trim(),
|
||||
newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined,
|
||||
);
|
||||
setApiKeys((prev) => [created, ...prev]);
|
||||
setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
|
||||
setCreatedKeyToken(created.token);
|
||||
setNewKeyName("");
|
||||
setNewKeyUserId("");
|
||||
@@ -682,7 +702,7 @@ export function AdminSettingsPanel() {
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Allow Password Reset"
|
||||
description="Email-based password reset"
|
||||
description="Reset code via Docker logs"
|
||||
>
|
||||
<AdminToggle
|
||||
on={allowPasswordReset}
|
||||
@@ -1451,29 +1471,20 @@ export function AdminSettingsPanel() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Scoped User ID{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
User <span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="User ID"
|
||||
<select
|
||||
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
|
||||
value={newKeyUserId}
|
||||
onChange={(e) => setNewKeyUserId(e.target.value)}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
{users.length > 0 && (
|
||||
<select
|
||||
className="mt-1 px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
|
||||
value={newKeyUserId}
|
||||
onChange={(e) => setNewKeyUserId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
@@ -1693,6 +1704,112 @@ export function AdminSettingsPanel() {
|
||||
onToggle={() => handleToggleAdmin(editUserTarget)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<span className="text-sm font-medium">Roles</span>
|
||||
{editUserRolesLoading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{editUserRoles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{editUserRoles.map((ur) => {
|
||||
const roleInfo = roles.find(
|
||||
(r) => r.id === ur.roleId,
|
||||
);
|
||||
const isSystem = roleInfo?.isSystem ?? false;
|
||||
return (
|
||||
<span
|
||||
key={ur.roleId}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
>
|
||||
{ur.roleDisplayName}
|
||||
{!isSystem && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeRoleFromUser(
|
||||
editUserTarget.id,
|
||||
ur.roleId,
|
||||
);
|
||||
setEditUserRoles((prev) =>
|
||||
prev.filter(
|
||||
(r) => r.roleId !== ur.roleId,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to remove role");
|
||||
}
|
||||
}}
|
||||
className="hover:text-destructive ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{roles.filter(
|
||||
(r) =>
|
||||
!r.isSystem &&
|
||||
!editUserRoles.some((ur) => ur.roleId === r.id),
|
||||
).length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
Add role
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{roles
|
||||
.filter(
|
||||
(r) =>
|
||||
!r.isSystem &&
|
||||
!editUserRoles.some((ur) => ur.roleId === r.id),
|
||||
)
|
||||
.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await assignRoleToUser(
|
||||
editUserTarget.id,
|
||||
r.id,
|
||||
);
|
||||
setEditUserRoles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
userId: editUserTarget.id,
|
||||
roleId: r.id,
|
||||
roleName: r.name,
|
||||
roleDisplayName: r.displayName,
|
||||
grantedBy: "",
|
||||
grantedByUsername: "",
|
||||
grantedAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
toast.error("Failed to assign role");
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
+ {r.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editUserRoles.length === 0 &&
|
||||
roles.filter((r) => !r.isSystem).length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No custom roles defined
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
|
||||
import { getCommandHistory, deleteCommandFromHistory } from "@/main-axios";
|
||||
import {
|
||||
getCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
clearCommandHistory,
|
||||
} from "@/main-axios";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
export function HistoryPanel({
|
||||
@@ -16,20 +20,51 @@ export function HistoryPanel({
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commands, setCommands] = useState<string[]>([]);
|
||||
const [trackingEnabled, setTrackingEnabled] = useState(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
const activeTab = terminalTabs.find((t) => t.id === activeTabId);
|
||||
const activeIsTerminal = !!activeTab;
|
||||
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
const handler = () =>
|
||||
setTrackingEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
window.addEventListener("commandHistoryTrackingChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("commandHistoryTrackingChanged", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId || !trackingEnabled) {
|
||||
setCommands([]);
|
||||
return;
|
||||
}
|
||||
getCommandHistory(hostId)
|
||||
.then(setCommands)
|
||||
.catch(() => setCommands([]));
|
||||
}, [hostId]);
|
||||
}, [hostId, trackingEnabled]);
|
||||
|
||||
if (activeIsTerminal && !trackingEnabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("newUi.sidebar.history.trackingDisabled")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("newUi.sidebar.history.trackingDisabledHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeIsTerminal) {
|
||||
return (
|
||||
@@ -85,7 +120,15 @@ export function HistoryPanel({
|
||||
{filtered.length} command{filtered.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommands([])}
|
||||
onClick={async () => {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await clearCommandHistory(hostId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCommands([]);
|
||||
}}
|
||||
className="text-xs text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("newUi.sidebar.history.clearAll")}
|
||||
|
||||
+1086
-352
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,13 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
export function QuickConnectPanel() {
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: "terminal" | "files") => void;
|
||||
}
|
||||
|
||||
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
@@ -12,8 +17,45 @@ export function QuickConnectPanel() {
|
||||
"password",
|
||||
);
|
||||
const [password, setPassword] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const connect = (type: "terminal" | "files") => {
|
||||
if (!host || !username) return;
|
||||
const hostConfig: Host = {
|
||||
id: `quick-connect-${Date.now()}`,
|
||||
name: `${username}@${host}`,
|
||||
ip: host,
|
||||
port: parseInt(port) || 22,
|
||||
username,
|
||||
authType,
|
||||
password: authType === "password" ? password : undefined,
|
||||
key: authType === "key" ? privateKey : undefined,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
pin: false,
|
||||
defaultPath: "",
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
enableTerminal: true,
|
||||
enableFileManager: true,
|
||||
enableTunnel: true,
|
||||
enableDocker: true,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: parseInt(port) || 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
};
|
||||
onConnect(hostConfig, type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
@@ -25,6 +67,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -36,6 +81,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,6 +95,9 @@ export function QuickConnectPanel() {
|
||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,6 +134,9 @@ export function QuickConnectPanel() {
|
||||
)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
}}
|
||||
className="h-7 text-xs pr-8"
|
||||
/>
|
||||
<button
|
||||
@@ -107,6 +161,8 @@ export function QuickConnectPanel() {
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||
)}
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,11 +181,17 @@ export function QuickConnectPanel() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors">
|
||||
<button
|
||||
onClick={() => connect("terminal")}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToTerminal")}
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors">
|
||||
<button
|
||||
onClick={() => connect("files")}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<FolderSearch className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToFiles")}
|
||||
</button>
|
||||
|
||||
+518
-149
@@ -3,7 +3,15 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getSnippets,
|
||||
createSnippet as apiCreateSnippet,
|
||||
updateSnippet as apiUpdateSnippet,
|
||||
deleteSnippet as apiDeleteSnippet,
|
||||
getSnippetFolders,
|
||||
createSnippetFolder as apiCreateSnippetFolder,
|
||||
shareSnippet as apiShareSnippet,
|
||||
getSnippetAccess,
|
||||
revokeSnippetAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
} from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -33,6 +41,8 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { FOLDER_COLORS } from "@/lib/theme";
|
||||
@@ -91,47 +101,63 @@ function FolderIconEl({
|
||||
}
|
||||
}
|
||||
|
||||
function CreateSnippetDialog({
|
||||
function SnippetFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
folders,
|
||||
onCreate,
|
||||
snippet,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
folders: SnippetFolder[];
|
||||
onCreate: (s: Omit<Snippet, "id">) => void;
|
||||
snippet: Snippet | null;
|
||||
onSave: (data: Omit<Snippet, "id">, id?: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [folderId, setFolderId] = useState<number | null>(null);
|
||||
const [command, setCommand] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
function handleCreate() {
|
||||
if (!name.trim() || !command.trim()) return;
|
||||
onCreate({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
command: command.trim(),
|
||||
folderId,
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFolderId(null);
|
||||
setCommand("");
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(snippet?.name ?? "");
|
||||
setDescription(snippet?.description ?? "");
|
||||
setFolder(snippet?.folder ?? null);
|
||||
setContent(snippet?.content ?? "");
|
||||
}
|
||||
}, [open, snippet]);
|
||||
|
||||
function handleSave() {
|
||||
if (!name.trim() || !content.trim()) return;
|
||||
onSave(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
folder,
|
||||
},
|
||||
snippet?.id,
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const isEdit = snippet !== null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.createSnippetTitle")}
|
||||
{isEdit
|
||||
? t("newUi.sidebar.snippets.editSnippetTitle")
|
||||
: t("newUi.sidebar.snippets.createSnippetTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.createSnippetDescription")}
|
||||
{isEdit
|
||||
? t("newUi.sidebar.snippets.editSnippetDescription")
|
||||
: t("newUi.sidebar.snippets.createSnippetDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
@@ -168,22 +194,18 @@ function CreateSnippetDialog({
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={folderId ?? ""}
|
||||
value={folder ?? ""}
|
||||
onChange={(e) =>
|
||||
setFolderId(
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
)
|
||||
setFolder(e.target.value === "" ? null : e.target.value)
|
||||
}
|
||||
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
|
||||
{folders
|
||||
.filter((f) => f.name !== "Uncategorized")
|
||||
.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -193,8 +215,8 @@ function CreateSnippetDialog({
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={t("newUi.sidebar.snippets.commandPlaceholder")}
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="w-full h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,9 +228,11 @@ function CreateSnippetDialog({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleCreate}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.createSnippetButton")}
|
||||
{isEdit
|
||||
? t("newUi.sidebar.snippets.saveSnippetButton")
|
||||
: t("newUi.sidebar.snippets.createSnippetButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -330,6 +354,317 @@ function CreateFolderDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type AccessRecord = {
|
||||
id: number;
|
||||
targetType: "user" | "role";
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
function ShareSnippetDialog({
|
||||
snippet,
|
||||
onClose,
|
||||
}: {
|
||||
snippet: Snippet | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
|
||||
const [roles, setRoles] = useState<
|
||||
{ id: number; name: string; displayName?: string }[]
|
||||
>([]);
|
||||
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
||||
const [targetType, setTargetType] = useState<"user" | "role">("user");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snippet) return;
|
||||
setLoading(true);
|
||||
Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
|
||||
.then(([usersData, rolesData, accessData]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setUsers(
|
||||
(usersData?.users || []).map((u: any) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
})),
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setRoles((rolesData?.roles || []).map((r: any) => r));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setAccessList((accessData as any).accessList || []);
|
||||
})
|
||||
.catch(() => toast.error(t("newUi.sidebar.snippets.shareLoadError")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [snippet, t]);
|
||||
|
||||
async function handleShare() {
|
||||
if (!snippet || !targetId) return;
|
||||
try {
|
||||
await apiShareSnippet(snippet.id, {
|
||||
targetType,
|
||||
targetUserId: targetType === "user" ? targetId : undefined,
|
||||
targetRoleId: targetType === "role" ? parseInt(targetId) : undefined,
|
||||
});
|
||||
toast.success(t("newUi.sidebar.snippets.shareSuccess"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accessData = (await getSnippetAccess(snippet.id)) as any;
|
||||
setAccessList(accessData.accessList || []);
|
||||
setTargetId("");
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.shareFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(accessId: number) {
|
||||
if (!snippet) return;
|
||||
try {
|
||||
await revokeSnippetAccess(snippet.id, accessId);
|
||||
toast.success(t("newUi.sidebar.snippets.revokeSuccess"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const accessData = (await getSnippetAccess(snippet.id)) as any;
|
||||
setAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.revokeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={snippet !== null} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.shareTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{snippet?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType("user");
|
||||
setTargetId("");
|
||||
}}
|
||||
className={`flex-1 py-1.5 text-xs border transition-colors ${targetType === "user" ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.shareUser")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType("role");
|
||||
setTargetId("");
|
||||
}}
|
||||
className={`flex-1 py-1.5 text-xs border transition-colors ${targetType === "role" ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.shareRole")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring h-9"
|
||||
>
|
||||
<option value="">
|
||||
{targetType === "user"
|
||||
? t("newUi.sidebar.snippets.selectUser")
|
||||
: t("newUi.sidebar.snippets.selectRole")}
|
||||
</option>
|
||||
{targetType === "user"
|
||||
? users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))
|
||||
: roles.map((r) => (
|
||||
<option key={r.id} value={String(r.id)}>
|
||||
{r.displayName || r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleShare}
|
||||
disabled={!targetId}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{accessList.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.currentAccess")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{accessList.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between px-2.5 py-1.5 border border-border text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{entry.targetType === "user"
|
||||
? entry.username
|
||||
: entry.roleDisplayName || entry.roleName}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({entry.targetType})
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRevoke(entry.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive ml-2"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("newUi.sidebar.snippets.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SnippetCard({
|
||||
snippet,
|
||||
selectedTabIds,
|
||||
terminalTabs,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
t,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
selectedTabIds: Set<string>;
|
||||
terminalTabs: Tab[];
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
function handleRun() {
|
||||
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((tab) => {
|
||||
tab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
});
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
count: targets.length,
|
||||
}),
|
||||
);
|
||||
} else if (terminalTabs.length > 0) {
|
||||
terminalTabs[0].terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(snippet.content);
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.copySuccess", { name: snippet.name }),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-background p-2.5 flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.content}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
onClick={handleRun}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onEdit(snippet)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => onDelete(snippet.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => onShare(snippet)}
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
@@ -341,6 +676,19 @@ export function SnippetsPanel({
|
||||
const [snippetSearch, setSnippetSearch] = useState("");
|
||||
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [snippetFormOpen, setSnippetFormOpen] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const [uncategorizedOpen, setUncategorizedOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets()
|
||||
@@ -351,23 +699,28 @@ export function SnippetsPanel({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
command: s.command,
|
||||
folderId: s.folderId ?? null,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
}));
|
||||
setSnippets(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
getSnippetFolders()
|
||||
.then((data) => {
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapped: SnippetFolder[] = arr.map((f: any) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: true,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [createSnippetOpen, setCreateSnippetOpen] = useState(false);
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((t) => t.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
@@ -377,24 +730,49 @@ export function SnippetsPanel({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateSnippet(s: Omit<Snippet, "id">) {
|
||||
async function handleSaveSnippet(data: Omit<Snippet, "id">, id?: number) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippet(s as any)) as any;
|
||||
setSnippets((prev) => [
|
||||
...prev,
|
||||
{ ...s, id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1 },
|
||||
]);
|
||||
toast.success("Snippet created successfully");
|
||||
if (id !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await apiUpdateSnippet(id, data as any);
|
||||
setSnippets((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
|
||||
);
|
||||
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippet(data as any)) as any;
|
||||
setSnippets((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...data,
|
||||
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
},
|
||||
]);
|
||||
toast.success(t("newUi.sidebar.snippets.createSuccess"));
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to create snippet");
|
||||
toast.error(
|
||||
id !== undefined
|
||||
? t("newUi.sidebar.snippets.updateFailed")
|
||||
: t("newUi.sidebar.snippets.createFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
|
||||
const id = Math.max(0, ...folders.map((x) => x.id)) + 1;
|
||||
setFolders((prev) => [...prev, { ...f, id, open: true }]);
|
||||
toast.success("Folder created successfully");
|
||||
async function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const created = (await apiCreateSnippetFolder({
|
||||
name: f.name,
|
||||
color: f.color,
|
||||
icon: f.icon,
|
||||
})) as any;
|
||||
setFolders((prev) => [...prev, { ...f, id: created.id, open: true }]);
|
||||
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(id: number) {
|
||||
@@ -403,29 +781,29 @@ export function SnippetsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteSnippet(id: number) {
|
||||
async function handleDeleteSnippet(id: number) {
|
||||
try {
|
||||
await apiDeleteSnippet(id);
|
||||
setSnippets((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch {
|
||||
toast.error("Failed to delete snippet");
|
||||
toast.error(t("newUi.sidebar.snippets.deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditSnippet(snippet: Snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
setSnippetFormOpen(true);
|
||||
}
|
||||
|
||||
const filtered = snippetSearch
|
||||
? snippets.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(snippetSearch.toLowerCase()) ||
|
||||
s.command.toLowerCase().includes(snippetSearch.toLowerCase()),
|
||||
s.content.toLowerCase().includes(snippetSearch.toLowerCase()),
|
||||
)
|
||||
: snippets;
|
||||
|
||||
const namedFolders = folders.filter((f) => f.name !== "Uncategorized");
|
||||
const uncategorized = folders.find((f) => f.name === "Uncategorized");
|
||||
const allFolders = [
|
||||
...namedFolders,
|
||||
...(uncategorized ? [uncategorized] : []),
|
||||
];
|
||||
const uncategorizedSnippets = filtered.filter((s) => s.folder === null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -442,7 +820,9 @@ export function SnippetsPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedTabIds(new Set(terminalTabs.map((t) => t.id)))
|
||||
setSelectedTabIds(
|
||||
new Set(terminalTabs.map((tab) => tab.id)),
|
||||
)
|
||||
}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
@@ -511,7 +891,10 @@ export function SnippetsPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden"
|
||||
onClick={() => setCreateSnippetOpen(true)}
|
||||
onClick={() => {
|
||||
setEditingSnippet(null);
|
||||
setSnippetFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.newSnippet")}
|
||||
@@ -526,11 +909,49 @@ export function SnippetsPanel({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{allFolders.map((folder) => {
|
||||
const folderSnippets = filtered.filter((s) =>
|
||||
folder.name === "Uncategorized"
|
||||
? s.folderId === null || s.folderId === folder.id
|
||||
: s.folderId === folder.id,
|
||||
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setUncategorizedOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${uncategorizedOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold flex-1 truncate text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.uncategorized")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{uncategorizedSnippets.length}
|
||||
</span>
|
||||
</button>
|
||||
{uncategorizedOpen && (
|
||||
<div className="flex flex-col gap-2 ml-1">
|
||||
{uncategorizedSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{uncategorizedSnippets.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 pl-1">
|
||||
{t("newUi.sidebar.snippets.noSnippetsInFolder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{folders.map((folder) => {
|
||||
const folderSnippets = filtered.filter(
|
||||
(s) => s.folder === folder.name,
|
||||
);
|
||||
if (folderSnippets.length === 0 && snippetSearch) return null;
|
||||
return (
|
||||
@@ -549,12 +970,7 @@ export function SnippetsPanel({
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-semibold flex-1 truncate"
|
||||
style={{
|
||||
color:
|
||||
folder.name === "Uncategorized"
|
||||
? undefined
|
||||
: folder.color,
|
||||
}}
|
||||
style={{ color: folder.color }}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
@@ -565,71 +981,16 @@ export function SnippetsPanel({
|
||||
{folder.open && (
|
||||
<div className="flex flex-col gap-2 ml-1">
|
||||
{folderSnippets.map((snippet) => (
|
||||
<div
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
className="border border-border bg-background p-2.5 flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30">
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
<div className="size-1 bg-muted-foreground rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-semibold">
|
||||
{snippet.name}
|
||||
</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.command}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => deleteSnippet(snippet.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{folderSnippets.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 pl-1">
|
||||
@@ -644,17 +1005,25 @@ export function SnippetsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateSnippetDialog
|
||||
open={createSnippetOpen}
|
||||
onOpenChange={setCreateSnippetOpen}
|
||||
<SnippetFormDialog
|
||||
open={snippetFormOpen}
|
||||
onOpenChange={(v) => {
|
||||
setSnippetFormOpen(v);
|
||||
if (!v) setEditingSnippet(null);
|
||||
}}
|
||||
folders={folders}
|
||||
onCreate={handleCreateSnippet}
|
||||
snippet={editingSnippet}
|
||||
onSave={handleSaveSnippet}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={createFolderOpen}
|
||||
onOpenChange={setCreateFolderOpen}
|
||||
onCreate={handleCreateFolder}
|
||||
/>
|
||||
<ShareSnippetDialog
|
||||
snippet={shareSnippet}
|
||||
onClose={() => setShareSnippet(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { Terminal } from "lucide-react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import { getCookie, setCookie } from "@/main-axios";
|
||||
|
||||
export function SshToolsPanel({
|
||||
terminalTabs,
|
||||
@@ -14,7 +15,9 @@ export function SshToolsPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [keyRecording, setKeyRecording] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(
|
||||
() => getCookie("rightClickCopyPaste") !== "false",
|
||||
);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -23,6 +26,11 @@ export function SshToolsPanel({
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyRecording) inputRef.current?.focus();
|
||||
}, [keyRecording]);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
@@ -40,6 +48,89 @@ export function SshToolsPanel({
|
||||
setSelectedTabIds(new Set());
|
||||
}
|
||||
|
||||
function broadcast(data: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
(tab?.terminalRef?.current as any)?.sendInput?.(data);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const ctrl = e.ctrlKey;
|
||||
const { key } = e;
|
||||
|
||||
if (ctrl) {
|
||||
const ctrlMap: Record<string, string> = {
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
l: "\x0C",
|
||||
u: "\x15",
|
||||
k: "\x0B",
|
||||
a: "\x01",
|
||||
e: "\x05",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
r: "\x12",
|
||||
};
|
||||
const seq = ctrlMap[key.toLowerCase()];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const specialMap: Record<string, string> = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7F",
|
||||
Delete: "\x1B[3~",
|
||||
Tab: "\t",
|
||||
Escape: "\x1B",
|
||||
ArrowUp: "\x1B[A",
|
||||
ArrowDown: "\x1B[B",
|
||||
ArrowRight: "\x1B[C",
|
||||
ArrowLeft: "\x1B[D",
|
||||
Home: "\x1B[H",
|
||||
End: "\x1B[F",
|
||||
PageUp: "\x1B[5~",
|
||||
PageDown: "\x1B[6~",
|
||||
Insert: "\x1B[2~",
|
||||
F1: "\x1BOP",
|
||||
F2: "\x1BOQ",
|
||||
F3: "\x1BOR",
|
||||
F4: "\x1BOS",
|
||||
F5: "\x1B[15~",
|
||||
F6: "\x1B[17~",
|
||||
F7: "\x1B[18~",
|
||||
F8: "\x1B[19~",
|
||||
F9: "\x1B[20~",
|
||||
F10: "\x1B[21~",
|
||||
F11: "\x1B[23~",
|
||||
F12: "\x1B[24~",
|
||||
};
|
||||
|
||||
const seq = specialMap[key];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
|
||||
broadcast(key);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
const next = !keyRecording;
|
||||
if (!next) {
|
||||
// clear the phantom text when stopping
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
setKeyRecording(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -114,14 +205,24 @@ export function SshToolsPanel({
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
|
||||
onClick={() => setKeyRecording((o) => !o)}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
{keyRecording
|
||||
? `Stop Recording (${selectedTabIds.size})`
|
||||
? `${t("newUi.sidebar.sshTools.stopRecording")} (${selectedTabIds.size})`
|
||||
: selectedTabIds.size === 0
|
||||
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
|
||||
: `Start Recording (${selectedTabIds.size})`}
|
||||
: `${t("newUi.sidebar.sshTools.startRecording")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
|
||||
{keyRecording && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
readOnly
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")}
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -135,7 +236,11 @@ export function SshToolsPanel({
|
||||
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRightClickPaste((o) => !o)}
|
||||
onClick={() => {
|
||||
const next = !rightClickPaste;
|
||||
setRightClickPaste(next);
|
||||
setCookie("rightClickCopyPaste", next ? "true" : "false");
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
rightClickPaste
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
enableTOTP,
|
||||
disableTOTP,
|
||||
getVersionInfo,
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
|
||||
@@ -65,17 +67,17 @@ type UserProfileSection =
|
||||
| "api-keys"
|
||||
| "c2s-tunnels";
|
||||
|
||||
const THEMES: { id: ThemeId; label: string; preview: string }[] = [
|
||||
{ id: "system", label: "System", preview: "auto" },
|
||||
{ id: "light", label: "Light", preview: "#ffffff" },
|
||||
{ id: "dark", label: "Dark", preview: "#1a1c22" },
|
||||
{ id: "dracula", label: "Dracula", preview: "#282a36" },
|
||||
{ id: "catppuccin", label: "Catppuccin", preview: "#1e1e2e" },
|
||||
{ id: "nord", label: "Nord", preview: "#2e3440" },
|
||||
{ id: "solarized", label: "Solarized", preview: "#002b36" },
|
||||
{ id: "tokyo-night", label: "Tokyo Night", preview: "#1a1b26" },
|
||||
{ id: "one-dark", label: "One Dark", preview: "#282c34" },
|
||||
{ id: "gruvbox", label: "Gruvbox", preview: "#282828" },
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
{ id: "light", preview: "#ffffff" },
|
||||
{ id: "dark", preview: "#1a1c22" },
|
||||
{ id: "dracula", preview: "#282a36" },
|
||||
{ id: "catppuccin", preview: "#1e1e2e" },
|
||||
{ id: "nord", preview: "#2e3440" },
|
||||
{ id: "solarized", preview: "#002b36" },
|
||||
{ id: "tokyo-night", preview: "#1a1b26" },
|
||||
{ id: "one-dark", preview: "#282c34" },
|
||||
{ id: "gruvbox", preview: "#282828" },
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -380,6 +382,18 @@ export function UserProfilePanel({
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const themeLabel: Record<ThemeId, string> = {
|
||||
system: t("newUi.sidebar.userProfile.themeSystem"),
|
||||
light: t("newUi.sidebar.userProfile.themeLight"),
|
||||
dark: t("newUi.sidebar.userProfile.themeDark"),
|
||||
dracula: t("newUi.sidebar.userProfile.themeDracula"),
|
||||
catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"),
|
||||
nord: t("newUi.sidebar.userProfile.themeNord"),
|
||||
solarized: t("newUi.sidebar.userProfile.themeSolarized"),
|
||||
"tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"),
|
||||
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
|
||||
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
|
||||
};
|
||||
const [openSection, setOpenSection] = useState<UserProfileSection | null>(
|
||||
"account",
|
||||
);
|
||||
@@ -389,6 +403,9 @@ export function UserProfilePanel({
|
||||
const [userRole, setUserRole] = useState("");
|
||||
const [authMethod, setAuthMethod] = useState("");
|
||||
const [version, setVersion] = useState("");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [isOidc, setIsOidc] = useState(false);
|
||||
const [isDualAuth, setIsDualAuth] = useState(false);
|
||||
|
||||
@@ -467,6 +484,9 @@ export function UserProfilePanel({
|
||||
// API keys
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
|
||||
// RBAC roles
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
@@ -486,13 +506,19 @@ export function UserProfilePanel({
|
||||
} else {
|
||||
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
|
||||
}
|
||||
getUserRoles(info.userId)
|
||||
.then(({ roles }) => setUserRoles(roles ?? []))
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
getApiKeys()
|
||||
.then(({ apiKeys: keys }) => setApiKeys(keys))
|
||||
.catch(() => {});
|
||||
getVersionInfo(false)
|
||||
.then((info) => setVersion(info.localVersion))
|
||||
getVersionInfo()
|
||||
.then((info) => {
|
||||
setVersion(info.localVersion);
|
||||
setVersionStatus(info.status ?? "up_to_date");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -638,9 +664,19 @@ export function UserProfilePanel({
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.roleLabel")}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand mt-0.5 w-fit">
|
||||
{userRole || "—"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
|
||||
{userRole || "—"}
|
||||
</span>
|
||||
{userRoles.map((r) => (
|
||||
<span
|
||||
key={r.roleId}
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
|
||||
>
|
||||
{r.roleDisplayName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col py-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
@@ -679,6 +715,21 @@ export function UserProfilePanel({
|
||||
<span className="text-sm font-bold text-accent-brand">
|
||||
{version ? `v${version}` : "—"}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
|
||||
versionStatus === "beta"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: versionStatus === "requires_update"
|
||||
? "bg-yellow-500/20 text-yellow-400"
|
||||
: "bg-accent-brand/20 text-accent-brand"
|
||||
}`}
|
||||
>
|
||||
{versionStatus === "beta"
|
||||
? t("dashboard.beta").toUpperCase()
|
||||
: versionStatus === "requires_update"
|
||||
? t("dashboard.updateAvailable").toUpperCase()
|
||||
: t("dashboardTab.stable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -743,7 +794,7 @@ export function UserProfilePanel({
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{th.label}
|
||||
{themeLabel[th.id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -753,7 +804,7 @@ export function UserProfilePanel({
|
||||
{THEMES.filter((th) => th.id !== "system").map((th) => (
|
||||
<button
|
||||
key={th.id}
|
||||
title={th.label}
|
||||
title={themeLabel[th.id]}
|
||||
onClick={() => setTheme(th.id)}
|
||||
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
|
||||
style={{ background: th.preview }}
|
||||
@@ -808,7 +859,7 @@ export function UserProfilePanel({
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="size-5 shrink-0 border border-border/60 cursor-pointer"
|
||||
style={{ background: accentColor }}
|
||||
title="Open color picker"
|
||||
title={t("newUi.sidebar.userProfile.colorPickerTooltip")}
|
||||
/>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
@@ -1136,7 +1187,7 @@ export function UserProfilePanel({
|
||||
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
||||
<img
|
||||
src={totpQrCode}
|
||||
alt="TOTP QR Code"
|
||||
alt={t("newUi.sidebar.userProfile.qrCode")}
|
||||
className="size-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user