feat: Add folder customization and batch delete for SSH host manager
Allow users to customize folder appearance and manage hosts in bulk: Database & Types: - Add sshFolders table with color and icon fields for folder metadata - Add SSHFolder interface to types with userId, name, color, icon fields - Update folder rename route to also update folder metadata API Endpoints: - GET /ssh/folders - Fetch all folders with metadata for user - PUT /ssh/folders/metadata - Create or update folder color and icon - DELETE /ssh/folders/:name/hosts - Delete all hosts in a folder and folder metadata Frontend Features: - Create FolderEditDialog component with color picker (8 colors) and icon selector (10 icons) - Add folder metadata state management in HostManagerViewer - Display custom folder colors and icons in host manager UI - Add "Edit Folder Appearance" button with palette icon - Add "Delete All Hosts in Folder" button with trash icon and confirmation - Fetch and sync folder metadata on component mount and refresh API Functions: - getSSHFolders() - Retrieve all folder metadata - updateFolderMetadata(name, color, icon) - Update folder appearance - deleteAllHostsInFolder(folderName) - Batch delete with count return i18n Support: - Add translations for folder customization (en, zh) - Add batch delete confirmation messages - Add success/error toast messages
This commit is contained in:
@@ -209,6 +209,22 @@ export const snippets = sqliteTable("snippets", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
sshFolders,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -1341,6 +1342,17 @@ router.put(
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
// Also update folder metadata if exists
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
name: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
updatedHosts: updatedHosts.length,
|
||||
@@ -1358,6 +1370,157 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Get all folders with metadata (requires JWT)
|
||||
// GET /ssh/db/folders
|
||||
router.get(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid user ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch folders", err, {
|
||||
operation: "fetch_folders",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch folders" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Update folder metadata (requires JWT)
|
||||
// PUT /ssh/db/folders/metadata
|
||||
router.put(
|
||||
"/folders/metadata",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if folder metadata exists
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
color,
|
||||
icon,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
|
||||
);
|
||||
} else {
|
||||
// Create new
|
||||
await db.insert(sshFolders).values({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
|
||||
|
||||
res.json({ message: "Folder metadata updated successfully" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update folder metadata", err, {
|
||||
operation: "update_folder_metadata",
|
||||
userId,
|
||||
name,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update folder metadata" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Delete all hosts in folder (requires JWT)
|
||||
// DELETE /ssh/db/folders/:name/hosts
|
||||
router.delete(
|
||||
"/folders/:name/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folderName = req.params.name;
|
||||
|
||||
if (!isNonEmptyString(userId) || !folderName) {
|
||||
return res.status(400).json({ error: "Invalid folder name" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all hosts in the folder
|
||||
const hostsToDelete = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
|
||||
if (hostsToDelete.length === 0) {
|
||||
return res.json({
|
||||
message: "No hosts found in folder",
|
||||
deletedCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all hosts
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
|
||||
// Delete folder metadata
|
||||
await db
|
||||
.delete(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||
|
||||
sshLogger.info("Deleted all hosts in folder", {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "All hosts in folder deleted successfully",
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete hosts in folder", err, {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route: Bulk import SSH hosts (requires JWT)
|
||||
// POST /ssh/bulk-import
|
||||
router.post(
|
||||
|
||||
@@ -754,6 +754,17 @@
|
||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
"failedToRenameFolder": "Failed to rename folder",
|
||||
"editFolderAppearance": "Edit Folder Appearance",
|
||||
"editFolderAppearanceDesc": "Customize the color and icon for folder",
|
||||
"folderColor": "Folder Color",
|
||||
"folderIcon": "Folder Icon",
|
||||
"preview": "Preview",
|
||||
"folderAppearanceUpdated": "Folder appearance updated successfully",
|
||||
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
|
||||
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
|
||||
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
|
||||
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
|
||||
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
|
||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||
"failedToMoveToFolder": "Failed to move host to folder",
|
||||
"statistics": "Statistics",
|
||||
|
||||
@@ -766,6 +766,17 @@
|
||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||
"failedToRenameFolder": "重命名文件夹失败",
|
||||
"editFolderAppearance": "编辑文件夹外观",
|
||||
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
|
||||
"folderColor": "文件夹颜色",
|
||||
"folderIcon": "文件夹图标",
|
||||
"preview": "预览",
|
||||
"folderAppearanceUpdated": "文件夹外观更新成功",
|
||||
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
|
||||
"deleteAllHostsInFolder": "删除文件夹内所有主机",
|
||||
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
|
||||
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
|
||||
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
|
||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||
"statistics": "统计",
|
||||
|
||||
@@ -62,6 +62,16 @@ export interface SSHHostData {
|
||||
terminalConfig?: TerminalConfig;
|
||||
}
|
||||
|
||||
export interface SSHFolder {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREDENTIAL TYPES
|
||||
// ============================================================================
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
updateSSHHost,
|
||||
renameFolder,
|
||||
exportSSHHostWithCredentials,
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,12 +48,24 @@ import {
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
Palette,
|
||||
Trash,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHFolder,
|
||||
SSHManagerHostViewerProps,
|
||||
} from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { FolderEditDialog } from "./components/FolderEditDialog";
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -65,13 +80,17 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(new Map());
|
||||
const [editingFolderAppearance, setEditingFolderAppearance] = useState<string | null>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
|
||||
const handleHostsRefresh = () => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
};
|
||||
|
||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||
@@ -116,6 +135,87 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFolderMetadata = async () => {
|
||||
try {
|
||||
const folders = await getSSHFolders();
|
||||
const metadataMap = new Map<string, SSHFolder>();
|
||||
folders.forEach((folder) => {
|
||||
metadataMap.set(folder.name, folder);
|
||||
});
|
||||
setFolderMetadata(metadataMap);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder metadata:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderAppearance = async (folderName: string, color: string, icon: string) => {
|
||||
try {
|
||||
await updateFolderMetadata(folderName, color, icon);
|
||||
toast.success(t("hosts.folderAppearanceUpdated"));
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("folders:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder appearance:", error);
|
||||
toast.error(t("hosts.failedToUpdateFolderAppearance"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllHostsInFolder = async (folderName: string) => {
|
||||
const hostsInFolder = hostsByFolder[folderName] || [];
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDeleteAllHostsInFolder", {
|
||||
folder: folderName,
|
||||
count: hostsInFolder.length,
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
const result = await deleteAllHostsInFolder(folderName);
|
||||
toast.success(
|
||||
t("hosts.allHostsInFolderDeleted", {
|
||||
folder: folderName,
|
||||
count: result.deletedCount,
|
||||
})
|
||||
);
|
||||
await fetchHosts();
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||
refreshServerPolling();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete hosts in folder:", error);
|
||||
toast.error(t("hosts.failedToDeleteHostsInFolder"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const getFolderIcon = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
if (!metadata?.icon) return Folder;
|
||||
|
||||
const iconMap: Record<string, React.ComponentType> = {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
};
|
||||
|
||||
return iconMap[metadata.icon] || Folder;
|
||||
};
|
||||
|
||||
const getFolderColor = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
return metadata?.color;
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDelete", { name: hostName }),
|
||||
@@ -854,7 +954,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
{(() => {
|
||||
const FolderIcon = getFolderIcon(folder);
|
||||
const folderColor = getFolderColor(folder);
|
||||
return (
|
||||
<FolderIcon
|
||||
className="h-4 w-4"
|
||||
style={folderColor ? { color: folderColor } : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
@@ -935,6 +1044,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingFolderAppearance(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteAllHostsInFolder(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.deleteAllHostsInFolder")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
@@ -1202,6 +1355,22 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{editingFolderAppearance && (
|
||||
<FolderEditDialog
|
||||
folderName={editingFolderAppearance}
|
||||
currentColor={getFolderColor(editingFolderAppearance)}
|
||||
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||
open={editingFolderAppearance !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingFolderAppearance(null);
|
||||
}}
|
||||
onSave={async (color, icon) => {
|
||||
await handleSaveFolderAppearance(editingFolderAppearance, color, icon);
|
||||
setEditingFolderAppearance(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
189
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
189
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
|
||||
interface FolderEditDialogProps {
|
||||
folderName: string;
|
||||
currentColor?: string;
|
||||
currentIcon?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (color: string, icon: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f97316", label: "Orange" },
|
||||
{ value: "#eab308", label: "Yellow" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ec4899", label: "Pink" },
|
||||
{ value: "#6b7280", label: "Gray" },
|
||||
];
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
{ value: "Folder", label: "Folder", Icon: Folder },
|
||||
{ value: "Server", label: "Server", Icon: Server },
|
||||
{ value: "Cloud", label: "Cloud", Icon: Cloud },
|
||||
{ value: "Database", label: "Database", Icon: Database },
|
||||
{ value: "Box", label: "Box", Icon: Box },
|
||||
{ value: "Package", label: "Package", Icon: Package },
|
||||
{ value: "Layers", label: "Layers", Icon: Layers },
|
||||
{ value: "Archive", label: "Archive", Icon: Archive },
|
||||
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
|
||||
{ value: "Globe", label: "Globe", Icon: Globe },
|
||||
];
|
||||
|
||||
export function FolderEditDialog({
|
||||
folderName,
|
||||
currentColor,
|
||||
currentIcon,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: FolderEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor || AVAILABLE_COLORS[0].value);
|
||||
const [selectedIcon, setSelectedIcon] = useState(currentIcon || AVAILABLE_ICONS[0].value);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
|
||||
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
|
||||
}
|
||||
}, [open, currentColor, currentIcon]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave(selectedColor, selectedIcon);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save folder metadata:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("hosts.editFolderAppearanceDesc")}: <span className="font-mono text-foreground">{folderName}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Color Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderColor")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
selectedColor === color.value
|
||||
? "border-white shadow-lg scale-105"
|
||||
: "border-dark-border"
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderIcon")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
|
||||
selectedIcon === value
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-dark-border bg-dark-bg-darker"
|
||||
}`}
|
||||
onClick={() => setSelectedIcon(value)}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.preview")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
|
||||
{(() => {
|
||||
const IconComponent = AVAILABLE_ICONS.find(
|
||||
(i) => i.value === selectedIcon
|
||||
)?.Icon || Folder;
|
||||
return (
|
||||
<IconComponent
|
||||
className="w-5 h-5"
|
||||
style={{ color: selectedColor }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="font-medium">{folderName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHHostData,
|
||||
SSHFolder,
|
||||
TunnelConfig,
|
||||
TunnelStatus,
|
||||
FileManagerFile,
|
||||
@@ -2451,6 +2452,90 @@ export async function renameFolder(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||
try {
|
||||
sshLogger.info("Fetching SSH folders", {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
|
||||
const response = await authApi.get("/ssh/folders");
|
||||
|
||||
sshLogger.success("SSH folders fetched successfully", {
|
||||
operation: "fetch_ssh_folders",
|
||||
count: response.data.length,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to fetch SSH folders", error, {
|
||||
operation: "fetch_ssh_folders",
|
||||
});
|
||||
handleApiError(error, "fetch SSH folders");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFolderMetadata(
|
||||
name: string,
|
||||
color?: string,
|
||||
icon?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
sshLogger.info("Updating folder metadata", {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
await authApi.put("/ssh/folders/metadata", {
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
});
|
||||
|
||||
sshLogger.success("Folder metadata updated successfully", {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to update folder metadata", error, {
|
||||
operation: "update_folder_metadata",
|
||||
name,
|
||||
});
|
||||
handleApiError(error, "update folder metadata");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllHostsInFolder(
|
||||
folderName: string,
|
||||
): Promise<{ deletedCount: number }> {
|
||||
try {
|
||||
sshLogger.info("Deleting all hosts in folder", {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
});
|
||||
|
||||
const response = await authApi.delete(`/ssh/folders/${encodeURIComponent(folderName)}/hosts`);
|
||||
|
||||
sshLogger.success("All hosts in folder deleted successfully", {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
deletedCount: response.data.deletedCount,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||
operation: "delete_folder_hosts",
|
||||
folderName,
|
||||
});
|
||||
handleApiError(error, "delete hosts in folder");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameCredentialFolder(
|
||||
oldName: string,
|
||||
newName: string,
|
||||
|
||||
Reference in New Issue
Block a user