v1.9.0 #437

Merged
LukeGus merged 33 commits from dev-1.9.0 into main 2025-11-17 15:46:05 +00:00
6 changed files with 1506 additions and 272 deletions
Showing only changes of commit 16e28c6e6b - Show all commits

View File

@@ -386,14 +386,14 @@ const addColumnIfNotExists = (
try {
sqlite
.prepare(
`SELECT ${column}
`SELECT "${column}"
FROM ${table} LIMIT 1`,
)
.get();
} catch {
try {
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
ADD COLUMN "${column}" ${definition};`);
} catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
operation: "schema_migration",
@@ -495,6 +495,35 @@ const migrateSchema = () => {
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("snippets", "folder", "TEXT");
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
try {
sqlite
.prepare("SELECT id FROM snippet_folders LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS snippet_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
icon TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create snippet_folders table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite
.prepare("SELECT id FROM sessions LIMIT 1")

View File

@@ -206,6 +206,24 @@ export const snippets = sqliteTable("snippets", {
name: text("name").notNull(),
content: text("content").notNull(),
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetFolders = sqliteTable("snippet_folders", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),

View File

@@ -1,8 +1,8 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { snippets } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import { snippets, snippetFolders } from "../db/schema.js";
import { eq, and, desc, asc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -17,241 +17,389 @@ const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Get all snippets for the authenticated user
// GET /snippets
// Get all snippet folders
// GET /snippets/folders
router.get(
"/",
"/folders",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippets fetch");
authLogger.warn("Invalid userId for snippet folders fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(desc(snippets.updatedAt));
.from(snippetFolders)
.where(eq(snippetFolders.userId, userId))
.orderBy(asc(snippetFolders.name));
res.json(result);
} catch (err) {
authLogger.error("Failed to fetch snippets", err);
res.status(500).json({ error: "Failed to fetch snippets" });
authLogger.error("Failed to fetch snippet folders", err);
res.status(500).json({ error: "Failed to fetch snippet folders" });
}
},
);
// Get a specific snippet by ID
// GET /snippets/:id
router.get(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const snippetId = parseInt(id, 10);
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
userId,
id,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const result = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (result.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
res.json(result[0]);
} catch (err) {
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch snippet",
});
}
},
);
// Create a new snippet
// POST /snippets
// Create a new snippet folder
// POST /snippets/folders
router.post(
"/",
"/folders",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, content, description } = req.body;
const { name, color, icon } = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(content)
) {
authLogger.warn("Invalid snippet creation data validation failed", {
operation: "snippet_create",
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
authLogger.warn("Invalid snippet folder creation data", {
operation: "snippet_folder_create",
userId,
hasName: !!name,
hasContent: !!content,
});
return res.status(400).json({ error: "Name and content are required" });
return res.status(400).json({ error: "Folder name is required" });
}
try {
const existing = await db
.select()
.from(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
);
if (existing.length > 0) {
return res
.status(409)
.json({ error: "Folder with this name already exists" });
}
const insertData = {
userId,
name: name.trim(),
content: content.trim(),
description: description?.trim() || null,
color: color?.trim() || null,
icon: icon?.trim() || null,
};
const result = await db.insert(snippets).values(insertData).returning();
const result = await db
.insert(snippetFolders)
.values(insertData)
.returning();
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
operation: "snippet_create_success",
authLogger.success(`Snippet folder created: ${name} by user ${userId}`, {
operation: "snippet_folder_create_success",
userId,
snippetId: result[0].id,
name,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet", err);
authLogger.error("Failed to create snippet folder", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create snippet",
error:
err instanceof Error
? err.message
: "Failed to create snippet folder",
});
}
},
);
// Update a snippet
// PUT /snippets/:id
// Update snippet folder metadata (color, icon)
// PUT /snippets/folders/:name/metadata
router.put(
"/:id",
"/folders/:name/metadata",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const updateData = req.body;
const { name } = req.params;
const { color, icon } = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet update");
if (!isNonEmptyString(userId) || !name) {
authLogger.warn("Invalid request for snippet folder metadata update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
return res.status(404).json({ error: "Folder not found" });
}
const updateFields: Partial<{
color: string | null;
icon: string | null;
updatedAt: ReturnType<typeof sql.raw>;
name: string;
content: string;
description: string | null;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.content !== undefined)
updateFields.content = updateData.content.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (color !== undefined) updateFields.color = color?.trim() || null;
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
await db
.update(snippets)
.update(snippetFolders)
.set(updateFields)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
const updated = await db
.select()
.from(snippets)
.where(eq(snippets.id, parseInt(id)));
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, decodeURIComponent(name)),
),
);
authLogger.success(
`Snippet updated: ${updated[0].name} by user ${userId}`,
`Snippet folder metadata updated: ${name} by user ${userId}`,
{
operation: "snippet_update_success",
operation: "snippet_folder_metadata_update_success",
userId,
snippetId: parseInt(id),
name: updated[0].name,
name,
},
);
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet", err);
authLogger.error("Failed to update snippet folder metadata", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update snippet",
error:
err instanceof Error
? err.message
: "Failed to update snippet folder metadata",
});
}
},
);
// Delete a snippet
// DELETE /snippets/:id
router.delete(
"/:id",
// Rename snippet folder
// PUT /snippets/folders/rename
router.put(
"/folders/rename",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const { oldName, newName } = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet delete");
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(oldName) ||
!isNonEmptyString(newName)
) {
authLogger.warn("Invalid request for snippet folder rename");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
return res.status(404).json({ error: "Folder not found" });
}
const nameExists = await db
.select()
.from(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, newName),
),
);
if (nameExists.length > 0) {
return res
.status(409)
.json({ error: "Folder with new name already exists" });
}
await db
.delete(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
.update(snippetFolders)
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, oldName),
),
);
await db
.update(snippets)
.set({ folder: newName })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
authLogger.success(
`Snippet deleted: ${existing[0].name} by user ${userId}`,
`Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`,
{
operation: "snippet_delete_success",
operation: "snippet_folder_rename_success",
userId,
snippetId: parseInt(id),
name: existing[0].name,
oldName,
newName,
},
);
res.json({ success: true, oldName, newName });
} catch (err) {
authLogger.error("Failed to rename snippet folder", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to rename snippet folder",
});
}
},
);
// Delete snippet folder
// DELETE /snippets/folders/:name
router.delete(
"/folders/:name",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name } = req.params;
if (!isNonEmptyString(userId) || !name) {
authLogger.warn("Invalid request for snippet folder delete");
return res.status(400).json({ error: "Invalid request" });
}
try {
const folderName = decodeURIComponent(name);
await db
.update(snippets)
.set({ folder: null })
.where(
and(eq(snippets.userId, userId), eq(snippets.folder, folderName)),
);
await db
.delete(snippetFolders)
.where(
and(
eq(snippetFolders.userId, userId),
eq(snippetFolders.name, folderName),
),
);
authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`,
{
operation: "snippet_folder_delete_success",
userId,
name: folderName,
},
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet", err);
authLogger.error("Failed to delete snippet folder", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete snippet",
error:
err instanceof Error
? err.message
: "Failed to delete snippet folder",
});
}
},
);
// Reorder snippets (bulk update)
// PUT /snippets/reorder
router.put(
"/reorder",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { snippets: snippetUpdates } = req.body;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippet reorder");
return res.status(400).json({ error: "Invalid userId" });
}
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
authLogger.warn("Invalid snippet reorder data", {
operation: "snippet_reorder",
userId,
});
return res
.status(400)
.json({ error: "snippets array is required and must not be empty" });
}
try {
for (const update of snippetUpdates) {
const { id, order, folder } = update;
if (!id || order === undefined) {
continue;
}
const updateFields: Partial<{
order: number;
folder: string | null;
}> = {
order,
};
if (folder !== undefined) {
updateFields.folder = folder?.trim() || null;
}
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
}
authLogger.success(`Snippets reordered by user ${userId}`, {
operation: "snippet_reorder_success",
userId,
count: snippetUpdates.length,
});
res.json({ success: true, updated: snippetUpdates.length });
} catch (err) {
authLogger.error("Failed to reorder snippets", err);
res.status(500).json({
error:
err instanceof Error ? err.message : "Failed to reorder snippets",
});
}
},
@@ -514,4 +662,274 @@ router.post(
},
);
// Get all snippets for the authenticated user
// GET /snippets
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for snippets fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(snippets)
.where(eq(snippets.userId, userId))
.orderBy(
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
asc(snippets.folder),
asc(snippets.order),
desc(snippets.updatedAt),
);
res.json(result);
} catch (err) {
authLogger.error("Failed to fetch snippets", err);
res.status(500).json({ error: "Failed to fetch snippets" });
}
},
);
// Get a specific snippet by ID
// GET /snippets/:id
router.get(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const snippetId = parseInt(id, 10);
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
userId,
id,
});
return res.status(400).json({ error: "Invalid request parameters" });
}
try {
const result = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (result.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
res.json(result[0]);
} catch (err) {
authLogger.error("Failed to fetch snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch snippet",
});
}
},
);
// Create a new snippet
// POST /snippets
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, content, description, folder, order } = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(content)
) {
authLogger.warn("Invalid snippet creation data validation failed", {
operation: "snippet_create",
userId,
hasName: !!name,
hasContent: !!content,
});
return res.status(400).json({ error: "Name and content are required" });
}
try {
let snippetOrder = order;
if (snippetOrder === undefined || snippetOrder === null) {
const folderValue = folder?.trim() || "";
const maxOrderResult = await db
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
.from(snippets)
.where(
and(
eq(snippets.userId, userId),
folderValue
? eq(snippets.folder, folderValue)
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
),
);
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
snippetOrder = maxOrder + 1;
}
const insertData = {
userId,
name: name.trim(),
content: content.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
order: snippetOrder,
};
const result = await db.insert(snippets).values(insertData).returning();
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
operation: "snippet_create_success",
userId,
snippetId: result[0].id,
name,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create snippet",
});
}
},
);
// Update a snippet
// PUT /snippets/:id
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
const updateFields: Partial<{
updatedAt: ReturnType<typeof sql.raw>;
name: string;
content: string;
description: string | null;
folder: string | null;
order: number;
}> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.content !== undefined)
updateFields.content = updateData.content.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.order !== undefined) updateFields.order = updateData.order;
await db
.update(snippets)
.set(updateFields)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
const updated = await db
.select()
.from(snippets)
.where(eq(snippets.id, parseInt(id)));
authLogger.success(
`Snippet updated: ${updated[0].name} by user ${userId}`,
{
operation: "snippet_update_success",
userId,
snippetId: parseInt(id),
name: updated[0].name,
},
);
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update snippet",
});
}
},
);
// Delete a snippet
// DELETE /snippets/:id
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for snippet delete");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
if (existing.length === 0) {
return res.status(404).json({ error: "Snippet not found" });
}
await db
.delete(snippets)
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
authLogger.success(
`Snippet deleted: ${existing[0].name} by user ${userId}`,
{
operation: "snippet_delete_success",
userId,
snippetId: parseInt(id),
name: existing[0].name,
},
);
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete snippet",
});
}
},
);
export default router;

View File

@@ -506,6 +506,8 @@ export interface Snippet {
name: string;
content: string;
description?: string;
folder?: string;
order?: number;
createdAt: string;
updatedAt: string;
}
@@ -514,6 +516,18 @@ export interface SnippetData {
name: string;
content: string;
description?: string;
folder?: string;
order?: number;
}
export interface SnippetFolder {
id: number;
userId: string;
name: string;
color?: string;
icon?: string;
createdAt: string;
updatedAt: string;
}
// ============================================================================

View File

@@ -4,6 +4,14 @@ import { Input } from "@/components/ui/input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Tabs,
TabsList,
@@ -36,6 +44,22 @@ import {
Terminal,
LayoutGrid,
MonitorCheck,
Folder,
ChevronDown,
ChevronRight,
GripVertical,
FolderPlus,
Settings,
MoreVertical,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -49,9 +73,15 @@ import {
setCookie,
getCommandHistory,
deleteCommandFromHistory,
getSnippetFolders,
createSnippetFolder,
updateSnippetFolderMetadata,
renameSnippetFolder,
deleteSnippetFolder,
reorderSnippets,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData } from "../../../../types";
import type { Snippet, SnippetData, SnippetFolder } from "../../../../types";
interface TabData {
id: number;
@@ -79,6 +109,30 @@ interface SSHToolsSidebarProps {
onTabChange?: () => 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 SSHToolsSidebar({
isOpen,
onClose,
@@ -125,6 +179,7 @@ export function SSHToolsSidebar({
);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
@@ -140,6 +195,23 @@ export function SSHToolsSidebar({
const [selectedSnippetTabIds, setSelectedSnippetTabIds] = useState<number[]>(
[],
);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
new Set(),
);
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null,
);
const [folderFormData, setFolderFormData] = useState({
name: "",
color: "",
icon: "",
});
const [folderFormErrors, setFolderFormErrors] = useState({
name: false,
});
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
@@ -470,11 +542,16 @@ export function SSHToolsSidebar({
const fetchSnippets = async () => {
try {
setLoading(true);
const data = await getSnippets();
setSnippets(Array.isArray(data) ? data : []);
const [snippetsData, foldersData] = await Promise.all([
getSnippets(),
getSnippetFolders(),
]);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
setSnippetFolders([]);
} finally {
setLoading(false);
}
@@ -493,6 +570,7 @@ export function SSHToolsSidebar({
name: snippet.name,
content: snippet.content,
description: snippet.description || "",
folder: snippet.folder,
});
setFormErrors({ name: false, content: false });
setShowDialog(true);
@@ -578,6 +656,246 @@ export function SSHToolsSidebar({
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
const toggleFolder = (folderName: string) => {
setCollapsedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) {
next.delete(folderName);
} else {
next.add(folderName);
}
return next;
});
};
const getFolderIcon = (folderName: string) => {
const metadata = snippetFolders.find((f) => f.name === folderName);
if (!metadata?.icon) return Folder;
const iconData = AVAILABLE_ICONS.find((i) => i.value === metadata.icon);
return iconData?.Icon || Folder;
};
const getFolderColor = (folderName: string) => {
const metadata = snippetFolders.find((f) => f.name === folderName);
return metadata?.color;
};
const groupSnippetsByFolder = () => {
const grouped = new Map<string, Snippet[]>();
snippetFolders.forEach((folder) => {
if (!grouped.has(folder.name)) {
grouped.set(folder.name, []);
}
});
snippets.forEach((snippet) => {
const folderName = snippet.folder || "";
if (!grouped.has(folderName)) {
grouped.set(folderName, []);
}
grouped.get(folderName)!.push(snippet);
});
return grouped;
};
const handleDragStart = (e: React.DragEvent, snippet: Snippet) => {
setDraggedSnippet(snippet);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, targetSnippet: Snippet) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
};
const handleDragEnterFolder = (folderName: string) => {
setDragOverFolder(folderName);
};
const handleDragLeaveFolder = () => {
setDragOverFolder(null);
};
const handleDrop = async (e: React.DragEvent, targetSnippet: Snippet) => {
e.preventDefault();
if (!draggedSnippet || draggedSnippet.id === targetSnippet.id) {
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const sourceFolder = draggedSnippet.folder || "";
const targetFolder = targetSnippet.folder || "";
if (sourceFolder !== targetFolder) {
toast.error(
t("snippets.reorderSameFolder", {
defaultValue: "Can only reorder snippets within the same folder",
}),
);
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const folderSnippets = snippets.filter(
(s) => (s.folder || "") === targetFolder,
);
const draggedIndex = folderSnippets.findIndex(
(s) => s.id === draggedSnippet.id,
);
const targetIndex = folderSnippets.findIndex(
(s) => s.id === targetSnippet.id,
);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedSnippet(null);
setDragOverFolder(null);
return;
}
const reorderedSnippets = [...folderSnippets];
reorderedSnippets.splice(draggedIndex, 1);
reorderedSnippets.splice(targetIndex, 0, draggedSnippet);
const updates = reorderedSnippets.map((snippet, index) => ({
id: snippet.id,
order: index,
folder: targetFolder || undefined,
}));
try {
await reorderSnippets(updates);
toast.success(
t("snippets.reorderSuccess", {
defaultValue: "Snippets reordered successfully",
}),
);
fetchSnippets();
} catch {
toast.error(
t("snippets.reorderFailed", {
defaultValue: "Failed to reorder snippets",
}),
);
}
setDraggedSnippet(null);
setDragOverFolder(null);
};
const handleDragEnd = () => {
setDraggedSnippet(null);
setDragOverFolder(null);
};
const handleCreateFolder = () => {
setEditingFolder(null);
setFolderFormData({
name: "",
color: AVAILABLE_COLORS[0].value,
icon: AVAILABLE_ICONS[0].value,
});
setFolderFormErrors({ name: false });
setShowFolderDialog(true);
};
const handleEditFolder = (folder: SnippetFolder) => {
setEditingFolder(folder);
setFolderFormData({
name: folder.name,
color: folder.color || AVAILABLE_COLORS[0].value,
icon: folder.icon || AVAILABLE_ICONS[0].value,
});
setFolderFormErrors({ name: false });
setShowFolderDialog(true);
};
const handleDeleteFolder = (folderName: string) => {
confirmWithToast(
t("snippets.deleteFolderConfirm", {
name: folderName,
defaultValue: `Delete folder "${folderName}"? All snippets will be moved to Uncategorized.`,
}),
async () => {
try {
await deleteSnippetFolder(folderName);
toast.success(
t("snippets.deleteFolderSuccess", {
defaultValue: "Folder deleted successfully",
}),
);
fetchSnippets();
} catch {
toast.error(
t("snippets.deleteFolderFailed", {
defaultValue: "Failed to delete folder",
}),
);
}
},
"destructive",
);
};
const handleFolderSubmit = async () => {
const errors = {
name: !folderFormData.name.trim(),
};
setFolderFormErrors(errors);
if (errors.name) {
return;
}
try {
if (editingFolder) {
if (editingFolder.name !== folderFormData.name) {
await renameSnippetFolder(editingFolder.name, folderFormData.name);
}
await updateSnippetFolderMetadata(folderFormData.name, {
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(
t("snippets.updateFolderSuccess", {
defaultValue: "Folder updated successfully",
}),
);
} else {
await createSnippetFolder({
name: folderFormData.name,
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(
t("snippets.createFolderSuccess", {
defaultValue: "Folder created successfully",
}),
);
}
setShowFolderDialog(false);
fetchSnippets();
} catch {
toast.error(
editingFolder
? t("snippets.updateFolderFailed", {
defaultValue: "Failed to update folder",
})
: t("snippets.createFolderFailed", {
defaultValue: "Failed to create folder",
}),
);
}
};
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
setSplitMode(mode);
@@ -589,25 +907,25 @@ export function SSHToolsSidebar({
}
};
const handleDragStart = (tabId: number) => {
const handleTabDragStart = (tabId: number) => {
setDraggedTabId(tabId);
};
const handleDragEnd = () => {
const handleTabDragEnd = () => {
setDraggedTabId(null);
setDragOverCellIndex(null);
};
const handleDragOver = (e: React.DragEvent, cellIndex: number) => {
const handleTabDragOver = (e: React.DragEvent, cellIndex: number) => {
e.preventDefault();
setDragOverCellIndex(cellIndex);
};
const handleDragLeave = () => {
const handleTabDragLeave = () => {
setDragOverCellIndex(null);
};
const handleDrop = (cellIndex: number) => {
const handleTabDrop = (cellIndex: number) => {
if (draggedTabId === null) return;
setSplitAssignments((prev) => {
@@ -893,7 +1211,11 @@ export function SSHToolsSidebar({
</div>
</TabsContent>
<TabsContent value="snippets" className="space-y-4">
<TabsContent
value="snippets"
className="space-y-4 flex flex-col flex-1 overflow-hidden"
>
<div className="flex-shrink-0 space-y-4">
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
@@ -936,21 +1258,34 @@ export function SSHToolsSidebar({
</>
)}
<div className="flex gap-2">
<Button
onClick={handleCreate}
className="w-full"
className="flex-1"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
<Button
onClick={handleCreateFolder}
className="flex-1"
variant="outline"
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("snippets.newFolder", {
defaultValue: "New Folder",
})}
</Button>
</div>
</div>
{loading ? (
<div className="text-center text-muted-foreground py-8">
<div className="text-center text-muted-foreground py-8 flex-1">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
) : snippets.length === 0 && snippetFolders.length === 0 ? (
<div className="text-center text-muted-foreground py-8 flex-1">
<p className="mb-2 font-medium">
{t("snippets.empty")}
</p>
@@ -958,13 +1293,119 @@ export function SSHToolsSidebar({
</div>
) : (
<TooltipProvider>
<div className="space-y-3">
{snippets.map((snippet) => (
<div className="space-y-3 overflow-y-auto flex-1 min-h-0">
{Array.from(groupSnippetsByFolder()).map(
([folderName, folderSnippets]) => {
const folderMetadata = snippetFolders.find(
(f) => f.name === folderName,
);
const isCollapsed =
collapsedFolders.has(folderName);
return (
<div key={folderName || "uncategorized"}>
{/* Folder Header */}
<div className="flex items-center gap-2 mb-2 hover:bg-dark-hover-alt p-2 rounded-lg transition-colors group/folder">
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => toggleFolder(folderName)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
{(() => {
const FolderIcon =
getFolderIcon(folderName);
const folderColor =
getFolderColor(folderName);
return (
<FolderIcon
className="h-4 w-4"
style={{
color: folderColor || undefined,
}}
/>
);
})()}
<span
className="text-sm font-semibold"
style={{
color:
getFolderColor(folderName) ||
undefined,
}}
>
{folderName ||
t("snippets.uncategorized", {
defaultValue: "Uncategorized",
})}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{folderSnippets.length}
</span>
</div>
{folderName && (
<div className="flex items-center gap-1 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditFolder(
folderMetadata || {
id: 0,
userId: "",
name: folderName,
createdAt: "",
updatedAt: "",
},
);
}}
>
<Settings className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folderName);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
{/* Folder Content */}
{!isCollapsed && (
<div className="space-y-2 ml-6">
{folderSnippets.map((snippet) => (
<div
key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
draggable
onDragStart={(e) =>
handleDragStart(e, snippet)
}
onDragOver={(e) =>
handleDragOver(e, snippet)
}
onDrop={(e) => handleDrop(e, snippet)}
onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-move hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group ${
draggedSnippet?.id === snippet.id
? "opacity-50"
: ""
}`}
>
<div className="mb-2">
<div className="mb-2 flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white mb-1">
{snippet.name}
</h3>
@@ -977,6 +1418,7 @@ export function SSHToolsSidebar({
ID: {snippet.id}
</p>
</div>
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
@@ -991,14 +1433,18 @@ export function SSHToolsSidebar({
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
onClick={() =>
handleExecute(snippet)
}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.runTooltip")}</p>
<p>
{t("snippets.runTooltip")}
</p>
</TooltipContent>
</Tooltip>
@@ -1007,13 +1453,17 @@ export function SSHToolsSidebar({
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(snippet)}
onClick={() =>
handleCopy(snippet)
}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.copyTooltip")}</p>
<p>
{t("snippets.copyTooltip")}
</p>
</TooltipContent>
</Tooltip>
@@ -1022,13 +1472,17 @@ export function SSHToolsSidebar({
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
onClick={() =>
handleEdit(snippet)
}
>
<Edit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.editTooltip")}</p>
<p>
{t("snippets.editTooltip")}
</p>
</TooltipContent>
</Tooltip>
@@ -1037,20 +1491,30 @@ export function SSHToolsSidebar({
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
onClick={() =>
handleDelete(snippet)
}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.deleteTooltip")}</p>
<p>
{t("snippets.deleteTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
);
},
)}
</div>
</TooltipProvider>
)}
</TabsContent>
@@ -1246,8 +1710,10 @@ export function SSHToolsSidebar({
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() => handleDragStart(tab.id)}
onDragEnd={handleDragEnd}
onDragStart={() =>
handleTabDragStart(tab.id)
}
onDragEnd={handleTabDragEnd}
className={`
px-3 py-2 rounded-md text-sm cursor-move transition-all
${
@@ -1299,9 +1765,11 @@ export function SSHToolsSidebar({
return (
<div
key={idx}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(idx)}
onDragOver={(e) =>
handleTabDragOver(e, idx)
}
onDragLeave={handleTabDragLeave}
onDrop={() => handleTabDrop(idx)}
className={`
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
flex flex-col items-center justify-center transition-all
@@ -1482,6 +1950,56 @@ export function SSHToolsSidebar({
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-2">
<Folder className="h-4 w-4" />
{t("snippets.folder", { defaultValue: "Folder" })}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
</label>
<Select
value={formData.folder || "__no_folder__"}
onValueChange={(value) =>
setFormData({
...formData,
folder: value === "__no_folder__" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue
placeholder={t("snippets.selectFolder", {
defaultValue: "Select a folder or leave empty",
})}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__no_folder__">
{t("snippets.noFolder", {
defaultValue: "No folder (Uncategorized)",
})}
</SelectItem>
{snippetFolders.map((folder) => {
const FolderIcon = getFolderIcon(folder.name);
return (
<SelectItem key={folder.id} value={folder.name}>
<div className="flex items-center gap-2">
<FolderIcon
className="h-4 w-4"
style={{
color: folder.color || undefined,
}}
/>
<span>{folder.name}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.content")}
@@ -1521,6 +2039,167 @@ export function SSHToolsSidebar({
</div>
</div>
)}
{showFolderDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowFolderDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
{editingFolder
? t("snippets.editFolder", { defaultValue: "Edit Folder" })
: t("snippets.createFolder", {
defaultValue: "Create Folder",
})}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingFolder
? t("snippets.editFolderDescription", {
defaultValue: "Customize your snippet folder",
})
: t("snippets.createFolderDescription", {
defaultValue: "Organize your snippets into folders",
})}
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.folderName", { defaultValue: "Folder Name" })}
<span className="text-destructive">*</span>
</label>
<Input
value={folderFormData.name}
onChange={(e) =>
setFolderFormData({
...folderFormData,
name: e.target.value,
})
}
placeholder={t("snippets.folderNamePlaceholder", {
defaultValue: "e.g., System Commands, Docker Scripts",
})}
className={`${folderFormErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{folderFormErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.folderNameRequired", {
defaultValue: "Folder name is required",
})}
</p>
)}
</div>
{/* Color Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderColor", { defaultValue: "Folder Color" })}
</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 ${
folderFormData.color === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
}`}
style={{ backgroundColor: color.value }}
onClick={() =>
setFolderFormData({
...folderFormData,
color: color.value,
})
}
title={color.label}
/>
))}
</div>
</div>
{/* Icon Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderIcon", { defaultValue: "Folder Icon" })}
</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 ${
folderFormData.icon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
}`}
onClick={() =>
setFolderFormData({ ...folderFormData, icon: 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-white">
{t("snippets.preview", { defaultValue: "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 === folderFormData.icon,
)?.Icon || Folder;
return (
<IconComponent
className="w-5 h-5"
style={{ color: folderFormData.color }}
/>
);
})()}
<span className="font-medium">
{folderFormData.name ||
t("snippets.folderName", { defaultValue: "Folder Name" })}
</span>
</div>
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowFolderDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleFolderSubmit} className="flex-1">
{editingFolder
? t("snippets.updateFolder", {
defaultValue: "Update Folder",
})
: t("snippets.createFolder", {
defaultValue: "Create Folder",
})}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -2874,6 +2874,82 @@ export async function executeSnippet(
}
}
export async function reorderSnippets(
snippets: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean; updated: number }> {
try {
const response = await authApi.put("/snippets/reorder", { snippets });
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
}
}
export async function getSnippetFolders(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/snippets/folders");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet folders");
}
}
export async function createSnippetFolder(folderData: {
name: string;
color?: string;
icon?: string;
}): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/snippets/folders", folderData);
return response.data;
} catch (error) {
throw handleApiError(error, "create snippet folder");
}
}
export async function updateSnippetFolderMetadata(
folderName: string,
metadata: { color?: string; icon?: string },
): Promise<Record<string, unknown>> {
try {
const response = await authApi.put(
`/snippets/folders/${encodeURIComponent(folderName)}/metadata`,
metadata,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update snippet folder metadata");
}
}
export async function renameSnippetFolder(
oldName: string,
newName: string,
): Promise<{ success: boolean; oldName: string; newName: string }> {
try {
const response = await authApi.put("/snippets/folders/rename", {
oldName,
newName,
});
return response.data;
} catch (error) {
throw handleApiError(error, "rename snippet folder");
}
}
export async function deleteSnippetFolder(
folderName: string,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/snippets/folders/${encodeURIComponent(folderName)}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "delete snippet folder");
}
}
// ============================================================================
// HOMEPAGE API
// ============================================================================