v1.9.0 #437
@@ -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")
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user