Run prettier

This commit is contained in:
LukeGus
2025-09-12 01:00:50 -05:00
parent ad05021fc5
commit 9672a3c27b
133 changed files with 30450 additions and 26428 deletions
+1 -1
View File
@@ -1 +1 @@
github: [ LukeGus ] github: [LukeGus]
+5 -4
View File
@@ -3,8 +3,7 @@ name: Bug report
about: Create a report to help Termix improve about: Create a report to help Termix improve
title: "[BUG]" title: "[BUG]"
labels: bug labels: bug
assignees: '' assignees: ""
--- ---
**Describe the bug** **Describe the bug**
@@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
@@ -24,8 +24,9 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots or console/Docker logs to help explain your problem. If applicable, add screenshots or console/Docker logs to help explain your problem.
**Environment (please complete the following information):** **Environment (please complete the following information):**
- Browser [e.g. chrome, safari]
- Version [e.g. 1.6.0] - Browser [e.g. chrome, safari]
- Version [e.g. 1.6.0]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.
+1 -2
View File
@@ -3,8 +3,7 @@ name: Feature request
about: Suggest an idea for Termix about: Suggest an idea for Termix
title: "[FEATURE]" title: "[FEATURE]"
labels: enhancement labels: enhancement
assignees: '' assignees: ""
--- ---
**Is your feature request related to a problem? Please describe.** **Is your feature request related to a problem? Please describe.**
+2 -2
View File
@@ -5,8 +5,8 @@ on:
branches: branches:
- development - development
paths-ignore: paths-ignore:
- '**.md' - "**.md"
- '.gitignore' - ".gitignore"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
+7 -8
View File
@@ -5,9 +5,9 @@ on:
branches: branches:
- development - development
paths-ignore: paths-ignore:
- '**.md' - "**.md"
- '.gitignore' - ".gitignore"
- 'docker/**' - "docker/**"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
build_type: build_type:
@@ -34,8 +34,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -79,8 +79,8 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: "20"
cache: 'npm' cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -100,4 +100,3 @@ jobs:
name: Termix-Linux-Portable name: Termix-Linux-Portable
path: Termix-Linux-Portable.zip path: Termix-Linux-Portable.zip
retention-days: 30 retention-days: 30
+3
View File
@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage
+1
View File
@@ -0,0 +1 @@
{}
+20 -20
View File
@@ -1,4 +1,4 @@
_# Contributing \_# Contributing
## Prerequisites ## Prerequisites
@@ -9,13 +9,13 @@ _# Contributing
## Installation ## Installation
1. Clone the repository: 1. Clone the repository:
```sh ```sh
git clone https://github.com/LukeGus/Termix git clone https://github.com/LukeGus/Termix
``` ```
2. Install the dependencies: 2. Install the dependencies:
```sh ```sh
npm install npm install
``` ```
## Running the development server ## Running the development server
@@ -34,18 +34,18 @@ This will start the backend and the frontend Vite server. You can access Termix
1. **Fork the repository**: Click the "Fork" button at the top right of 1. **Fork the repository**: Click the "Fork" button at the top right of
the [repository page](https://github.com/LukeGus/Termix). the [repository page](https://github.com/LukeGus/Termix).
2. **Create a new branch**: 2. **Create a new branch**:
```sh ```sh
git checkout -b feature/my-new-feature git checkout -b feature/my-new-feature
``` ```
3. **Make your changes**: Implement your feature, fix, or improvement. 3. **Make your changes**: Implement your feature, fix, or improvement.
4. **Commit your changes**: 4. **Commit your changes**:
```sh ```sh
git commit -m "Feature request my new feature" git commit -m "Feature request my new feature"
``` ```
5. **Push to your fork**: 5. **Push to your fork**:
```sh ```sh
git push origin feature/my-feature-request git push origin feature/my-feature-request
``` ```
6. **Open a pull request**: Go to the original repository and create a PR with a clear description. 6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
## 📝 Guidelines ## 📝 Guidelines
@@ -62,7 +62,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Background Colors ### Background Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|-------------------------------|-------------|-----------------------------|------------------------------------------| | ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color | | `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
@@ -74,7 +74,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Element-Specific Backgrounds ### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|--------------------|-----------------------------------------------| | ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements | | `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | | `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
@@ -83,7 +83,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Border Colors ### Border Colors
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|------------------------------|-------------|-----------------|------------------------------------------| | ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border` | `#303032` | Default borders | Standard border color | | `--color-dark-border` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
@@ -94,7 +94,7 @@ This will start the backend and the frontend Vite server. You can access Termix
### Interactive States ### Interactive States
| CSS Variable | Color Value | Usage | Description | | CSS Variable | Color Value | Usage | Description |
|--------------------------|-------------|-------------------|-----------------------------------------------| | ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects | | `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements | | `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements | | `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
-1
View File
@@ -5,7 +5,6 @@
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> <a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks) ![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release) ![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
+1 -3
View File
@@ -15,9 +15,7 @@
"!vite.config.ts", "!vite.config.ts",
"!eslint.config.js" "!eslint.config.js"
], ],
"asarUnpack": [ "asarUnpack": ["node_modules/node-fetch/**/*"],
"node_modules/node-fetch/**/*"
],
"extraMetadata": { "extraMetadata": {
"main": "electron/main.cjs" "main": "electron/main.cjs"
}, },
+286 -249
View File
@@ -1,297 +1,334 @@
const {app, BrowserWindow, shell, ipcMain} = require('electron'); const { app, BrowserWindow, shell, ipcMain } = require("electron");
const path = require('path'); const path = require("path");
const fs = require('fs'); const fs = require("fs");
let mainWindow = null; let mainWindow = null;
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
console.log('Another instance is already running, quitting...'); console.log("Another instance is already running, quitting...");
app.quit(); app.quit();
process.exit(0); process.exit(0);
} else { } else {
app.on('second-instance', (event, commandLine, workingDirectory) => { app.on("second-instance", (event, commandLine, workingDirectory) => {
console.log('Second instance detected, focusing existing window...'); console.log("Second instance detected, focusing existing window...");
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore(); if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus(); mainWindow.focus();
mainWindow.show(); mainWindow.show();
} }
}); });
} }
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
title: 'Termix', title: "Termix",
icon: isDev icon: isDev
? path.join(__dirname, '..', 'public', 'icon.png') ? path.join(__dirname, "..", "public", "icon.png")
: path.join(process.resourcesPath, 'public', 'icon.png'), : path.join(process.resourcesPath, "public", "icon.png"),
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
webSecurity: !isDev, webSecurity: !isDev,
preload: path.join(__dirname, 'preload.js') preload: path.join(__dirname, "preload.js"),
}, },
show: false show: false,
}); });
if (process.platform !== 'darwin') { if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false); mainWindow.setMenuBarVisibility(false);
}
if (isDev) {
mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, "..", "dist", "index.html");
console.log("Loading frontend from:", indexPath);
mainWindow.loadFile(indexPath);
}
mainWindow.once("ready-to-show", () => {
console.log("Window ready to show");
mainWindow.show();
});
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription, validatedURL) => {
console.error(
"Failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
mainWindow.webContents.on("did-finish-load", () => {
console.log("Frontend loaded successfully");
});
mainWindow.on("close", (event) => {
if (process.platform === "darwin") {
event.preventDefault();
mainWindow.hide();
} }
});
if (isDev) { mainWindow.on("closed", () => {
mainWindow.loadURL('http://localhost:5173'); mainWindow = null;
mainWindow.webContents.openDevTools(); });
} else {
const indexPath = path.join(__dirname, '..', 'dist', 'index.html');
console.log('Loading frontend from:', indexPath);
mainWindow.loadFile(indexPath);
}
mainWindow.once('ready-to-show', () => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
console.log('Window ready to show'); shell.openExternal(url);
mainWindow.show(); return { action: "deny" };
}); });
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
console.error('Failed to load:', errorCode, errorDescription, validatedURL);
});
mainWindow.webContents.on('did-finish-load', () => {
console.log('Frontend loaded successfully');
});
mainWindow.on('close', (event) => {
if (process.platform === 'darwin') {
event.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({url}) => {
shell.openExternal(url);
return {action: 'deny'};
});
} }
ipcMain.handle('get-app-version', () => { ipcMain.handle("get-app-version", () => {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle('get-platform', () => { ipcMain.handle("get-platform", () => {
return process.platform; return process.platform;
}); });
ipcMain.handle('get-server-config', () => { ipcMain.handle("get-server-config", () => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, "server-config.json");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, 'utf8'); const configData = fs.readFileSync(configPath, "utf8");
return JSON.parse(configData); return JSON.parse(configData);
}
return null;
} catch (error) {
console.error('Error reading server config:', error);
return null;
} }
return null;
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
}); });
ipcMain.handle('save-server-config', (event, config) => { ipcMain.handle("save-server-config", (event, config) => {
try { try {
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, 'server-config.json'); const configPath = path.join(userDataPath, "server-config.json");
if (!fs.existsSync(userDataPath)) { if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, {recursive: true}); fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return {success: true};
} catch (error) {
console.error('Error saving server config:', error);
return {success: false, error: error.message};
} }
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
}); });
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
ipcMain.handle('test-server-connection', async (event, serverUrl) => { try {
let fetch;
try { try {
let fetch; fetch = globalThis.fetch || require("node:fetch");
try { } catch (e) {
fetch = globalThis.fetch || require('node:fetch'); const https = require("https");
} catch (e) { const http = require("http");
const https = require('https'); const { URL } = require("url");
const http = require('http');
const {URL} = require('url');
fetch = (url, options = {}) => { fetch = (url, options = {}) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const urlObj = new URL(url); const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:'; const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http; const client = isHttps ? https : http;
const req = client.request(url, { const req = client.request(
method: options.method || 'GET', url,
headers: options.headers || {}, {
timeout: options.timeout || 5000 method: options.method || "GET",
}, (res) => { headers: options.headers || {},
let data = ''; timeout: options.timeout || 5000,
res.on('data', chunk => data += chunk); },
res.on('end', () => { (res) => {
resolve({ let data = "";
ok: res.statusCode >= 200 && res.statusCode < 300, res.on("data", (chunk) => (data += chunk));
status: res.statusCode, res.on("end", () => {
text: () => Promise.resolve(data), resolve({
json: () => Promise.resolve(JSON.parse(data)) ok: res.statusCode >= 200 && res.statusCode < 300,
}); status: res.statusCode,
}); text: () => Promise.resolve(data),
}); json: () => Promise.resolve(JSON.parse(data)),
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (options.body) {
req.write(options.body);
}
req.end();
}); });
}; });
} },
);
const normalizedServerUrl = serverUrl.replace(/\/$/, ''); req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
const healthUrl = `${normalizedServerUrl}/health`; if (options.body) {
req.write(options.body);
try { }
const response = await fetch(healthUrl, { req.end();
method: 'GET', });
timeout: 5000 };
});
if (response.ok) {
const data = await response.text();
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) {
console.log('Health endpoint returned HTML instead of JSON - not a Termix server');
return {
success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.'
};
}
try {
const healthData = JSON.parse(data);
if (healthData && (
healthData.status === 'ok' ||
healthData.status === 'healthy' ||
healthData.healthy === true ||
healthData.database === 'connected'
)) {
return {success: true, status: response.status, testedUrl: healthUrl};
}
} catch (parseError) {
console.log('Health endpoint did not return valid JSON');
}
}
} catch (urlError) {
console.error('Health check failed:', urlError);
}
try {
const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, {
method: 'GET',
timeout: 5000
});
if (response.ok) {
const data = await response.text();
if (data.includes('<html') || data.includes('<!DOCTYPE') || data.includes('<head>') || data.includes('<body>')) {
console.log('Version endpoint returned HTML instead of JSON - not a Termix server');
return {
success: false,
error: 'Server returned HTML instead of JSON. This does not appear to be a Termix server.'
};
}
try {
const versionData = JSON.parse(data);
if (versionData && (
versionData.status === 'up_to_date' ||
versionData.status === 'requires_update' ||
(versionData.localVersion && versionData.version && versionData.latest_release)
)) {
return {
success: true,
status: response.status,
testedUrl: versionUrl,
warning: 'Health endpoint not available, but server appears to be running'
};
}
} catch (parseError) {
console.log('Version endpoint did not return valid JSON');
}
}
} catch (versionError) {
console.error('Version check failed:', versionError);
}
return {
success: false,
error: 'Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.'
};
} catch (error) {
return {success: false, error: error.message};
} }
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
try {
const response = await fetch(healthUrl, {
method: "GET",
timeout: 5000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Health endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
};
}
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
} catch (urlError) {
console.error("Health check failed:", urlError);
}
try {
const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, {
method: "GET",
timeout: 5000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Version endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
};
}
try {
const versionData = JSON.parse(data);
if (
versionData &&
(versionData.status === "up_to_date" ||
versionData.status === "requires_update" ||
(versionData.localVersion &&
versionData.version &&
versionData.latest_release))
) {
return {
success: true,
status: response.status,
testedUrl: versionUrl,
warning:
"Health endpoint not available, but server appears to be running",
};
}
} catch (parseError) {
console.log("Version endpoint did not return valid JSON");
}
}
} catch (versionError) {
console.error("Version check failed:", versionError);
}
return {
success: false,
error:
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
};
} catch (error) {
return { success: false, error: error.message };
}
}); });
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow();
console.log("Termix started successfully");
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
console.log('Termix started successfully'); } else if (mainWindow) {
mainWindow.show();
}
}); });
app.on('window-all-closed', () => { app.on("before-quit", () => {
if (process.platform !== 'darwin') { console.log("App is quitting...");
app.quit();
}
}); });
app.on('activate', () => { app.on("will-quit", () => {
if (BrowserWindow.getAllWindows().length === 0) { console.log("App will quit...");
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
}); });
app.on('before-quit', () => { process.on("uncaughtException", (error) => {
console.log('App is quitting...'); console.error("Uncaught Exception:", error);
}); });
app.on('will-quit', () => { process.on("unhandledRejection", (reason, promise) => {
console.log('App will quit...'); console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
}); });
+19 -17
View File
@@ -1,27 +1,29 @@
const {contextBridge, ipcRenderer} = require('electron'); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke('get-platform'), getPlatform: () => ipcRenderer.invoke("get-platform"),
getServerConfig: () => ipcRenderer.invoke('get-server-config'), getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), saveServerConfig: (config) =>
testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), ipcRenderer.invoke("save-server-config", config),
testServerConnection: (serverUrl) =>
ipcRenderer.invoke("test-server-connection", serverUrl),
showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), onUpdateDownloaded: (callback) =>
ipcRenderer.on("update-downloaded", callback),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true, isElectron: true,
isDev: process.env.NODE_ENV === 'development', isDev: process.env.NODE_ENV === "development",
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
}); });
window.IS_ELECTRON = true; window.IS_ELECTRON = true;
console.log('electronAPI exposed to window'); console.log("electronAPI exposed to window");
+10 -10
View File
@@ -1,18 +1,18 @@
import js from '@eslint/js' import js from "@eslint/js";
import globals from 'globals' import globals from "globals";
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from 'typescript-eslint' import tseslint from "typescript-eslint";
import { globalIgnores } from 'eslint/config' import { globalIgnores } from "eslint/config";
export default tseslint.config([ export default tseslint.config([
globalIgnores(['dist']), globalIgnores(["dist"]),
{ {
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs['recommended-latest'], reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
languageOptions: { languageOptions: {
@@ -20,4 +20,4 @@ export default tseslint.config([
globals: globals.browser, globals: globals.browser,
}, },
}, },
]) ]);
+2054 -2011
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -102,6 +102,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "~5.9.2", "typescript": "~5.9.2",
@@ -13330,6 +13331,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proc-log": { "node_modules/proc-log": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
+2
View File
@@ -7,6 +7,7 @@
"main": "electron/main.cjs", "main": "electron/main.cjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
"clean": "npx prettier . --write",
"dev": "vite", "dev": "vite",
"build": "vite build && tsc -p tsconfig.node.json", "build": "vite build && tsc -p tsconfig.node.json",
"build:backend": "tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json",
@@ -114,6 +115,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "~5.9.2", "typescript": "~5.9.2",
+249 -211
View File
@@ -1,252 +1,290 @@
import express from 'express'; import express from "express";
import bodyParser from 'body-parser'; import bodyParser from "body-parser";
import userRoutes from './routes/users.js'; import userRoutes from "./routes/users.js";
import sshRoutes from './routes/ssh.js'; import sshRoutes from "./routes/ssh.js";
import alertRoutes from './routes/alerts.js'; import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from './routes/credentials.js'; import credentialsRoutes from "./routes/credentials.js";
import cors from 'cors'; import cors from "cors";
import fetch from 'node-fetch'; import fetch from "node-fetch";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import 'dotenv/config'; import "dotenv/config";
import {databaseLogger, apiLogger} from '../utils/logger.js'; import { databaseLogger, apiLogger } from "../utils/logger.js";
const app = express(); const app = express();
app.use(cors({ app.use(
origin: '*', cors({
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], origin: "*",
allowedHeaders: ['Content-Type', 'Authorization'] methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})); allowedHeaders: ["Content-Type", "Authorization"],
}),
);
interface CacheEntry { interface CacheEntry {
data: any; data: any;
timestamp: number; timestamp: number;
expiresAt: number; expiresAt: number;
} }
class GitHubCache { class GitHubCache {
private cache: Map<string, CacheEntry> = new Map(); private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000; private readonly CACHE_DURATION = 30 * 60 * 1000;
set(key: string, data: any): void { set(key: string, data: any): void {
const now = Date.now(); const now = Date.now();
this.cache.set(key, { this.cache.set(key, {
data, data,
timestamp: now, timestamp: now,
expiresAt: now + this.CACHE_DURATION expiresAt: now + this.CACHE_DURATION,
}); });
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
} }
get(key: string): any | null { if (Date.now() > entry.expiresAt) {
const entry = this.cache.get(key); this.cache.delete(key);
if (!entry) { return null;
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
} }
return entry.data;
}
} }
const githubCache = new GitHubCache(); const githubCache = new GitHubCache();
const GITHUB_API_BASE = 'https://api.github.com'; const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = 'LukeGus'; const REPO_OWNER = "LukeGus";
const REPO_NAME = 'Termix'; const REPO_NAME = "Termix";
interface GitHubRelease { interface GitHubRelease {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
html_url: string;
assets: Array<{
id: number; id: number;
tag_name: string;
name: string; name: string;
body: string; size: number;
published_at: string; download_count: number;
html_url: string; browser_download_url: string;
assets: Array<{ }>;
id: number; prerelease: boolean;
name: string; draft: boolean;
size: number;
download_count: number;
browser_download_url: string;
}>;
prerelease: boolean;
draft: boolean;
} }
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> { async function fetchGitHubAPI(
const cachedData = githubCache.get(cacheKey); endpoint: string,
if (cachedData) { cacheKey: string,
return { ): Promise<any> {
data: cachedData, const cachedData = githubCache.get(cacheKey);
cached: true, if (cachedData) {
cache_age: Date.now() - cachedData.timestamp return {
}; data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp,
};
}
try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
} }
try { const data = await response.json();
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { githubCache.set(cacheKey, data);
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'TermixUpdateChecker/1.0',
'X-GitHub-Api-Version': '2022-11-28'
}
});
if (!response.ok) { return {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); data: data,
} cached: false,
};
const data = await response.json(); } catch (error) {
githubCache.set(cacheKey, data); databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
return { endpoint,
data: data, });
cached: false throw error;
}; }
} catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {operation: 'github_api', endpoint});
throw error;
}
} }
app.use(bodyParser.json()); app.use(bodyParser.json());
app.get('/health', (req, res) => { app.get("/health", (req, res) => {
res.json({status: 'ok'}); res.json({ status: "ok" });
}); });
app.get('/version', async (req, res) => { app.get("/version", async (req, res) => {
let localVersion = process.env.VERSION; let localVersion = process.env.VERSION;
if (!localVersion) {
try {
const packagePath = path.resolve(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
localVersion = packageJson.version;
} catch (error) {
databaseLogger.error('Failed to read version from package.json', error, {operation: 'version_check'});
}
}
if (!localVersion) {
databaseLogger.error('No version information available', undefined, {operation: 'version_check'});
return res.status(404).send('Local Version Not Set');
}
if (!localVersion) {
try { try {
const cacheKey = 'latest_release'; const packagePath = path.resolve(process.cwd(), "package.json");
const releaseData = await fetchGitHubAPI( const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, localVersion = packageJson.version;
cacheKey
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
databaseLogger.warn('Remote version not found in GitHub response', {operation: 'version_check', rawTag});
return res.status(401).send('Remote Version Not Found');
}
const isUpToDate = localVersion === remoteVersion;
const response = {
status: isUpToDate ? 'up_to_date' : 'requires_update',
localVersion: localVersion,
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url
},
cached: releaseData.cached,
cache_age: releaseData.cache_age
};
res.json(response);
} catch (err) {
databaseLogger.error('Version check failed', err, {operation: 'version_check'});
res.status(500).send('Fetch Error');
}
});
app.get('/releases/rss', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map(asset => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url
}))
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString()
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age
};
res.json(response);
} catch (error) { } catch (error) {
databaseLogger.error('Failed to generate RSS format', error, {operation: 'rss_releases'}); databaseLogger.error("Failed to read version from package.json", error, {
res.status(500).json({ operation: "version_check",
error: 'Failed to generate RSS format', });
details: error instanceof Error ? error.message : 'Unknown error'
});
} }
}); }
if (!localVersion) {
app.use('/users', userRoutes); databaseLogger.error("No version information available", undefined, {
app.use('/ssh', sshRoutes); operation: "version_check",
app.use('/alerts', alertRoutes);
app.use('/credentials', credentialsRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
apiLogger.error('Unhandled error in request', err, {
operation: 'error_handler',
method: req.method,
url: req.url,
userAgent: req.get('User-Agent')
}); });
res.status(500).json({error: 'Internal Server Error'}); return res.status(404).send("Local Version Not Set");
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
});
return res.status(401).send("Remote Version Not Found");
}
const isUpToDate = localVersion === remoteVersion;
const response = {
status: isUpToDate ? "up_to_date" : "requires_update",
localVersion: localVersion,
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
});
res.status(500).send("Fetch Error");
}
}); });
app.get("/releases/rss", async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(
parseInt(req.query.per_page as string) || 20,
100,
);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey,
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map((asset) => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url,
})),
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString(),
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age,
};
res.json(response);
} catch (error) {
databaseLogger.error("Failed to generate RSS format", error, {
operation: "rss_releases",
});
res.status(500).json({
error: "Failed to generate RSS format",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use(
(
err: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
method: req.method,
url: req.url,
userAgent: req.get("User-Agent"),
});
res.status(500).json({ error: "Internal Server Error" });
},
);
const PORT = 8081; const PORT = 8081;
app.listen(PORT, () => { app.listen(PORT, () => {
databaseLogger.success(`Database API server started on port ${PORT}`, { databaseLogger.success(`Database API server started on port ${PORT}`, {
operation: 'server_start', operation: "server_start",
port: PORT, port: PORT,
routes: ['/users', '/ssh', '/alerts', '/credentials', '/health', '/version', '/releases/rss'] routes: [
}); "/users",
"/ssh",
"/alerts",
"/credentials",
"/health",
"/version",
"/releases/rss",
],
});
}); });
+155 -75
View File
@@ -1,19 +1,25 @@
import {drizzle} from 'drizzle-orm/better-sqlite3'; import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from 'better-sqlite3'; import Database from "better-sqlite3";
import * as schema from './schema.js'; import * as schema from "./schema.js";
import fs from 'fs'; import fs from "fs";
import path from 'path'; import path from "path";
import { databaseLogger } from '../../utils/logger.js'; import { databaseLogger } from "../../utils/logger.js";
const dataDir = process.env.DATA_DIR || './db/data'; const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir); const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) { if (!fs.existsSync(dbDir)) {
databaseLogger.info(`Creating database directory`, { operation: 'db_init', path: dbDir }); databaseLogger.info(`Creating database directory`, {
fs.mkdirSync(dbDir, {recursive: true}); operation: "db_init",
path: dbDir,
});
fs.mkdirSync(dbDir, { recursive: true });
} }
const dbPath = path.join(dataDir, 'db.sqlite'); const dbPath = path.join(dataDir, "db.sqlite");
databaseLogger.info(`Initializing SQLite database`, { operation: 'db_init', path: dbPath }); databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: dbPath,
});
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
sqlite.exec(` sqlite.exec(`
@@ -137,90 +143,164 @@ sqlite.exec(`
); );
`); `);
const addColumnIfNotExists = (table: string, column: string, definition: string) => { const addColumnIfNotExists = (
table: string,
column: string,
definition: string,
) => {
try {
sqlite
.prepare(
`SELECT ${column}
FROM ${table} LIMIT 1`,
)
.get();
} catch (e) {
try { try {
sqlite.prepare(`SELECT ${column} databaseLogger.debug(`Adding column ${column} to ${table}`, {
FROM ${table} LIMIT 1`).get(); operation: "schema_migration",
} catch (e) { table,
try { column,
databaseLogger.debug(`Adding column ${column} to ${table}`, { operation: 'schema_migration', table, column }); });
sqlite.exec(`ALTER TABLE ${table} sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`); ADD COLUMN ${column} ${definition};`);
databaseLogger.success(`Column ${column} added to ${table}`, { operation: 'schema_migration', table, column }); databaseLogger.success(`Column ${column} added to ${table}`, {
} catch (alterError) { operation: "schema_migration",
databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: 'schema_migration', table, column, error: alterError }); table,
} column,
});
} catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
error: alterError,
});
} }
}
}; };
const migrateSchema = () => { const migrateSchema = () => {
databaseLogger.info('Checking for schema updates...', { operation: 'schema_migration' }); databaseLogger.info("Checking for schema updates...", {
operation: "schema_migration",
});
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT'); addColumnIfNotExists("users", "oidc_identifier", "TEXT");
addColumnIfNotExists('users', 'client_id', 'TEXT'); addColumnIfNotExists("users", "client_id", "TEXT");
addColumnIfNotExists('users', 'client_secret', 'TEXT'); addColumnIfNotExists("users", "client_secret", "TEXT");
addColumnIfNotExists('users', 'issuer_url', 'TEXT'); addColumnIfNotExists("users", "issuer_url", "TEXT");
addColumnIfNotExists('users', 'authorization_url', 'TEXT'); addColumnIfNotExists("users", "authorization_url", "TEXT");
addColumnIfNotExists('users', 'token_url', 'TEXT'); addColumnIfNotExists("users", "token_url", "TEXT");
addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists("users", "identifier_path", "TEXT");
addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists("users", "name_path", "TEXT");
addColumnIfNotExists('users', 'scopes', 'TEXT'); addColumnIfNotExists("users", "scopes", "TEXT");
addColumnIfNotExists('users', 'totp_secret', 'TEXT'); addColumnIfNotExists("users", "totp_secret", "TEXT");
addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT'); addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
addColumnIfNotExists('ssh_data', 'name', 'TEXT'); addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); addColumnIfNotExists("ssh_data", "folder", "TEXT");
addColumnIfNotExists('ssh_data', 'tags', 'TEXT'); addColumnIfNotExists("ssh_data", "tags", "TEXT");
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"'); addColumnIfNotExists(
addColumnIfNotExists('ssh_data', 'password', 'TEXT'); "ssh_data",
addColumnIfNotExists('ssh_data', 'key', 'TEXT'); "auth_type",
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT'); 'TEXT NOT NULL DEFAULT "password"',
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT'); );
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "password", "TEXT");
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "key", "TEXT");
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT'); addColumnIfNotExists("ssh_data", "key_password", "TEXT");
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1'); addColumnIfNotExists("ssh_data", "key_type", "TEXT");
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT'); addColumnIfNotExists(
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); "ssh_data",
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP'); "enable_terminal",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_tunnel",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_file_manager",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
addColumnIfNotExists(
"ssh_data",
"created_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists(
"ssh_data",
"updated_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)'); addColumnIfNotExists(
"ssh_data",
"credential_id",
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL'); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
databaseLogger.success('Schema migration completed', { operation: 'schema_migration' }); databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
}; };
const initializeDatabase = async () => { const initializeDatabase = async () => {
migrateSchema(); migrateSchema();
try { try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get(); const row = sqlite
if (!row) { .prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
databaseLogger.info('Initializing default settings', { operation: 'db_init', setting: 'allow_registration' }); .get();
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run(); if (!row) {
databaseLogger.success('Default settings initialized', { operation: 'db_init' }); databaseLogger.info("Initializing default settings", {
} else { operation: "db_init",
databaseLogger.debug('Default settings already exist', { operation: 'db_init' }); setting: "allow_registration",
} });
} catch (e) { sqlite
databaseLogger.warn('Could not initialize default settings', { operation: 'db_init', error: e }); .prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
databaseLogger.success("Default settings initialized", {
operation: "db_init",
});
} else {
databaseLogger.debug("Default settings already exist", {
operation: "db_init",
});
} }
} catch (e) {
databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
error: e,
});
}
}; };
initializeDatabase().catch(error => { initializeDatabase().catch((error) => {
databaseLogger.error('Failed to initialize database', error, { operation: 'db_init' }); databaseLogger.error("Failed to initialize database", error, {
process.exit(1); operation: "db_init",
});
process.exit(1);
}); });
databaseLogger.success('Database connection established', { operation: 'db_init', path: dbPath }); databaseLogger.success("Database connection established", {
export const db = drizzle(sqlite, {schema}); operation: "db_init",
path: dbPath,
});
export const db = drizzle(sqlite, { schema });
+145 -95
View File
@@ -1,117 +1,167 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import {sql} from 'drizzle-orm'; import { sql } from "drizzle-orm";
export const users = sqliteTable('users', { export const users = sqliteTable("users", {
id: text('id').primaryKey(), id: text("id").primaryKey(),
username: text('username').notNull(), username: text("username").notNull(),
password_hash: text('password_hash').notNull(), password_hash: text("password_hash").notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false), is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false), is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidc_identifier: text('oidc_identifier'), oidc_identifier: text("oidc_identifier"),
client_id: text('client_id'), client_id: text("client_id"),
client_secret: text('client_secret'), client_secret: text("client_secret"),
issuer_url: text('issuer_url'), issuer_url: text("issuer_url"),
authorization_url: text('authorization_url'), authorization_url: text("authorization_url"),
token_url: text('token_url'), token_url: text("token_url"),
identifier_path: text('identifier_path'), identifier_path: text("identifier_path"),
name_path: text('name_path'), name_path: text("name_path"),
scopes: text().default("openid email profile"), scopes: text().default("openid email profile"),
totp_secret: text('totp_secret'), totp_secret: text("totp_secret"),
totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), totp_enabled: integer("totp_enabled", { mode: "boolean" })
totp_backup_codes: text('totp_backup_codes'), .notNull()
.default(false),
totp_backup_codes: text("totp_backup_codes"),
}); });
export const settings = sqliteTable('settings', { export const settings = sqliteTable("settings", {
key: text('key').primaryKey(), key: text("key").primaryKey(),
value: text('value').notNull(), value: text("value").notNull(),
}); });
export const sshData = sqliteTable('ssh_data', { export const sshData = sqliteTable("ssh_data", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
name: text('name'), .notNull()
ip: text('ip').notNull(), .references(() => users.id),
port: integer('port').notNull(), name: text("name"),
username: text('username').notNull(), ip: text("ip").notNull(),
folder: text('folder'), port: integer("port").notNull(),
tags: text('tags'), username: text("username").notNull(),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false), folder: text("folder"),
authType: text('auth_type').notNull(), tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
password: text('password'), password: text("password"),
key: text('key', {length: 8192}), key: text("key", { length: 8192 }),
keyPassword: text('key_password'), keyPassword: text("key_password"),
keyType: text('key_type'), keyType: text("key_type"),
credentialId: integer('credential_id').references(() => sshCredentials.id), credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true), enableTerminal: integer("enable_terminal", { mode: "boolean" })
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true), .notNull()
tunnelConnections: text('tunnel_connections'), .default(true),
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true), enableTunnel: integer("enable_tunnel", { mode: "boolean" })
defaultPath: text('default_path'), .notNull()
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), .default(true),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), tunnelConnections: text("tunnel_connections"),
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
defaultPath: text("default_path"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerRecent = sqliteTable('file_manager_recent', { export const fileManagerRecent = sqliteTable("file_manager_recent", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerPinned = sqliteTable('file_manager_pinned', { export const fileManagerPinned = sqliteTable("file_manager_pinned", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', { export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
name: text('name').notNull(), .references(() => users.id),
path: text('path').notNull(), hostId: integer("host_id")
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), .notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const dismissedAlerts = sqliteTable('dismissed_alerts', { export const dismissedAlerts = sqliteTable("dismissed_alerts", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
alertId: text('alert_id').notNull(), .notNull()
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), .references(() => users.id),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const sshCredentials = sqliteTable('ssh_credentials', { export const sshCredentials = sqliteTable("ssh_credentials", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text('user_id').notNull().references(() => users.id), userId: text("user_id")
name: text('name').notNull(), .notNull()
description: text('description'), .references(() => users.id),
folder: text('folder'), name: text("name").notNull(),
tags: text('tags'), description: text("description"),
authType: text('auth_type').notNull(), folder: text("folder"),
username: text('username').notNull(), tags: text("tags"),
password: text('password'), authType: text("auth_type").notNull(),
key: text('key', {length: 16384}), username: text("username").notNull(),
keyPassword: text('key_password'), password: text("password"),
keyType: text('key_type'), key: text("key", { length: 16384 }),
usageCount: integer('usage_count').notNull().default(0), keyPassword: text("key_password"),
lastUsed: text('last_used'), keyType: text("key_type"),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), usageCount: integer("usage_count").notNull().default(0),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), lastUsed: text("last_used"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', { export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
id: integer('id').primaryKey({autoIncrement: true}), id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id), credentialId: integer("credential_id")
hostId: integer('host_id').notNull().references(() => sshData.id), .notNull()
userId: text('user_id').notNull().references(() => users.id), .references(() => sshCredentials.id),
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`), hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
usedAt: text("used_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
+205 -192
View File
@@ -1,248 +1,261 @@
import express from 'express'; import express from "express";
import {db} from '../db/index.js'; import { db } from "../db/index.js";
import {dismissedAlerts} from '../db/schema.js'; import { dismissedAlerts } from "../db/schema.js";
import {eq, and} from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
import fetch from 'node-fetch'; import fetch from "node-fetch";
import {authLogger} from '../../utils/logger.js'; import { authLogger } from "../../utils/logger.js";
interface CacheEntry { interface CacheEntry {
data: any; data: any;
timestamp: number; timestamp: number;
expiresAt: number; expiresAt: number;
} }
class AlertCache { class AlertCache {
private cache: Map<string, CacheEntry> = new Map(); private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000; private readonly CACHE_DURATION = 5 * 60 * 1000;
set(key: string, data: any): void { set(key: string, data: any): void {
const now = Date.now(); const now = Date.now();
this.cache.set(key, { this.cache.set(key, {
data, data,
timestamp: now, timestamp: now,
expiresAt: now + this.CACHE_DURATION expiresAt: now + this.CACHE_DURATION,
}); });
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
} }
get(key: string): any | null { if (Date.now() > entry.expiresAt) {
const entry = this.cache.get(key); this.cache.delete(key);
if (!entry) { return null;
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
} }
return entry.data;
}
} }
const alertCache = new AlertCache(); const alertCache = new AlertCache();
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = 'LukeGus'; const REPO_OWNER = "LukeGus";
const REPO_NAME = 'Termix-Docs'; const REPO_NAME = "Termix-Docs";
const ALERTS_FILE = 'main/termix-alerts.json'; const ALERTS_FILE = "main/termix-alerts.json";
interface TermixAlert { interface TermixAlert {
id: string; id: string;
title: string; title: string;
message: string; message: string;
expiresAt: string; expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical'; priority?: "low" | "medium" | "high" | "critical";
type?: 'info' | 'warning' | 'error' | 'success'; type?: "info" | "warning" | "error" | "success";
actionUrl?: string; actionUrl?: string;
actionText?: string; actionText?: string;
} }
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> { async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
const cacheKey = 'termix_alerts'; const cacheKey = "termix_alerts";
const cachedData = alertCache.get(cacheKey); const cachedData = alertCache.get(cacheKey);
if (cachedData) { if (cachedData) {
return cachedData; return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
});
if (!response.ok) {
authLogger.warn("GitHub API returned error status", {
operation: "alerts_fetch",
status: response.status,
statusText: response.statusText,
});
throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
} }
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, { const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
headers: {
'Accept': 'application/json',
'User-Agent': 'TermixAlertChecker/1.0'
}
});
if (!response.ok) { const now = new Date();
authLogger.warn('GitHub API returned error status', {
operation: 'alerts_fetch',
status: response.status,
statusText: response.statusText
});
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
}
const alerts: TermixAlert[] = await response.json() as TermixAlert[]; const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
const now = new Date(); alertCache.set(cacheKey, validAlerts);
return validAlerts;
const validAlerts = alerts.filter(alert => { } catch (error) {
const expiryDate = new Date(alert.expiresAt); authLogger.error("Failed to fetch alerts from GitHub", {
const isValid = expiryDate > now; operation: "alerts_fetch",
return isValid; error: error instanceof Error ? error.message : "Unknown error",
}); });
return [];
alertCache.set(cacheKey, validAlerts); }
return validAlerts;
} catch (error) {
authLogger.error('Failed to fetch alerts from GitHub', {
operation: 'alerts_fetch',
error: error instanceof Error ? error.message : 'Unknown error'
});
return [];
}
} }
const router = express.Router(); const router = express.Router();
// Route: Get all active alerts // Route: Get all active alerts
// GET /alerts // GET /alerts
router.get('/', async (req, res) => { router.get("/", async (req, res) => {
try { try {
const alerts = await fetchAlertsFromGitHub(); const alerts = await fetchAlertsFromGitHub();
res.json({ res.json({
alerts, alerts,
cached: alertCache.get('termix_alerts') !== null, cached: alertCache.get("termix_alerts") !== null,
total_count: alerts.length total_count: alerts.length,
}); });
} catch (error) { } catch (error) {
authLogger.error('Failed to get alerts', error); authLogger.error("Failed to get alerts", error);
res.status(500).json({error: 'Failed to fetch alerts'}); res.status(500).json({ error: "Failed to fetch alerts" });
} }
}); });
// Route: Get alerts for a specific user (excluding dismissed ones) // Route: Get alerts for a specific user (excluding dismissed ones)
// GET /alerts/user/:userId // GET /alerts/user/:userId
router.get('/user/:userId', async (req, res) => { router.get("/user/:userId", async (req, res) => {
try { try {
const {userId} = req.params; const { userId } = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({ error: "User ID is required" });
}
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({alertId: dismissedAlerts.alertId})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size
});
} catch (error) {
authLogger.error('Failed to get user alerts', error);
res.status(500).json({error: 'Failed to fetch user alerts'});
} }
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({ alertId: dismissedAlerts.alertId })
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(
dismissedAlertRecords.map((record) => record.alertId),
);
const userAlerts = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch user alerts" });
}
}); });
// Route: Dismiss an alert for a user // Route: Dismiss an alert for a user
// POST /alerts/dismiss // POST /alerts/dismiss
router.post('/dismiss', async (req, res) => { router.post("/dismiss", async (req, res) => {
try { try {
const {userId, alertId} = req.body; const { userId, alertId } = req.body;
if (!userId || !alertId) { if (!userId || !alertId) {
authLogger.warn('Missing userId or alertId in dismiss request'); authLogger.warn("Missing userId or alertId in dismiss request");
return res.status(400).json({error: 'User ID and Alert ID are required'}); return res
} .status(400)
.json({ error: "User ID and Alert ID are required" });
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (existingDismissal.length > 0) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({error: 'Alert already dismissed'});
}
const result = await db.insert(dismissedAlerts).values({
userId,
alertId
});
res.json({message: 'Alert dismissed successfully'});
} catch (error) {
authLogger.error('Failed to dismiss alert', error);
res.status(500).json({error: 'Failed to dismiss alert'});
} }
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (existingDismissal.length > 0) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
const result = await db.insert(dismissedAlerts).values({
userId,
alertId,
});
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
authLogger.error("Failed to dismiss alert", error);
res.status(500).json({ error: "Failed to dismiss alert" });
}
}); });
// Route: Get dismissed alerts for a user // Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId // GET /alerts/dismissed/:userId
router.get('/dismissed/:userId', async (req, res) => { router.get("/dismissed/:userId", async (req, res) => {
try { try {
const {userId} = req.params; const { userId } = req.params;
if (!userId) { if (!userId) {
return res.status(400).json({error: 'User ID is required'}); return res.status(400).json({ error: "User ID is required" });
}
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length
});
} catch (error) {
authLogger.error('Failed to get dismissed alerts', error);
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
} }
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt,
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length,
});
} catch (error) {
authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
}
}); });
// Route: Undismiss an alert for a user (remove from dismissed list) // Route: Undismiss an alert for a user (remove from dismissed list)
// DELETE /alerts/dismiss // DELETE /alerts/dismiss
router.delete('/dismiss', async (req, res) => { router.delete("/dismiss", async (req, res) => {
try { try {
const {userId, alertId} = req.body; const { userId, alertId } = req.body;
if (!userId || !alertId) { if (!userId || !alertId) {
return res.status(400).json({error: 'User ID and Alert ID are required'}); return res
} .status(400)
.json({ error: "User ID and Alert ID are required" });
const result = await db
.delete(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (result.changes === 0) {
return res.status(404).json({error: 'Dismissed alert not found'});
}
res.json({message: 'Alert undismissed successfully'});
} catch (error) {
authLogger.error('Failed to undismiss alert', error);
res.status(500).json({error: 'Failed to undismiss alert'});
} }
const result = await db
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (result.changes === 0) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
} catch (error) {
authLogger.error("Failed to undismiss alert", error);
res.status(500).json({ error: "Failed to undismiss alert" });
}
}); });
export default router; export default router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+495 -396
View File
@@ -1,399 +1,498 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws'; import { WebSocketServer, WebSocket, type RawData } from "ws";
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2'; import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import {db} from '../database/db/index.js'; import { db } from "../database/db/index.js";
import {sshCredentials} from '../database/db/schema.js'; import { sshCredentials } from "../database/db/schema.js";
import {eq, and} from 'drizzle-orm'; import { eq, and } from "drizzle-orm";
import {sshLogger} from '../utils/logger.js'; import { sshLogger } from "../utils/logger.js";
const wss = new WebSocketServer({port: 8082}); const wss = new WebSocketServer({ port: 8082 });
sshLogger.success('SSH Terminal WebSocket server started', {operation: 'server_start', port: 8082}); sshLogger.success("SSH Terminal WebSocket server started", {
operation: "server_start",
wss.on('connection', (ws: WebSocket) => { port: 8082,
let sshConn: Client | null = null; });
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null; wss.on("connection", (ws: WebSocket) => {
let sshConn: Client | null = null;
ws.on('close', () => { let sshStream: ClientChannel | null = null;
cleanupSSH(); let pingInterval: NodeJS.Timeout | null = null;
});
ws.on("close", () => {
ws.on('message', (msg: RawData) => { cleanupSSH();
let parsed: any; });
try {
parsed = JSON.parse(msg.toString()); ws.on("message", (msg: RawData) => {
} catch (e) { let parsed: any;
sshLogger.error('Invalid JSON received', e, { try {
operation: 'websocket_message', parsed = JSON.parse(msg.toString());
messageLength: msg.toString().length } catch (e) {
}); sshLogger.error("Invalid JSON received", e, {
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'})); operation: "websocket_message",
return; messageLength: msg.toString().length,
} });
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
const {type, data} = parsed; return;
}
switch (type) {
case 'connectToHost': const { type, data } = parsed;
handleConnectToHost(data).catch(error => {
sshLogger.error('Failed to connect to host', error, { switch (type) {
operation: 'ssh_connect', case "connectToHost":
hostId: data.hostConfig?.id, handleConnectToHost(data).catch((error) => {
ip: data.hostConfig?.ip sshLogger.error("Failed to connect to host", error, {
}); operation: "ssh_connect",
ws.send(JSON.stringify({ hostId: data.hostConfig?.id,
type: 'error', ip: data.hostConfig?.ip,
message: 'Failed to connect to host: ' + (error instanceof Error ? error.message : 'Unknown error') });
})); ws.send(
}); JSON.stringify({
break; type: "error",
message:
case 'resize': "Failed to connect to host: " +
handleResize(data); (error instanceof Error ? error.message : "Unknown error"),
break; }),
);
case 'disconnect': });
cleanupSSH(); break;
break;
case "resize":
case 'input': handleResize(data);
if (sshStream) { break;
if (data === '\t') {
sshStream.write(data); case "disconnect":
} else if (data.startsWith('\x1b')) { cleanupSSH();
sshStream.write(data); break;
} else {
sshStream.write(Buffer.from(data, 'utf8')); case "input":
} if (sshStream) {
} if (data === "\t") {
break; sshStream.write(data);
} else if (data.startsWith("\x1b")) {
case 'ping': sshStream.write(data);
ws.send(JSON.stringify({type: 'pong'})); } else {
break; sshStream.write(Buffer.from(data, "utf8"));
}
default: }
sshLogger.warn('Unknown message type received', {operation: 'websocket_message', messageType: type}); break;
}
}); case "ping":
ws.send(JSON.stringify({ type: "pong" }));
async function handleConnectToHost(data: { break;
cols: number;
rows: number; default:
hostConfig: { sshLogger.warn("Unknown message type received", {
id: number; operation: "websocket_message",
ip: string; messageType: type,
port: number; });
username: string; }
password?: string; });
key?: string;
keyPassword?: string; async function handleConnectToHost(data: {
keyType?: string; cols: number;
authType?: string; rows: number;
credentialId?: number; hostConfig: {
userId?: string; id: number;
}; ip: string;
}) { port: number;
const {cols, rows, hostConfig} = data; username: string;
const {id, ip, port, username, password, key, keyPassword, keyType, authType, credentialId} = hostConfig; password?: string;
key?: string;
if (!username || typeof username !== 'string' || username.trim() === '') { keyPassword?: string;
sshLogger.error('Invalid username provided', undefined, {operation: 'ssh_connect', hostId: id, ip}); keyType?: string;
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'})); authType?: string;
return; credentialId?: number;
} userId?: string;
};
if (!ip || typeof ip !== 'string' || ip.trim() === '') { }) {
sshLogger.error('Invalid IP provided', undefined, {operation: 'ssh_connect', hostId: id, username}); const { cols, rows, hostConfig } = data;
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'})); const {
return; id,
} ip,
port,
if (!port || typeof port !== 'number' || port <= 0) { username,
sshLogger.error('Invalid port provided', undefined, { password,
operation: 'ssh_connect', key,
hostId: id, keyPassword,
ip, keyType,
username, authType,
port credentialId,
}); } = hostConfig;
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
return; if (!username || typeof username !== "string" || username.trim() === "") {
} sshLogger.error("Invalid username provided", undefined, {
operation: "ssh_connect",
sshConn = new Client(); hostId: id,
ip,
const connectionTimeout = setTimeout(() => { });
if (sshConn) { ws.send(
sshLogger.error('SSH connection timeout', undefined, { JSON.stringify({ type: "error", message: "Invalid username provided" }),
operation: 'ssh_connect', );
hostId: id, return;
ip, }
port,
username if (!ip || typeof ip !== "string" || ip.trim() === "") {
}); sshLogger.error("Invalid IP provided", undefined, {
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); operation: "ssh_connect",
cleanupSSH(connectionTimeout); hostId: id,
} username,
}, 60000); });
ws.send(
let resolvedCredentials = {password, key, keyPassword, keyType, authType}; JSON.stringify({ type: "error", message: "Invalid IP provided" }),
if (credentialId && id && hostConfig.userId) { );
try { return;
const credentials = await db }
.select()
.from(sshCredentials) if (!port || typeof port !== "number" || port <= 0) {
.where(and( sshLogger.error("Invalid port provided", undefined, {
eq(sshCredentials.id, credentialId), operation: "ssh_connect",
eq(sshCredentials.userId, hostConfig.userId) hostId: id,
)); ip,
username,
if (credentials.length > 0) { port,
const credential = credentials[0]; });
resolvedCredentials = { ws.send(
password: credential.password, JSON.stringify({ type: "error", message: "Invalid port provided" }),
key: credential.key, );
keyPassword: credential.keyPassword, return;
keyType: credential.keyType, }
authType: credential.authType
}; sshConn = new Client();
} else {
sshLogger.warn(`No credentials found for host ${id}`, { const connectionTimeout = setTimeout(() => {
operation: 'ssh_credentials', if (sshConn) {
hostId: id, sshLogger.error("SSH connection timeout", undefined, {
credentialId, operation: "ssh_connect",
userId: hostConfig.userId hostId: id,
}); ip,
} port,
} catch (error) { username,
sshLogger.warn(`Failed to resolve credentials for host ${id}`, { });
operation: 'ssh_credentials', ws.send(
hostId: id, JSON.stringify({ type: "error", message: "SSH connection timeout" }),
credentialId, );
error: error instanceof Error ? error.message : 'Unknown error' cleanupSSH(connectionTimeout);
}); }
} }, 60000);
} else if (credentialId && id) {
sshLogger.warn('Missing userId for credential resolution in terminal', { let resolvedCredentials = { password, key, keyPassword, keyType, authType };
operation: 'ssh_credentials', if (credentialId && id && hostConfig.userId) {
hostId: id, try {
credentialId, const credentials = await db
hasUserId: !!hostConfig.userId .select()
}); .from(sshCredentials)
} .where(
and(
sshConn.on('ready', () => { eq(sshCredentials.id, credentialId),
clearTimeout(connectionTimeout); eq(sshCredentials.userId, hostConfig.userId),
),
);
sshConn!.shell({
rows: data.rows, if (credentials.length > 0) {
cols: data.cols, const credential = credentials[0];
term: 'xterm-256color' resolvedCredentials = {
} as PseudoTtyOptions, (err, stream) => { password: credential.password,
if (err) { key: credential.key,
sshLogger.error('Shell error', err, {operation: 'ssh_shell', hostId: id, ip, port, username}); keyPassword: credential.keyPassword,
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message})); keyType: credential.keyType,
return; authType: credential.authType,
} };
} else {
sshStream = stream; sshLogger.warn(`No credentials found for host ${id}`, {
operation: "ssh_credentials",
stream.on('data', (data: Buffer) => { hostId: id,
ws.send(JSON.stringify({type: 'data', data: data.toString()})); credentialId,
}); userId: hostConfig.userId,
});
stream.on('close', () => { }
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'})); } catch (error) {
}); sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: "ssh_credentials",
stream.on('error', (err: Error) => { hostId: id,
sshLogger.error('SSH stream error', err, {operation: 'ssh_stream', hostId: id, ip, port, username}); credentialId,
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message})); error: error instanceof Error ? error.message : "Unknown error",
}); });
}
setupPingInterval(); } else if (credentialId && id) {
sshLogger.warn("Missing userId for credential resolution in terminal", {
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'})); operation: "ssh_credentials",
}); hostId: id,
}); credentialId,
hasUserId: !!hostConfig.userId,
sshConn.on('error', (err: Error) => { });
clearTimeout(connectionTimeout); }
sshLogger.error('SSH connection error', err, {
operation: 'ssh_connect', sshConn.on("ready", () => {
hostId: id, clearTimeout(connectionTimeout);
ip,
port, sshConn!.shell(
username, {
authType: resolvedCredentials.authType rows: data.rows,
}); cols: data.cols,
term: "xterm-256color",
let errorMessage = 'SSH error: ' + err.message; } as PseudoTtyOptions,
if (err.message.includes('No matching key exchange algorithm')) { (err, stream) => {
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.'; if (err) {
} else if (err.message.includes('No matching cipher')) { sshLogger.error("Shell error", err, {
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.'; operation: "ssh_shell",
} else if (err.message.includes('No matching MAC')) { hostId: id,
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.'; ip,
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) { port,
errorMessage = 'SSH error: Could not resolve hostname or connect to server.'; username,
} else if (err.message.includes('ECONNREFUSED')) { });
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.'; ws.send(
} else if (err.message.includes('ETIMEDOUT')) { JSON.stringify({
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.'; type: "error",
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) { message: "Shell error: " + err.message,
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.'; }),
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) { );
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.'; return;
} }
ws.send(JSON.stringify({type: 'error', message: errorMessage})); sshStream = stream;
cleanupSSH(connectionTimeout);
}); stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
sshConn.on('close', () => { });
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout); stream.on("close", () => {
}); ws.send(
JSON.stringify({
const connectConfig: any = { type: "disconnected",
host: ip, message: "Connection lost",
port, }),
username, );
keepaliveInterval: 30000, });
keepaliveCountMax: 3,
readyTimeout: 60000, stream.on("error", (err: Error) => {
tcpKeepAlive: true, sshLogger.error("SSH stream error", err, {
tcpKeepAliveInitialDelay: 30000, operation: "ssh_stream",
hostId: id,
env: { ip,
TERM: 'xterm-256color', port,
LANG: 'en_US.UTF-8', username,
LC_ALL: 'en_US.UTF-8', });
LC_CTYPE: 'en_US.UTF-8', ws.send(
LC_MESSAGES: 'en_US.UTF-8', JSON.stringify({
LC_MONETARY: 'en_US.UTF-8', type: "error",
LC_NUMERIC: 'en_US.UTF-8', message: "SSH stream error: " + err.message,
LC_TIME: 'en_US.UTF-8', }),
LC_COLLATE: 'en_US.UTF-8', );
COLORTERM: 'truecolor', });
},
setupPingInterval();
algorithms: {
kex: [ ws.send(
'diffie-hellman-group14-sha256', JSON.stringify({ type: "connected", message: "SSH connected" }),
'diffie-hellman-group14-sha1', );
'diffie-hellman-group1-sha1', },
'diffie-hellman-group-exchange-sha256', );
'diffie-hellman-group-exchange-sha1', });
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384', sshConn.on("error", (err: Error) => {
'ecdh-sha2-nistp521' clearTimeout(connectionTimeout);
], sshLogger.error("SSH connection error", err, {
cipher: [ operation: "ssh_connect",
'aes128-ctr', hostId: id,
'aes192-ctr', ip,
'aes256-ctr', port,
'aes128-gcm@openssh.com', username,
'aes256-gcm@openssh.com', authType: resolvedCredentials.authType,
'aes128-cbc', });
'aes192-cbc',
'aes256-cbc', let errorMessage = "SSH error: " + err.message;
'3des-cbc' if (err.message.includes("No matching key exchange algorithm")) {
], errorMessage =
hmac: [ "SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
'hmac-sha2-256', } else if (err.message.includes("No matching cipher")) {
'hmac-sha2-512', errorMessage =
'hmac-sha1', "SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
'hmac-md5' } else if (err.message.includes("No matching MAC")) {
], errorMessage =
compress: [ "SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
'none', } else if (
'zlib@openssh.com', err.message.includes("ENOTFOUND") ||
'zlib' err.message.includes("ENOENT")
] ) {
} errorMessage =
}; "SSH error: Could not resolve hostname or connect to server.";
if (resolvedCredentials.authType === 'key' && resolvedCredentials.key) { } else if (err.message.includes("ECONNREFUSED")) {
try { errorMessage =
if (!resolvedCredentials.key.includes('-----BEGIN') || !resolvedCredentials.key.includes('-----END')) { "SSH error: Connection refused. The server may not be running or the port may be incorrect.";
throw new Error('Invalid private key format'); } else if (err.message.includes("ETIMEDOUT")) {
} errorMessage =
"SSH error: Connection timed out. Check your network connection and server availability.";
const cleanKey = resolvedCredentials.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } else if (
err.message.includes("ECONNRESET") ||
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8'); err.message.includes("EPIPE")
) {
if (resolvedCredentials.keyPassword) { errorMessage =
connectConfig.passphrase = resolvedCredentials.keyPassword; "SSH error: Connection was reset. This may be due to network issues or server timeout.";
} } else if (
err.message.includes("authentication failed") ||
if (resolvedCredentials.keyType && resolvedCredentials.keyType !== 'auto') { err.message.includes("Permission denied")
connectConfig.privateKeyType = resolvedCredentials.keyType; ) {
} errorMessage =
} catch (keyError) { "SSH error: Authentication failed. Please check your username and password/key.";
sshLogger.error('SSH key format error: ' + keyError.message); }
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
return; ws.send(JSON.stringify({ type: "error", message: errorMessage }));
} cleanupSSH(connectionTimeout);
} else if (resolvedCredentials.authType === 'key') { });
sshLogger.error('SSH key authentication requested but no key provided');
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'})); sshConn.on("close", () => {
return; clearTimeout(connectionTimeout);
} else { cleanupSSH(connectionTimeout);
connectConfig.password = resolvedCredentials.password; });
}
const connectConfig: any = {
sshConn.connect(connectConfig); host: ip,
} port,
username,
function handleResize(data: { cols: number; rows: number }) { keepaliveInterval: 30000,
if (sshStream && sshStream.setWindow) { keepaliveCountMax: 3,
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols); readyTimeout: 60000,
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows})); tcpKeepAlive: true,
} tcpKeepAliveInitialDelay: 30000,
}
env: {
function cleanupSSH(timeoutId?: NodeJS.Timeout) { TERM: "xterm-256color",
if (timeoutId) { LANG: "en_US.UTF-8",
clearTimeout(timeoutId); LC_ALL: "en_US.UTF-8",
} LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
if (pingInterval) { LC_MONETARY: "en_US.UTF-8",
clearInterval(pingInterval); LC_NUMERIC: "en_US.UTF-8",
pingInterval = null; LC_TIME: "en_US.UTF-8",
} LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
if (sshStream) { },
try {
sshStream.end(); algorithms: {
} catch (e: any) { kex: [
sshLogger.error('Error closing stream: ' + e.message); "diffie-hellman-group14-sha256",
} "diffie-hellman-group14-sha1",
sshStream = null; "diffie-hellman-group1-sha1",
} "diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
if (sshConn) { "ecdh-sha2-nistp256",
try { "ecdh-sha2-nistp384",
sshConn.end(); "ecdh-sha2-nistp521",
} catch (e: any) { ],
sshLogger.error('Error closing connection: ' + e.message); cipher: [
} "aes128-ctr",
sshConn = null; "aes192-ctr",
} "aes256-ctr",
} "aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
function setupPingInterval() { "aes128-cbc",
pingInterval = setInterval(() => { "aes192-cbc",
if (sshConn && sshStream) { "aes256-cbc",
try { "3des-cbc",
sshStream.write('\x00'); ],
} catch (e: any) { hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
sshLogger.error('SSH keepalive failed: ' + e.message); compress: ["none", "zlib@openssh.com", "zlib"],
cleanupSSH(); },
} };
} if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
}, 60000); try {
} if (
!resolvedCredentials.key.includes("-----BEGIN") ||
!resolvedCredentials.key.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = resolvedCredentials.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword;
}
if (
resolvedCredentials.keyType &&
resolvedCredentials.keyType !== "auto"
) {
connectConfig.privateKeyType = resolvedCredentials.keyType;
}
} catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message);
ws.send(
JSON.stringify({
type: "error",
message: "SSH key format error: Invalid private key format",
}),
);
return;
}
} else if (resolvedCredentials.authType === "key") {
sshLogger.error("SSH key authentication requested but no key provided");
ws.send(
JSON.stringify({
type: "error",
message: "SSH key authentication requested but no key provided",
}),
);
return;
} else {
connectConfig.password = resolvedCredentials.password;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
sshLogger.error("Error closing stream: " + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
sshLogger.error("Error closing connection: " + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: any) {
sshLogger.error("SSH keepalive failed: " + e.message);
cleanupSSH();
}
}
}, 60000);
}
}); });
+959 -871
View File
File diff suppressed because it is too large Load Diff
+53 -40
View File
@@ -1,52 +1,65 @@
// npx tsc -p tsconfig.node.json // npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js // node ./dist/backend/starter.js
import './database/database.js' import "./database/database.js";
import './ssh/terminal.js'; import "./ssh/terminal.js";
import './ssh/tunnel.js'; import "./ssh/tunnel.js";
import './ssh/file-manager.js'; import "./ssh/file-manager.js";
import './ssh/server-stats.js'; import "./ssh/server-stats.js";
import { systemLogger, versionLogger } from './utils/logger.js'; import { systemLogger, versionLogger } from "./utils/logger.js";
import 'dotenv/config'; import "dotenv/config";
(async () => { (async () => {
try { try {
const version = process.env.VERSION || 'unknown'; const version = process.env.VERSION || "unknown";
versionLogger.info(`Termix Backend starting - Version: ${version}`, { versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: 'startup', operation: "startup",
version: version version: version,
}); });
systemLogger.info("Initializing backend services...", { operation: 'startup' }); systemLogger.info("Initializing backend services...", {
operation: "startup",
});
systemLogger.success("All backend services initialized successfully", { systemLogger.success("All backend services initialized successfully", {
operation: 'startup_complete', operation: "startup_complete",
services: ['database', 'terminal', 'tunnel', 'file_manager', 'stats'], services: ["database", "terminal", "tunnel", "file_manager", "stats"],
version: version version: version,
}); });
process.on('SIGINT', () => { process.on("SIGINT", () => {
systemLogger.info("Received SIGINT signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info(
process.exit(0); "Received SIGINT signal, initiating graceful shutdown...",
}); { operation: "shutdown" },
);
process.exit(0);
});
process.on('SIGTERM', () => { process.on("SIGTERM", () => {
systemLogger.info("Received SIGTERM signal, initiating graceful shutdown...", { operation: 'shutdown' }); systemLogger.info(
process.exit(0); "Received SIGTERM signal, initiating graceful shutdown...",
}); { operation: "shutdown" },
);
process.exit(0);
});
process.on('uncaughtException', (error) => { process.on("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, { operation: 'error_handling' }); systemLogger.error("Uncaught exception occurred", error, {
process.exit(1); operation: "error_handling",
}); });
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => { process.on("unhandledRejection", (reason, promise) => {
systemLogger.error("Unhandled promise rejection", reason, { operation: 'error_handling' }); systemLogger.error("Unhandled promise rejection", reason, {
process.exit(1); operation: "error_handling",
}); });
process.exit(1);
} catch (error) { });
systemLogger.error("Failed to initialize backend services", error, { operation: 'startup_failed' }); } catch (error) {
process.exit(1); systemLogger.error("Failed to initialize backend services", error, {
} operation: "startup_failed",
});
process.exit(1);
}
})(); })();
+141 -125
View File
@@ -1,158 +1,174 @@
import chalk from 'chalk'; import chalk from "chalk";
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext { export interface LogContext {
service?: string; service?: string;
operation?: string; operation?: string;
userId?: string; userId?: string;
hostId?: number; hostId?: number;
tunnelName?: string; tunnelName?: string;
sessionId?: string; sessionId?: string;
requestId?: string; requestId?: string;
duration?: number; duration?: number;
[key: string]: any; [key: string]: any;
} }
class Logger { class Logger {
private serviceName: string; private serviceName: string;
private serviceIcon: string; private serviceIcon: string;
private serviceColor: string; private serviceColor: string;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) { constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName; this.serviceName = serviceName;
this.serviceIcon = serviceIcon; this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor; this.serviceColor = serviceColor;
}
private getTimeStamp(): string {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
}
private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp();
const levelColor = this.getLevelColor(level);
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
const levelTag = levelColor(`[${level.toUpperCase()}]`);
let contextStr = "";
if (context) {
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
}
} }
private getTimeStamp(): string { return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
return chalk.gray(`[${new Date().toLocaleTimeString()}]`); }
private getLevelColor(level: LogLevel): chalk.Chalk {
switch (level) {
case "debug":
return chalk.magenta;
case "info":
return chalk.cyan;
case "warn":
return chalk.yellow;
case "error":
return chalk.redBright;
case "success":
return chalk.greenBright;
default:
return chalk.white;
} }
}
private formatMessage(level: LogLevel, message: string, context?: LogContext): string { private shouldLog(level: LogLevel): boolean {
const timestamp = this.getTimeStamp(); if (level === "debug" && process.env.NODE_ENV === "production") {
const levelColor = this.getLevelColor(level); return false;
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
const levelTag = levelColor(`[${level.toUpperCase()}]`);
let contextStr = '';
if (context) {
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(',')}]`);
}
}
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
} }
return true;
}
private getLevelColor(level: LogLevel): chalk.Chalk { debug(message: string, context?: LogContext): void {
switch (level) { if (!this.shouldLog("debug")) return;
case 'debug': return chalk.magenta; console.debug(this.formatMessage("debug", message, context));
case 'info': return chalk.cyan; }
case 'warn': return chalk.yellow;
case 'error': return chalk.redBright;
case 'success': return chalk.greenBright;
default: return chalk.white;
}
}
private shouldLog(level: LogLevel): boolean { info(message: string, context?: LogContext): void {
if (level === 'debug' && process.env.NODE_ENV === 'production') { if (!this.shouldLog("info")) return;
return false; console.log(this.formatMessage("info", message, context));
} }
return true;
}
debug(message: string, context?: LogContext): void { warn(message: string, context?: LogContext): void {
if (!this.shouldLog('debug')) return; if (!this.shouldLog("warn")) return;
console.debug(this.formatMessage('debug', message, context)); console.warn(this.formatMessage("warn", message, context));
} }
info(message: string, context?: LogContext): void { error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog('info')) return; if (!this.shouldLog("error")) return;
console.log(this.formatMessage('info', message, context)); console.error(this.formatMessage("error", message, context));
if (error) {
console.error(error);
} }
}
warn(message: string, context?: LogContext): void { success(message: string, context?: LogContext): void {
if (!this.shouldLog('warn')) return; if (!this.shouldLog("success")) return;
console.warn(this.formatMessage('warn', message, context)); console.log(this.formatMessage("success", message, context));
} }
error(message: string, error?: unknown, context?: LogContext): void { auth(message: string, context?: LogContext): void {
if (!this.shouldLog('error')) return; this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
console.error(this.formatMessage('error', message, context)); }
if (error) {
console.error(error);
}
}
success(message: string, context?: LogContext): void { db(message: string, context?: LogContext): void {
if (!this.shouldLog('success')) return; this.info(`DB: ${message}`, { ...context, operation: "database" });
console.log(this.formatMessage('success', message, context)); }
}
auth(message: string, context?: LogContext): void { ssh(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: 'auth' }); this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
} }
db(message: string, context?: LogContext): void { tunnel(message: string, context?: LogContext): void {
this.info(`DB: ${message}`, { ...context, operation: 'database' }); this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
} }
ssh(message: string, context?: LogContext): void { file(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: 'ssh' }); this.info(`FILE: ${message}`, { ...context, operation: "file" });
} }
tunnel(message: string, context?: LogContext): void { api(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: 'tunnel' }); this.info(`API: ${message}`, { ...context, operation: "api" });
} }
file(message: string, context?: LogContext): void { request(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: 'file' }); this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
} }
api(message: string, context?: LogContext): void { response(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: 'api' }); this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
} }
request(message: string, context?: LogContext): void { connection(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: 'request' }); this.info(`CONNECTION: ${message}`, {
} ...context,
operation: "connection",
});
}
response(message: string, context?: LogContext): void { disconnect(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: 'response' }); this.info(`DISCONNECT: ${message}`, {
} ...context,
operation: "disconnect",
});
}
connection(message: string, context?: LogContext): void { retry(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, { ...context, operation: 'connection' }); this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
} }
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, { ...context, operation: 'disconnect' });
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: 'retry' });
}
} }
export const databaseLogger = new Logger('DATABASE', '🗄️', '#6366f1'); export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
export const sshLogger = new Logger('SSH', '🖥️', '#0ea5e9'); export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
export const tunnelLogger = new Logger('TUNNEL', '📡', '#a855f7'); export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
export const fileLogger = new Logger('FILE', '📁', '#f59e0b'); export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
export const statsLogger = new Logger('STATS', '📊', '#22c55e'); export const statsLogger = new Logger("STATS", "📊", "#22c55e");
export const apiLogger = new Logger('API', '🌐', '#3b82f6'); export const apiLogger = new Logger("API", "🌐", "#3b82f6");
export const authLogger = new Logger('AUTH', '🔐', '#ef4444'); export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
export const systemLogger = new Logger('SYSTEM', '🚀', '#14b8a6'); export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger('VERSION', '📦', '#8b5cf6'); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const logger = systemLogger; export const logger = systemLogger;
+52 -52
View File
@@ -1,73 +1,73 @@
import {createContext, useContext, useEffect, useState} from "react" import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system" type Theme = "dark" | "light" | "system";
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode children: React.ReactNode;
defaultTheme?: Theme defaultTheme?: Theme;
storageKey?: string storageKey?: string;
} };
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme theme: Theme;
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void;
} };
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "system", theme: "system",
setTheme: () => null, setTheme: () => null,
} };
const ThemeProviderContext = createContext<ThemeProviderState>(initialState) const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
storageKey = "vite-ui-theme", storageKey = "vite-ui-theme",
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
) );
useEffect(() => { useEffect(() => {
const root = window.document.documentElement const root = window.document.documentElement;
root.classList.remove("light", "dark") root.classList.remove("light", "dark");
if (theme === "system") { if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches .matches
? "dark" ? "dark"
: "light" : "light";
root.classList.add(systemTheme) root.classList.add(systemTheme);
return return;
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
} }
return ( root.classList.add(theme);
<ThemeProviderContext.Provider {...props} value={value}> }, [theme]);
{children}
</ThemeProviderContext.Provider> const value = {
) theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
} }
export const useTheme = () => { export const useTheme = () => {
const context = useContext(ThemeProviderContext) const context = useContext(ThemeProviderContext);
if (context === undefined) if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider") throw new Error("useTheme must be used within a ThemeProvider");
return context return context;
} };
+10 -10
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion" import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Accordion({ function Accordion({
...props ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) { }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} /> return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
} }
function AccordionItem({ function AccordionItem({
@@ -20,7 +20,7 @@ function AccordionItem({
className={cn("border-b last:border-b-0", className)} className={cn("border-b last:border-b-0", className)}
{...props} {...props}
/> />
) );
} }
function AccordionTrigger({ function AccordionTrigger({
@@ -34,7 +34,7 @@ function AccordionTrigger({
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className,
)} )}
{...props} {...props}
> >
@@ -42,7 +42,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
) );
} }
function AccordionContent({ function AccordionContent({
@@ -58,7 +58,7 @@ function AccordionContent({
> >
<div className={cn("pt-0 pb-4", className)}>{children}</div> <div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
) );
} }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+11 -11
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Alert({ function Alert({
className, className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"col-start-2 font-medium tracking-tight whitespace-normal break-words", "col-start-2 font-medium tracking-tight whitespace-normal break-words",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDescription({ function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@@ -32,7 +32,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+20 -20
View File
@@ -1,37 +1,37 @@
import { Children, ReactElement, cloneElement, isValidElement } from 'react'; import { Children, ReactElement, cloneElement, isValidElement } from "react";
import { type ButtonProps } from '@/components/ui/button'; import { type ButtonProps } from "@/components/ui/button";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
interface ButtonGroupProps { interface ButtonGroupProps {
className?: string; className?: string;
orientation?: 'horizontal' | 'vertical'; orientation?: "horizontal" | "vertical";
children: ReactElement<ButtonProps>[] | React.ReactNode; children: ReactElement<ButtonProps>[] | React.ReactNode;
} }
export const ButtonGroup = ({ export const ButtonGroup = ({
className, className,
orientation = 'horizontal', orientation = "horizontal",
children, children,
}: ButtonGroupProps) => { }: ButtonGroupProps) => {
const isHorizontal = orientation === 'horizontal'; const isHorizontal = orientation === "horizontal";
const isVertical = orientation === 'vertical'; const isVertical = orientation === "vertical";
// Normalize and filter only valid React elements // Normalize and filter only valid React elements
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> => const childArray = Children.toArray(children).filter(
isValidElement(child) (child): child is ReactElement<ButtonProps> => isValidElement(child),
); );
const totalButtons = childArray.length; const totalButtons = childArray.length;
return ( return (
<div <div
className={cn( className={cn(
'flex', "flex",
{ {
'flex-col': isVertical, "flex-col": isVertical,
'w-fit': isVertical, "w-fit": isVertical,
}, },
className className,
)} )}
> >
{childArray.map((child, index) => { {childArray.map((child, index) => {
@@ -41,15 +41,15 @@ export const ButtonGroup = ({
return cloneElement(child, { return cloneElement(child, {
className: cn( className: cn(
{ {
'rounded-l-none': isHorizontal && !isFirst, "rounded-l-none": isHorizontal && !isFirst,
'rounded-r-none': isHorizontal && !isLast, "rounded-r-none": isHorizontal && !isLast,
'border-l-0': isHorizontal && !isFirst, "border-l-0": isHorizontal && !isFirst,
'rounded-t-none': isVertical && !isFirst, "rounded-t-none": isVertical && !isFirst,
'rounded-b-none': isVertical && !isLast, "rounded-b-none": isVertical && !isLast,
'border-t-0': isVertical && !isFirst, "border-t-0": isVertical && !isFirst,
}, },
child.props.className child.props.className,
), ),
}); });
})} })}
+10 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -32,13 +32,13 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
export interface ButtonProps export interface ButtonProps
extends React.ComponentProps<"button">, extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
function Button({ function Button({
@@ -48,7 +48,7 @@ function Button({
asChild = false, asChild = false,
...props ...props
}: ButtonProps) { }: ButtonProps) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -56,7 +56,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants, type ButtonProps } export { Button, buttonVariants, type ButtonProps };
+13 -13
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };
+7 -7
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -13,7 +13,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -24,7 +24,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };
+40 -40
View File
@@ -1,25 +1,25 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react" import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -27,16 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn( className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", "focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto h-4 w-4" /> <ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -64,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -83,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -133,13 +133,13 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@@ -147,12 +147,12 @@ const DropdownMenuLabel = React.forwardRef<
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", "px-2 py-1.5 text-sm font-semibold",
inset && "pl-8", inset && "pl-8",
className className,
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -163,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("bg-muted -mx-1 my-1 h-px", className)} className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@@ -175,9 +175,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { export {
DropdownMenu, DropdownMenu,
@@ -195,4 +195,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };
+40 -39
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -9,23 +9,23 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from "react-hook-form";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue,
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
@@ -37,21 +37,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext() const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name }) const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@@ -60,19 +60,19 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue,
) );
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
} }
function FormLabel({ function FormLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
@@ -99,11 +99,12 @@ function FormLabel({
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? "") : props.children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
> >
{body} {body}
</p> </p>
) );
} }
export { export {
@@ -162,4 +163,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };
+5 -5
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Label({ function Label({
className, className,
@@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+29 -28
View File
@@ -6,35 +6,36 @@ import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface PasswordInputProps interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>( export const PasswordInput = React.forwardRef<
({ className, ...props }, ref) => { HTMLInputElement,
const [showPassword, setShowPassword] = React.useState(false); PasswordInputProps
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Input <Input
ref={ref} ref={ref}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className={cn("h-11 text-base pr-12", className)} // extra padding-right className={cn("h-11 text-base pr-12", className)} // extra padding-right
{...props} {...props}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
> >
{showPassword ? ( {showPassword ? (
<EyeOff className="h-5 w-5" /> <EyeOff className="h-5 w-5" />
) : ( ) : (
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
)} )}
</button> </button>
</div> </div>
); );
} });
);
PasswordInput.displayName = "PasswordInput"; PasswordInput.displayName = "PasswordInput";
+9 -9
View File
@@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -29,18 +29,18 @@ function PopoverContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+6 -6
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@@ -13,7 +13,7 @@ function Progress({
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
> >
@@ -23,7 +23,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };
+11 -11
View File
@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels" import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} /> return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
} }
function ResizableHandle({ function ResizableHandle({
@@ -31,14 +31,14 @@ function ResizableHandle({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean withHandle?: boolean;
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150", "relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
className className,
)} )}
{...props} {...props}
> >
@@ -48,7 +48,7 @@ function ResizableHandle({
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.PanelResizeHandle>
) );
} }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function ScrollArea({ function ScrollArea({
className, className,
@@ -23,7 +23,7 @@ function ScrollArea({
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
) );
} }
function ScrollBar({ function ScrollBar({
@@ -41,7 +41,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent", "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent", "h-2.5 flex-col border-t border-t-transparent",
className className,
)} )}
{...props} {...props}
> >
@@ -50,7 +50,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full" className="bg-border relative flex-1 rounded-full"
/> />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
) );
} }
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };
+22 -22
View File
@@ -1,25 +1,25 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@@ -28,7 +28,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -36,7 +36,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -45,7 +45,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -62,7 +62,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
{...props} {...props}
@@ -72,7 +72,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -80,7 +80,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -93,7 +93,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -106,7 +106,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@@ -117,7 +117,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -130,7 +130,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -180,4 +180,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };
+7 -7
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
@@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+29 -21
View File
@@ -1,15 +1,15 @@
import type { ComponentProps, HTMLAttributes } from 'react'; import type { ComponentProps, HTMLAttributes } from "react";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
export type StatusProps = ComponentProps<typeof Badge> & { export type StatusProps = ComponentProps<typeof Badge> & {
status: 'online' | 'offline' | 'maintenance' | 'degraded'; status: "online" | "offline" | "maintenance" | "degraded";
}; };
export const Status = ({ className, status, ...props }: StatusProps) => ( export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge <Badge
className={cn('flex items-center gap-2', 'group', status, className)} className={cn("flex items-center gap-2", "group", status, className)}
variant="secondary" variant="secondary"
{...props} {...props}
/> />
@@ -24,20 +24,20 @@ export const StatusIndicator = ({
<span className="relative flex h-2 w-2" {...props}> <span className="relative flex h-2 w-2" {...props}>
<span <span
className={cn( className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', "absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
'group-[.online]:bg-emerald-500', "group-[.online]:bg-emerald-500",
'group-[.offline]:bg-red-500', "group-[.offline]:bg-red-500",
'group-[.maintenance]:bg-blue-500', "group-[.maintenance]:bg-blue-500",
'group-[.degraded]:bg-amber-500' "group-[.degraded]:bg-amber-500",
)} )}
/> />
<span <span
className={cn( className={cn(
'relative inline-flex h-2 w-2 rounded-full', "relative inline-flex h-2 w-2 rounded-full",
'group-[.online]:bg-emerald-500', "group-[.online]:bg-emerald-500",
'group-[.offline]:bg-red-500', "group-[.offline]:bg-red-500",
'group-[.maintenance]:bg-blue-500', "group-[.maintenance]:bg-blue-500",
'group-[.degraded]:bg-amber-500' "group-[.degraded]:bg-amber-500",
)} )}
/> />
</span> </span>
@@ -52,13 +52,21 @@ export const StatusLabel = ({
}: StatusLabelProps) => { }: StatusLabelProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span className={cn('text-muted-foreground', className)} {...props}> <span className={cn("text-muted-foreground", className)} {...props}>
{children ?? ( {children ?? (
<> <>
<span className="hidden group-[.online]:block">{t('common.online')}</span> <span className="hidden group-[.online]:block">
<span className="hidden group-[.offline]:block">{t('common.offline')}</span> {t("common.online")}
<span className="hidden group-[.maintenance]:block">{t('common.maintenance')}</span> </span>
<span className="hidden group-[.degraded]:block">{t('common.degraded')}</span> <span className="hidden group-[.offline]:block">
{t("common.offline")}
</span>
<span className="hidden group-[.maintenance]:block">
{t("common.maintenance")}
</span>
<span className="hidden group-[.degraded]:block">
{t("common.degraded")}
</span>
</> </>
)} )}
</span> </span>
+18 -18
View File
@@ -1,29 +1,29 @@
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -35,11 +35,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -48,7 +48,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -65,7 +65,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@@ -76,7 +76,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +86,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,7 +96,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -109,7 +109,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -122,7 +122,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -134,4 +134,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };
+122 -122
View File
@@ -1,54 +1,54 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@@ -60,36 +60,36 @@ function SidebarProvider({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@@ -98,18 +98,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
} }
} };
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@@ -121,8 +121,8 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@@ -138,7 +138,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className className,
)} )}
{...props} {...props}
> >
@@ -146,7 +146,7 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
@@ -157,11 +157,11 @@ function Sidebar({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
@@ -169,13 +169,13 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
// Commented out mobile behavior to keep sidebar always visible // Commented out mobile behavior to keep sidebar always visible
@@ -222,7 +222,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
@@ -236,7 +236,7 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
@@ -249,7 +249,7 @@ function Sidebar({
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@@ -257,7 +257,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@@ -267,19 +267,19 @@ function SidebarTrigger({
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@@ -296,11 +296,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -310,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@@ -328,7 +328,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -339,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -350,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@@ -364,7 +364,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -374,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -389,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
@@ -397,7 +397,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : "div";
return ( return (
<Comp <Comp
@@ -406,11 +406,11 @@ function SidebarGroupLabel({
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
@@ -418,7 +418,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -429,11 +429,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
@@ -447,7 +447,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -458,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -469,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
@@ -491,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
@@ -503,12 +503,12 @@ function SidebarMenuButton({
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@@ -519,16 +519,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@@ -541,7 +541,7 @@ function SidebarMenuButton({
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@@ -550,10 +550,10 @@ function SidebarMenuAction({
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -569,11 +569,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@@ -591,11 +591,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
@@ -603,12 +603,12 @@ function SidebarMenuSkeleton({
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@@ -633,7 +633,7 @@ function SidebarMenuSkeleton({
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -644,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
@@ -662,7 +662,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
@@ -672,11 +672,11 @@ function SidebarMenuSubButton({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : "a";
return ( return (
<Comp <Comp
@@ -690,11 +690,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -722,4 +722,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };
+3 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };
+6 -6
View File
@@ -1,8 +1,8 @@
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch" import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Switch({ function Switch({
className, className,
@@ -12,18 +12,18 @@ function Switch({
data-slot="switch" data-slot="switch"
className={cn( className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
<SwitchPrimitive.Thumb <SwitchPrimitive.Thumb
data-slot="switch-thumb" data-slot="switch-thumb"
className={cn( className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitive.Root>
) );
} }
export { Switch } export { Switch };
+15 -15
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };
+10 -10
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Tabs({ function Tabs({
className, className,
@@ -13,7 +13,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function TabsList({ function TabsList({
@@ -25,11 +25,11 @@ function TabsList({
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsTrigger({ function TabsTrigger({
@@ -41,11 +41,11 @@ function TabsTrigger({
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TabsContent({ function TabsContent({
@@ -58,7 +58,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)} className={cn("flex-1 outline-none", className)}
{...props} {...props}
/> />
) );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };
+8 -8
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils";
export interface TextareaProps export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} },
) );
Textarea.displayName = "Textarea" Textarea.displayName = "Textarea";
export { Textarea } export { Textarea };
+10 -10
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -47,14 +47,14 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+56 -53
View File
@@ -1,65 +1,68 @@
import {useState} from 'react'; import { useState } from "react";
import {toast} from 'sonner'; import { toast } from "sonner";
interface ConfirmationOptions { interface ConfirmationOptions {
title: string; title: string;
description: string; description: string;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
variant?: 'default' | 'destructive'; variant?: "default" | "destructive";
} }
export function useConfirmation() { export function useConfirmation() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmationOptions | null>(null); const [options, setOptions] = useState<ConfirmationOptions | null>(null);
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null); const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
const confirm = (opts: ConfirmationOptions, callback: () => void) => { const confirm = (opts: ConfirmationOptions, callback: () => void) => {
setOptions(opts); setOptions(opts);
setOnConfirm(() => callback); setOnConfirm(() => callback);
setIsOpen(true); setIsOpen(true);
}; };
const handleConfirm = () => { const handleConfirm = () => {
if (onConfirm) { if (onConfirm) {
onConfirm(); onConfirm();
} }
setIsOpen(false); setIsOpen(false);
setOptions(null); setOptions(null);
setOnConfirm(null); setOnConfirm(null);
}; };
const handleCancel = () => { const handleCancel = () => {
setIsOpen(false); setIsOpen(false);
setOptions(null); setOptions(null);
setOnConfirm(null); setOnConfirm(null);
}; };
const confirmWithToast = (message: string, callback: () => void, variant: 'default' | 'destructive' = 'default') => { const confirmWithToast = (
const actionText = variant === 'destructive' ? 'Delete' : 'Confirm'; message: string,
const cancelText = 'Cancel'; callback: () => void,
variant: "default" | "destructive" = "default",
) => {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
toast(message, { toast(message, {
action: { action: {
label: actionText, label: actionText,
onClick: callback onClick: callback,
}, },
cancel: { cancel: {
label: cancelText, label: cancelText,
onClick: () => { onClick: () => {},
} },
}, duration: 10000,
duration: 10000, className: variant === "destructive" ? "border-red-500" : "",
className: variant === 'destructive' ? 'border-red-500' : '' });
}); };
};
return { return {
isOpen, isOpen,
options, options,
confirm, confirm,
handleConfirm, handleConfirm,
handleCancel, handleCancel,
confirmWithToast confirmWithToast,
}; };
} }
+15 -13
View File
@@ -1,19 +1,21 @@
import * as React from "react" import * as React from "react";
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener("change", onChange) mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener("change", onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }
+33 -33
View File
@@ -1,42 +1,42 @@
import i18n from 'i18next'; import i18n from "i18next";
import {initReactI18next} from 'react-i18next'; import { initReactI18next } from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from '../locales/en/translation.json'; import enTranslation from "../locales/en/translation.json";
import zhTranslation from '../locales/zh/translation.json'; import zhTranslation from "../locales/zh/translation.json";
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
supportedLngs: ['en', 'zh'], supportedLngs: ["en", "zh"],
fallbackLng: 'en', fallbackLng: "en",
debug: false, debug: false,
detection: { detection: {
order: ['localStorage', 'cookie'], order: ["localStorage", "cookie"],
caches: ['localStorage', 'cookie'], caches: ["localStorage", "cookie"],
lookupLocalStorage: 'i18nextLng', lookupLocalStorage: "i18nextLng",
lookupCookie: 'i18nextLng', lookupCookie: "i18nextLng",
checkWhitelist: true, checkWhitelist: true,
}, },
resources: { resources: {
en: { en: {
translation: enTranslation translation: enTranslation,
}, },
zh: { zh: {
translation: zhTranslation translation: zhTranslation,
} },
}, },
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },
react: { react: {
useSuspense: false, useSuspense: false,
}, },
}); });
export default i18n; export default i18n;
+156 -155
View File
@@ -4,200 +4,201 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: light dark;
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: #09090b; background-color: #09090b;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885); --primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375); --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938); --muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32); --border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067); --ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885); --sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-ring: oklch(0.705 0.015 286.067);
} }
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-dark-bg: #18181b; --color-dark-bg: #18181b;
--color-dark-bg-darker: #0e0e10; --color-dark-bg-darker: #0e0e10;
--color-dark-bg-darkest: #09090b; --color-dark-bg-darkest: #09090b;
--color-dark-bg-input: #222225; --color-dark-bg-input: #222225;
--color-dark-bg-button: #23232a; --color-dark-bg-button: #23232a;
--color-dark-bg-active: #1d1d1f; --color-dark-bg-active: #1d1d1f;
--color-dark-bg-header: #131316; --color-dark-bg-header: #131316;
--color-dark-border: #303032; --color-dark-border: #303032;
--color-dark-border-active: #2d2d30; --color-dark-border-active: #2d2d30;
--color-dark-border-hover: #434345; --color-dark-border-hover: #434345;
--color-dark-hover: #2d2d30; --color-dark-hover: #2d2d30;
--color-dark-active: #2a2a2c; --color-dark-active: #2a2a2c;
--color-dark-pressed: #1a1a1c; --color-dark-pressed: #1a1a1c;
--color-dark-hover-alt: #2a2a2d; --color-dark-hover-alt: #2a2a2d;
--color-dark-border-light: #5a5a5d; --color-dark-border-light: #5a5a5d;
--color-dark-bg-light: #141416; --color-dark-bg-light: #141416;
--color-dark-border-medium: #373739; --color-dark-border-medium: #373739;
--color-dark-bg-very-light: #101014; --color-dark-bg-very-light: #101014;
--color-dark-bg-panel: #1b1b1e; --color-dark-bg-panel: #1b1b1e;
--color-dark-border-panel: #222224; --color-dark-border-panel: #222224;
--color-dark-bg-panel-hover: #232327; --color-dark-bg-panel-hover: #232327;
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885); --card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885); --popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32); --primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --sidebar-ring: oklch(0.552 0.016 285.938);
} }
@layer base { @layer base {
html, body { html,
height: 100%; body {
} height: 100%;
}
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #303032 transparent; scrollbar-color: #303032 transparent;
} }
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
height: 6px; height: 6px;
width: 6px; width: 6px;
} }
.thin-scrollbar::-webkit-scrollbar-track { .thin-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.thin-scrollbar::-webkit-scrollbar-thumb { .thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #303032; background-color: #303032;
border-radius: 9999px; border-radius: 9999px;
border: 2px solid transparent; border: 2px solid transparent;
background-clip: content-box; background-clip: content-box;
} }
.thin-scrollbar::-webkit-scrollbar { .thin-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
.thin-scrollbar::-webkit-scrollbar-track { .thin-scrollbar::-webkit-scrollbar-track {
background: #18181b; background: #18181b;
} }
.thin-scrollbar::-webkit-scrollbar-thumb { .thin-scrollbar::-webkit-scrollbar-thumb {
background: #434345; background: #434345;
border-radius: 3px; border-radius: 3px;
} }
.thin-scrollbar::-webkit-scrollbar-thumb:hover { .thin-scrollbar::-webkit-scrollbar-thumb:hover {
background: #5a5a5d; background: #5a5a5d;
} }
.thin-scrollbar { .thin-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #434345 #18181b; scrollbar-color: #434345 #18181b;
} }
+365 -307
View File
@@ -1,330 +1,388 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success'; export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext { export interface LogContext {
operation?: string; operation?: string;
userId?: string; userId?: string;
hostId?: number; hostId?: number;
tunnelName?: string; tunnelName?: string;
sessionId?: string; sessionId?: string;
requestId?: string; requestId?: string;
duration?: number; duration?: number;
method?: string; method?: string;
url?: string; url?: string;
status?: number; status?: number;
statusText?: string; statusText?: string;
responseTime?: number; responseTime?: number;
retryCount?: number; retryCount?: number;
errorCode?: string; errorCode?: string;
errorMessage?: string; errorMessage?: string;
[key: string]: any; [key: string]: any;
} }
class FrontendLogger { class FrontendLogger {
private serviceName: string; private serviceName: string;
private serviceIcon: string; private serviceIcon: string;
private serviceColor: string; private serviceColor: string;
private isDevelopment: boolean; private isDevelopment: boolean;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) { constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName; this.serviceName = serviceName;
this.serviceIcon = serviceIcon; this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor; this.serviceColor = serviceColor;
this.isDevelopment = process.env.NODE_ENV === 'development'; this.isDevelopment = process.env.NODE_ENV === "development";
}
private getTimeStamp(): string {
const now = new Date();
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`;
}
private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp();
const levelTag = this.getLevelTag(level);
const serviceTag = this.getServiceTag();
let contextStr = "";
if (context && this.isDevelopment) {
const contextParts = [];
if (context.operation) contextParts.push(context.operation);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
if (context.status) contextParts.push(`status:${context.status}`);
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
if (contextParts.length > 0) {
contextStr = ` (${contextParts.join(", ")})`;
}
} }
private getTimeStamp(): string { return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
const now = new Date(); }
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}]`;
private getLevelTag(level: LogLevel): string {
const symbols = {
debug: "🔍",
info: "️",
warn: "⚠️",
error: "❌",
success: "✅",
};
return `${symbols[level]} [${level.toUpperCase()}]`;
}
private getServiceTag(): string {
return `${this.serviceIcon} [${this.serviceName}]`;
}
private shouldLog(level: LogLevel): boolean {
if (level === "debug" && !this.isDevelopment) {
return false;
} }
return true;
}
private formatMessage(level: LogLevel, message: string, context?: LogContext): string { private log(
const timestamp = this.getTimeStamp(); level: LogLevel,
const levelTag = this.getLevelTag(level); message: string,
const serviceTag = this.getServiceTag(); context?: LogContext,
error?: unknown,
): void {
if (!this.shouldLog(level)) return;
let contextStr = ''; const formattedMessage = this.formatMessage(level, message, context);
if (context && this.isDevelopment) {
const contextParts = [];
if (context.operation) contextParts.push(context.operation);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
if (context.status) contextParts.push(`status:${context.status}`);
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
if (contextParts.length > 0) { switch (level) {
contextStr = ` (${contextParts.join(', ')})`; case "debug":
} console.debug(formattedMessage);
break;
case "info":
console.log(formattedMessage);
break;
case "warn":
console.warn(formattedMessage);
break;
case "error":
console.error(formattedMessage);
if (error) {
console.error("Error details:", error);
} }
break;
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; case "success":
console.log(formattedMessage);
break;
} }
}
private getLevelTag(level: LogLevel): string { debug(message: string, context?: LogContext): void {
const symbols = { this.log("debug", message, context);
debug: '🔍', }
info: '️',
warn: '⚠️', info(message: string, context?: LogContext): void {
error: '❌', this.log("info", message, context);
success: '✅' }
};
return `${symbols[level]} [${level.toUpperCase()}]`; warn(message: string, context?: LogContext): void {
this.log("warn", message, context);
}
error(message: string, error?: unknown, context?: LogContext): void {
this.log("error", message, context, error);
}
success(message: string, context?: LogContext): void {
this.log("success", message, context);
}
api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: "api" });
}
request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
}
response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
}
auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
}
ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: "file" });
}
connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, {
...context,
operation: "connection",
});
}
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, {
...context,
operation: "disconnect",
});
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
}
performance(message: string, context?: LogContext): void {
this.info(`PERFORMANCE: ${message}`, {
...context,
operation: "performance",
});
}
security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, { ...context, operation: "security" });
}
requestStart(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
this.request(`→ Starting request to ${cleanUrl}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
});
}
requestSuccess(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(
`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
{
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
responseTime,
},
);
console.groupEnd();
}
requestError(
method: string,
url: string,
status: number,
errorMessage: string,
responseTime?: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
errorMessage,
responseTime,
});
console.groupEnd();
}
networkError(
method: string,
url: string,
errorMessage: string,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorMessage,
errorCode: "NETWORK_ERROR",
});
console.groupEnd();
}
authError(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorCode: "AUTH_REQUIRED",
});
console.groupEnd();
}
retryAttempt(
method: string,
url: string,
attempt: number,
maxAttempts: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
retryCount: attempt,
});
}
apiOperation(operation: string, details: string, context?: LogContext): void {
this.info(`🔧 ${operation}: ${details}`, {
...context,
operation: "api_operation",
});
}
requestSummary(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(
`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
"color: #666; font-style: italic; font-size: 0.9em;",
context,
);
}
private getShortUrl(url: string): string {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const query = urlObj.search;
return `${urlObj.hostname}${path}${query}`;
} catch {
return url.length > 50 ? url.substring(0, 47) + "..." : url;
} }
}
private getServiceTag(): string { private getStatusIcon(status: number): string {
return `${this.serviceIcon} [${this.serviceName}]`; if (status >= 200 && status < 300) return "✅";
} if (status >= 300 && status < 400) return "↩️";
if (status >= 400 && status < 500) return "⚠️";
private shouldLog(level: LogLevel): boolean { if (status >= 500) return "❌";
if (level === 'debug' && !this.isDevelopment) { return "❓";
return false; }
}
return true; private getPerformanceIcon(responseTime: number): string {
} if (responseTime < 100) return "⚡";
if (responseTime < 500) return "🚀";
private log(level: LogLevel, message: string, context?: LogContext, error?: unknown): void { if (responseTime < 1000) return "🏃";
if (!this.shouldLog(level)) return; if (responseTime < 3000) return "🚶";
return "🐌";
const formattedMessage = this.formatMessage(level, message, context); }
switch (level) { private sanitizeUrl(url: string): string {
case 'debug': try {
console.debug(formattedMessage); const urlObj = new URL(url);
break; if (
case 'info': urlObj.searchParams.has("password") ||
console.log(formattedMessage); urlObj.searchParams.has("token")
break; ) {
case 'warn': urlObj.search = "";
console.warn(formattedMessage); }
break; return urlObj.toString();
case 'error': } catch {
console.error(formattedMessage); return url;
if (error) {
console.error('Error details:', error);
}
break;
case 'success':
console.log(formattedMessage);
break;
}
}
debug(message: string, context?: LogContext): void {
this.log('debug', message, context);
}
info(message: string, context?: LogContext): void {
this.log('info', message, context);
}
warn(message: string, context?: LogContext): void {
this.log('warn', message, context);
}
error(message: string, error?: unknown, context?: LogContext): void {
this.log('error', message, context, error);
}
success(message: string, context?: LogContext): void {
this.log('success', message, context);
}
api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, {...context, operation: 'api'});
}
request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, {...context, operation: 'request'});
}
response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, {...context, operation: 'response'});
}
auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, {...context, operation: 'auth'});
}
ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, {...context, operation: 'ssh'});
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, {...context, operation: 'tunnel'});
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, {...context, operation: 'file'});
}
connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, {...context, operation: 'connection'});
}
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, {...context, operation: 'disconnect'});
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, {...context, operation: 'retry'});
}
performance(message: string, context?: LogContext): void {
this.info(`PERFORMANCE: ${message}`, {...context, operation: 'performance'});
}
security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, {...context, operation: 'security'});
}
requestStart(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
this.request(`→ Starting request to ${cleanUrl}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl
});
}
requestSuccess(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
responseTime
});
console.groupEnd();
}
requestError(method: string, url: string, status: number, errorMessage: string, responseTime?: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
errorMessage,
responseTime
});
console.groupEnd();
}
networkError(method: string, url: string, errorMessage: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorMessage,
errorCode: 'NETWORK_ERROR'
});
console.groupEnd();
}
authError(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorCode: 'AUTH_REQUIRED'
});
console.groupEnd();
}
retryAttempt(method: string, url: string, attempt: number, maxAttempts: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
retryCount: attempt
});
}
apiOperation(operation: string, details: string, context?: LogContext): void {
this.info(`🔧 ${operation}: ${details}`, {...context, operation: 'api_operation'});
}
requestSummary(method: string, url: string, status: number, responseTime: number, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
'color: #666; font-style: italic; font-size: 0.9em;',
context
);
}
private getShortUrl(url: string): string {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const query = urlObj.search;
return `${urlObj.hostname}${path}${query}`;
} catch {
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
}
private getStatusIcon(status: number): string {
if (status >= 200 && status < 300) return '✅';
if (status >= 300 && status < 400) return '↩️';
if (status >= 400 && status < 500) return '⚠️';
if (status >= 500) return '❌';
return '❓';
}
private getPerformanceIcon(responseTime: number): string {
if (responseTime < 100) return '⚡';
if (responseTime < 500) return '🚀';
if (responseTime < 1000) return '🏃';
if (responseTime < 3000) return '🚶';
return '🐌';
}
private sanitizeUrl(url: string): string {
try {
const urlObj = new URL(url);
if (urlObj.searchParams.has('password') || urlObj.searchParams.has('token')) {
urlObj.search = '';
}
return urlObj.toString();
} catch {
return url;
}
} }
}
} }
export const apiLogger = new FrontendLogger('API', '🌐', '#3b82f6'); export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
export const authLogger = new FrontendLogger('AUTH', '🔐', '#dc2626'); export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
export const sshLogger = new FrontendLogger('SSH', '🖥️', '#1e3a8a'); export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
export const tunnelLogger = new FrontendLogger('TUNNEL', '📡', '#1e3a8a'); export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
export const fileLogger = new FrontendLogger('FILE', '📁', '#1e3a8a'); export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
export const statsLogger = new FrontendLogger('STATS', '📊', '#22c55e'); export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
export const systemLogger = new FrontendLogger('SYSTEM', '🚀', '#1e3a8a'); export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
export const logger = systemLogger; export const logger = systemLogger;
+3 -3
View File
@@ -1,6 +1,6 @@
import {clsx, type ClassValue} from "clsx" import { clsx, type ClassValue } from "clsx";
import {twMerge} from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+59 -56
View File
@@ -1,69 +1,72 @@
import {StrictMode, useEffect, useState, useRef} from 'react' import { StrictMode, useEffect, useState, useRef } from "react";
import {createRoot} from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import DesktopApp from './ui/Desktop/DesktopApp.tsx' import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
import {MobileApp} from './ui/Mobile/MobileApp.tsx' import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
import {ThemeProvider} from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider";
import './i18n/i18n' import "./i18n/i18n";
import {isElectron} from './ui/main-axios.ts' import { isElectron } from "./ui/main-axios.ts";
function useWindowWidth() { function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth); const [width, setWidth] = useState(window.innerWidth);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const lastSwitchTime = useRef(0); const lastSwitchTime = useRef(0);
const isCurrentlyMobile = useRef(window.innerWidth < 768); const isCurrentlyMobile = useRef(window.innerWidth < 768);
const hasSwitchedOnce = useRef(false); const hasSwitchedOnce = useRef(false);
useEffect(() => { useEffect(() => {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
const handleResize = () => { const handleResize = () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
const newWidth = window.innerWidth; const newWidth = window.innerWidth;
const newIsMobile = newWidth < 768; const newIsMobile = newWidth < 768;
const now = Date.now(); const now = Date.now();
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) { if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
setWidth(newWidth); setWidth(newWidth);
return; return;
} }
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) { if (
lastSwitchTime.current = now; newIsMobile !== isCurrentlyMobile.current &&
isCurrentlyMobile.current = newIsMobile; now - lastSwitchTime.current > 5000
hasSwitchedOnce.current = true; ) {
setWidth(newWidth); lastSwitchTime.current = now;
setIsMobile(newIsMobile); isCurrentlyMobile.current = newIsMobile;
} else { hasSwitchedOnce.current = true;
setWidth(newWidth); setWidth(newWidth);
} setIsMobile(newIsMobile);
}, 2000); } else {
}; setWidth(newWidth);
window.addEventListener("resize", handleResize); }
}, 2000);
};
window.addEventListener("resize", handleResize);
return () => { return () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, []); }, []);
return width; return width;
} }
function RootApp() { function RootApp() {
const width = useWindowWidth(); const width = useWindowWidth();
const isMobile = width < 768; const isMobile = width < 768;
if (isElectron()) { if (isElectron()) {
return <DesktopApp/>; return <DesktopApp />;
} }
return isMobile ? <MobileApp key="mobile"/> : <DesktopApp key="desktop"/>; return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
} }
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp/> <RootApp />
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) );
+261 -233
View File
@@ -4,56 +4,56 @@
// This file contains all shared interfaces and types used across the application // This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency. // to avoid duplication and ensure consistency.
import type {Client} from 'ssh2'; import type { Client } from "ssh2";
// ============================================================================ // ============================================================================
// SSH HOST TYPES // SSH HOST TYPES
// ============================================================================ // ============================================================================
export interface SSHHost { export interface SSHHost {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
folder: string; folder: string;
tags: string[]; tags: string[];
pin: boolean; pin: boolean;
authType: 'password' | 'key' | 'credential'; authType: "password" | "key" | "credential";
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
credentialId?: number; credentialId?: number;
userId?: string; userId?: string;
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: TunnelConnection[]; tunnelConnections: TunnelConnection[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface SSHHostData { export interface SSHHostData {
name?: string; name?: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
folder?: string; folder?: string;
tags?: string[]; tags?: string[];
pin?: boolean; pin?: boolean;
authType: 'password' | 'key' | 'credential'; authType: "password" | "key" | "credential";
password?: string; password?: string;
key?: File | null; key?: File | null;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
credentialId?: number | null; credentialId?: number | null;
enableTerminal?: boolean; enableTerminal?: boolean;
enableTunnel?: boolean; enableTunnel?: boolean;
enableFileManager?: boolean; enableFileManager?: boolean;
defaultPath?: string; defaultPath?: string;
tunnelConnections?: any[]; tunnelConnections?: any[];
} }
// ============================================================================ // ============================================================================
@@ -61,34 +61,34 @@ export interface SSHHostData {
// ============================================================================ // ============================================================================
export interface Credential { export interface Credential {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
folder?: string; folder?: string;
tags: string[]; tags: string[];
authType: 'password' | 'key'; authType: "password" | "key";
username: string; username: string;
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
usageCount: number; usageCount: number;
lastUsed?: string; lastUsed?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface CredentialData { export interface CredentialData {
name: string; name: string;
description?: string; description?: string;
folder?: string; folder?: string;
tags: string[]; tags: string[];
authType: 'password' | 'key'; authType: "password" | "key";
username: string; username: string;
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
} }
// ============================================================================ // ============================================================================
@@ -96,55 +96,55 @@ export interface CredentialData {
// ============================================================================ // ============================================================================
export interface TunnelConnection { export interface TunnelConnection {
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
endpointHost: string; endpointHost: string;
maxRetries: number; maxRetries: number;
retryInterval: number; retryInterval: number;
autoStart: boolean; autoStart: boolean;
} }
export interface TunnelConfig { export interface TunnelConfig {
name: string; name: string;
hostName: string; hostName: string;
sourceIP: string; sourceIP: string;
sourceSSHPort: number; sourceSSHPort: number;
sourceUsername: string; sourceUsername: string;
sourcePassword?: string; sourcePassword?: string;
sourceAuthMethod: string; sourceAuthMethod: string;
sourceSSHKey?: string; sourceSSHKey?: string;
sourceKeyPassword?: string; sourceKeyPassword?: string;
sourceKeyType?: string; sourceKeyType?: string;
sourceCredentialId?: number; sourceCredentialId?: number;
sourceUserId?: string; sourceUserId?: string;
endpointIP: string; endpointIP: string;
endpointSSHPort: number; endpointSSHPort: number;
endpointUsername: string; endpointUsername: string;
endpointPassword?: string; endpointPassword?: string;
endpointAuthMethod: string; endpointAuthMethod: string;
endpointSSHKey?: string; endpointSSHKey?: string;
endpointKeyPassword?: string; endpointKeyPassword?: string;
endpointKeyType?: string; endpointKeyType?: string;
endpointCredentialId?: number; endpointCredentialId?: number;
endpointUserId?: string; endpointUserId?: string;
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
maxRetries: number; maxRetries: number;
retryInterval: number; retryInterval: number;
autoStart: boolean; autoStart: boolean;
isPinned: boolean; isPinned: boolean;
} }
export interface TunnelStatus { export interface TunnelStatus {
connected: boolean; connected: boolean;
status: ConnectionState; status: ConnectionState;
retryCount?: number; retryCount?: number;
maxRetries?: number; maxRetries?: number;
nextRetryIn?: number; nextRetryIn?: number;
reason?: string; reason?: string;
errorType?: ErrorType; errorType?: ErrorType;
manualDisconnect?: boolean; manualDisconnect?: boolean;
retryExhausted?: boolean; retryExhausted?: boolean;
} }
// ============================================================================ // ============================================================================
@@ -152,50 +152,50 @@ export interface TunnelStatus {
// ============================================================================ // ============================================================================
export interface Tab { export interface Tab {
id: string | number; id: string | number;
title: string; title: string;
fileName: string; fileName: string;
content: string; content: string;
isSSH?: boolean; isSSH?: boolean;
sshSessionId?: string; sshSessionId?: string;
filePath?: string; filePath?: string;
loading?: boolean; loading?: boolean;
dirty?: boolean; dirty?: boolean;
} }
export interface FileManagerFile { export interface FileManagerFile {
name: string; name: string;
path: string; path: string;
type?: 'file' | 'directory'; type?: "file" | "directory";
isSSH?: boolean; isSSH?: boolean;
sshSessionId?: string; sshSessionId?: string;
} }
export interface FileManagerShortcut { export interface FileManagerShortcut {
name: string; name: string;
path: string; path: string;
} }
export interface FileItem { export interface FileItem {
name: string; name: string;
path: string; path: string;
isPinned?: boolean; isPinned?: boolean;
type: 'file' | 'directory'; type: "file" | "directory";
sshSessionId?: string; sshSessionId?: string;
} }
export interface ShortcutItem { export interface ShortcutItem {
name: string; name: string;
path: string; path: string;
} }
export interface SSHConnection { export interface SSHConnection {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
isPinned?: boolean; isPinned?: boolean;
} }
// ============================================================================ // ============================================================================
@@ -203,11 +203,11 @@ export interface SSHConnection {
// ============================================================================ // ============================================================================
export interface HostInfo { export interface HostInfo {
id: number; id: number;
name?: string; name?: string;
ip: string; ip: string;
port: number; port: number;
createdAt: string; createdAt: string;
} }
// ============================================================================ // ============================================================================
@@ -215,14 +215,14 @@ export interface HostInfo {
// ============================================================================ // ============================================================================
export interface TermixAlert { export interface TermixAlert {
id: string; id: string;
title: string; title: string;
message: string; message: string;
expiresAt: string; expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical'; priority?: "low" | "medium" | "high" | "critical";
type?: 'info' | 'warning' | 'error' | 'success'; type?: "info" | "warning" | "error" | "success";
actionUrl?: string; actionUrl?: string;
actionText?: string; actionText?: string;
} }
// ============================================================================ // ============================================================================
@@ -230,11 +230,18 @@ export interface TermixAlert {
// ============================================================================ // ============================================================================
export interface TabContextTab { export interface TabContextTab {
id: number; id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager' | 'user_profile'; type:
title: string; | "home"
hostConfig?: any; | "terminal"
terminalRef?: React.RefObject<any>; | "ssh_manager"
| "server"
| "admin"
| "file_manager"
| "user_profile";
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
} }
// ============================================================================ // ============================================================================
@@ -242,38 +249,44 @@ export interface TabContextTab {
// ============================================================================ // ============================================================================
export const CONNECTION_STATES = { export const CONNECTION_STATES = {
DISCONNECTED: "disconnected", DISCONNECTED: "disconnected",
CONNECTING: "connecting", CONNECTING: "connecting",
CONNECTED: "connected", CONNECTED: "connected",
VERIFYING: "verifying", VERIFYING: "verifying",
FAILED: "failed", FAILED: "failed",
UNSTABLE: "unstable", UNSTABLE: "unstable",
RETRYING: "retrying", RETRYING: "retrying",
WAITING: "waiting", WAITING: "waiting",
DISCONNECTING: "disconnecting" DISCONNECTING: "disconnecting",
} as const; } as const;
export type ConnectionState = typeof CONNECTION_STATES[keyof typeof CONNECTION_STATES]; export type ConnectionState =
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
export type ErrorType = 'CONNECTION_FAILED' | 'AUTHENTICATION_FAILED' | 'TIMEOUT' | 'NETWORK_ERROR' | 'UNKNOWN'; export type ErrorType =
| "CONNECTION_FAILED"
| "AUTHENTICATION_FAILED"
| "TIMEOUT"
| "NETWORK_ERROR"
| "UNKNOWN";
// ============================================================================ // ============================================================================
// AUTHENTICATION TYPES // AUTHENTICATION TYPES
// ============================================================================ // ============================================================================
export type AuthType = 'password' | 'key' | 'credential'; export type AuthType = "password" | "key" | "credential";
export type KeyType = 'rsa' | 'ecdsa' | 'ed25519'; export type KeyType = "rsa" | "ecdsa" | "ed25519";
// ============================================================================ // ============================================================================
// API RESPONSE TYPES // API RESPONSE TYPES
// ============================================================================ // ============================================================================
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
data?: T; data?: T;
error?: string; error?: string;
message?: string; message?: string;
status?: number; status?: number;
} }
// ============================================================================ // ============================================================================
@@ -281,107 +294,122 @@ export interface ApiResponse<T = any> {
// ============================================================================ // ============================================================================
export interface CredentialsManagerProps { export interface CredentialsManagerProps {
onEditCredential?: (credential: Credential) => void; onEditCredential?: (credential: Credential) => void;
} }
export interface CredentialEditorProps { export interface CredentialEditorProps {
editingCredential?: Credential | null; editingCredential?: Credential | null;
onFormSubmit?: () => void; onFormSubmit?: () => void;
} }
export interface CredentialViewerProps { export interface CredentialViewerProps {
credential: Credential; credential: Credential;
onClose: () => void; onClose: () => void;
onEdit: () => void; onEdit: () => void;
} }
export interface CredentialSelectorProps { export interface CredentialSelectorProps {
value?: number | null; value?: number | null;
onValueChange: (value: number | null) => void; onValueChange: (value: number | null) => void;
} }
export interface HostManagerProps { export interface HostManagerProps {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export interface SSHManagerHostEditorProps { export interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null; editingHost?: SSHHost | null;
onFormSubmit?: () => void; onFormSubmit?: () => void;
} }
export interface SSHManagerHostViewerProps { export interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void; onEditHost?: (host: SSHHost) => void;
} }
export interface HostProps { export interface HostProps {
host: SSHHost; host: SSHHost;
onHostConnect?: () => void; onHostConnect?: () => void;
} }
export interface SSHTunnelProps { export interface SSHTunnelProps {
filterHostKey?: string; filterHostKey?: string;
} }
export interface SSHTunnelViewerProps { export interface SSHTunnelViewerProps {
hosts?: SSHHost[]; hosts?: SSHHost[];
tunnelStatuses?: Record<string, TunnelStatus>; tunnelStatuses?: Record<string, TunnelStatus>;
tunnelActions?: Record<string, (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>>; tunnelActions?: Record<
onTunnelAction?: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; string,
(
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>
>;
onTunnelAction?: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
} }
export interface FileManagerProps { export interface FileManagerProps {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
embedded?: boolean; embedded?: boolean;
initialHost?: SSHHost | null; initialHost?: SSHHost | null;
} }
export interface FileManagerLeftSidebarProps { export interface FileManagerLeftSidebarProps {
onSelectView?: (view: string) => void; onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: Tab[]; tabs: Tab[];
host: SSHHost; host: SSHHost;
onOperationComplete?: () => void; onOperationComplete?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onSuccess?: (message: string) => void; onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void; onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void; onDeleteItem?: (item: any) => void;
} }
export interface FileManagerOperationsProps { export interface FileManagerOperationsProps {
currentPath: string; currentPath: string;
sshSessionId: string | null; sshSessionId: string | null;
onOperationComplete?: () => void; onOperationComplete?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onSuccess?: (message: string) => void; onSuccess?: (message: string) => void;
} }
export interface AlertCardProps { export interface AlertCardProps {
alert: TermixAlert; alert: TermixAlert;
onDismiss: (alertId: string) => void; onDismiss: (alertId: string) => void;
} }
export interface AlertManagerProps { export interface AlertManagerProps {
alerts: TermixAlert[]; alerts: TermixAlert[];
onDismiss: (alertId: string) => void; onDismiss: (alertId: string) => void;
loggedIn: boolean; loggedIn: boolean;
} }
export interface SSHTunnelObjectProps { export interface SSHTunnelObjectProps {
host: SSHHost; host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>; tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>; tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (
compact?: boolean; action: "connect" | "disconnect" | "cancel",
bare?: boolean; host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
compact?: boolean;
bare?: boolean;
} }
export interface FolderStats { export interface FolderStats {
totalHosts: number; totalHosts: number;
hostsByType: Array<{ hostsByType: Array<{
type: string; type: string;
count: number; count: number;
}>; }>;
} }
// ============================================================================ // ============================================================================
@@ -389,16 +417,16 @@ export interface FolderStats {
// ============================================================================ // ============================================================================
export interface HostConfig { export interface HostConfig {
host: SSHHost; host: SSHHost;
tunnels: TunnelConfig[]; tunnels: TunnelConfig[];
} }
export interface VerificationData { export interface VerificationData {
conn: Client; conn: Client;
timeout: NodeJS.Timeout; timeout: NodeJS.Timeout;
startTime: number; startTime: number;
attempts: number; attempts: number;
maxAttempts: number; maxAttempts: number;
} }
// ============================================================================ // ============================================================================
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,202 +1,226 @@
import React, {useState, useEffect, useRef} from 'react'; import React, { useState, useEffect, useRef } from "react";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Input} from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import {FormControl, FormItem, FormLabel} from "@/components/ui/form.tsx"; import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import {getCredentials} from '@/ui/main-axios.ts'; import { getCredentials } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import type {Credential} from '../../../../types'; import type { Credential } from "../../../../types";
interface CredentialSelectorProps { interface CredentialSelectorProps {
value?: number | null; value?: number | null;
onValueChange: (credentialId: number | null) => void; onValueChange: (credentialId: number | null) => void;
onCredentialSelect?: (credential: Credential | null) => void; onCredentialSelect?: (credential: Credential | null) => void;
} }
export function CredentialSelector({value, onValueChange, onCredentialSelect}: CredentialSelectorProps) { export function CredentialSelector({
const {t} = useTranslation(); value,
const [credentials, setCredentials] = useState<Credential[]>([]); onValueChange,
const [loading, setLoading] = useState(true); onCredentialSelect,
const [dropdownOpen, setDropdownOpen] = useState(false); }: CredentialSelectorProps) {
const [searchQuery, setSearchQuery] = useState(''); const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const fetchCredentials = async () => { const fetchCredentials = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await getCredentials(); const data = await getCredentials();
const credentialsArray = Array.isArray(data) ? data : (data.credentials || data.data || []); const credentialsArray = Array.isArray(data)
setCredentials(credentialsArray); ? data
} catch (error) { : data.credentials || data.data || [];
const {toast} = await import('sonner'); setCredentials(credentialsArray);
toast.error(t('credentials.failedToFetchCredentials')); } catch (error) {
setCredentials([]); const { toast } = await import("sonner");
} finally { toast.error(t("credentials.failedToFetchCredentials"));
setLoading(false); setCredentials([]);
} } finally {
}; setLoading(false);
}
fetchCredentials();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
const selectedCredential = credentials.find(c => c.id === value);
const filteredCredentials = credentials.filter(credential => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
credential.name.toLowerCase().includes(searchLower) ||
credential.username.toLowerCase().includes(searchLower) ||
(credential.folder && credential.folder.toLowerCase().includes(searchLower))
);
});
const handleCredentialSelect = (credential: Credential) => {
onValueChange(credential.id);
if (onCredentialSelect) {
onCredentialSelect(credential);
}
setDropdownOpen(false);
setSearchQuery('');
}; };
const handleClear = () => { fetchCredentials();
onValueChange(null); }, []);
if (onCredentialSelect) {
onCredentialSelect(null);
}
setDropdownOpen(false);
setSearchQuery('');
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
const selectedCredential = credentials.find((c) => c.id === value);
const filteredCredentials = credentials.filter((credential) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return ( return (
<FormItem> credential.name.toLowerCase().includes(searchLower) ||
<FormLabel>{t('hosts.selectCredential')}</FormLabel> credential.username.toLowerCase().includes(searchLower) ||
<FormControl> (credential.folder &&
<div className="relative"> credential.folder.toLowerCase().includes(searchLower))
<Button
ref={buttonRef}
type="button"
variant="outline"
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{loading ? (
t('common.loading')
) : value === "existing_credential" ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">{t('hosts.existingCredential')}</span>
</div>
</div>
) : selectedCredential ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">{selectedCredential.name}</span>
<span className="text-sm text-muted-foreground ml-2">
({selectedCredential.username} {selectedCredential.authType})
</span>
</div>
</div>
) : (
t('hosts.selectCredentialPlaceholder')
)}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
</svg>
</Button>
{dropdownOpen && (
<div
ref={dropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
>
<div className="p-2 border-b border-border">
<Input
placeholder={t('credentials.searchCredentials')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{loading ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{t('common.loading')}
</div>
) : filteredCredentials.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{searchQuery ? t('credentials.noCredentialsMatchFilters') : t('credentials.noCredentialsYet')}
</div>
) : (
<div className="grid grid-cols-1 gap-2.5">
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear}
>
{t('common.clear')}
</Button>
)}
{filteredCredentials.map((credential) => (
<Button
key={credential.id}
type="button"
variant="ghost"
size="sm"
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? 'bg-muted' : ''
}`}
onClick={() => handleCredentialSelect(credential)}
>
<div className="w-full">
<div className="flex items-center justify-between">
<span className="font-medium">{credential.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{credential.username} {credential.authType}
{credential.description && `${credential.description}`}
</div>
</div>
</Button>
))}
</div>
)}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
); );
});
const handleCredentialSelect = (credential: Credential) => {
onValueChange(credential.id);
if (onCredentialSelect) {
onCredentialSelect(credential);
}
setDropdownOpen(false);
setSearchQuery("");
};
const handleClear = () => {
onValueChange(null);
if (onCredentialSelect) {
onCredentialSelect(null);
}
setDropdownOpen(false);
setSearchQuery("");
};
return (
<FormItem>
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={buttonRef}
type="button"
variant="outline"
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{loading ? (
t("common.loading")
) : value === "existing_credential" ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">
{t("hosts.existingCredential")}
</span>
</div>
</div>
) : selectedCredential ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">{selectedCredential.name}</span>
<span className="text-sm text-muted-foreground ml-2">
({selectedCredential.username} {" "}
{selectedCredential.authType})
</span>
</div>
</div>
) : (
t("hosts.selectCredentialPlaceholder")
)}
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{dropdownOpen && (
<div
ref={dropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
>
<div className="p-2 border-b border-border">
<Input
placeholder={t("credentials.searchCredentials")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{loading ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : filteredCredentials.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{searchQuery
? t("credentials.noCredentialsMatchFilters")
: t("credentials.noCredentialsYet")}
</div>
) : (
<div className="grid grid-cols-1 gap-2.5">
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear}
>
{t("common.clear")}
</Button>
)}
{filteredCredentials.map((credential) => (
<Button
key={credential.id}
type="button"
variant="ghost"
size="sm"
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? "bg-muted" : ""
}`}
onClick={() => handleCredentialSelect(credential)}
>
<div className="w-full">
<div className="flex items-center justify-between">
<span className="font-medium">
{credential.name}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{credential.username} {credential.authType}
{credential.description &&
`${credential.description}`}
</div>
</div>
</Button>
))}
</div>
)}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
);
} }
@@ -1,465 +1,533 @@
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect } from "react";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Badge} from "@/components/ui/badge";
import {Separator} from "@/components/ui/separator";
import {ScrollArea} from "@/components/ui/scroll-area";
import {Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet";
import { import {
Key, Card,
User, CardContent,
Calendar, CardDescription,
Hash, CardHeader,
Folder, CardTitle,
Edit3, } from "@/components/ui/card";
Copy, import { Badge } from "@/components/ui/badge";
Shield, import { Separator } from "@/components/ui/separator";
Clock, import { ScrollArea } from "@/components/ui/scroll-area";
Server, import {
Eye, Sheet,
EyeOff, SheetContent,
AlertTriangle, SheetFooter,
CheckCircle, SheetHeader,
FileText SheetTitle,
} from 'lucide-react'; } from "@/components/ui/sheet";
import {getCredentialDetails, getCredentialHosts} from '@/ui/main-axios'; import {
import {toast} from 'sonner'; Key,
import {useTranslation} from 'react-i18next'; User,
import type {Credential, HostInfo, CredentialViewerProps} from '../../../types/index.js'; Calendar,
Hash,
Folder,
Edit3,
Copy,
Shield,
Clock,
Server,
Eye,
EyeOff,
AlertTriangle,
CheckCircle,
FileText,
} from "lucide-react";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
Credential,
HostInfo,
CredentialViewerProps,
} from "../../../types/index.js";
const CredentialViewer: React.FC<CredentialViewerProps> = ({credential, onClose, onEdit}) => { const CredentialViewer: React.FC<CredentialViewerProps> = ({
const {t} = useTranslation(); credential,
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(null); onClose,
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]); onEdit,
const [loading, setLoading] = useState(true); }) => {
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({}); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview'); const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
null,
);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
{},
);
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
"overview",
);
useEffect(() => { useEffect(() => {
fetchCredentialDetails(); fetchCredentialDetails();
fetchHostsUsing(); fetchHostsUsing();
}, [credential.id]); }, [credential.id]);
const fetchCredentialDetails = async () => { const fetchCredentialDetails = async () => {
try { try {
const response = await getCredentialDetails(credential.id); const response = await getCredentialDetails(credential.id);
setCredentialDetails(response); setCredentialDetails(response);
} catch (error) { } catch (error) {
toast.error(t('credentials.failedToFetchCredentialDetails')); toast.error(t("credentials.failedToFetchCredentialDetails"));
}
};
const fetchHostsUsing = async () => {
try {
const response = await getCredentialHosts(credential.id);
setHostsUsing(response);
} catch (error) {
toast.error(t('credentials.failedToFetchHostsUsing'));
} finally {
setLoading(false);
}
};
const toggleSensitiveVisibility = (field: string) => {
setShowSensitive(prev => ({
...prev,
[field]: !prev[field]
}));
};
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('copiedToClipboard', {field: fieldName}));
} catch (error) {
toast.error(t('credentials.failedToCopy'));
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getAuthIcon = (authType: string) => {
return authType === 'password' ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400"/>
);
};
const renderSensitiveField = (
value: string | undefined,
fieldName: string,
label: string,
isMultiline = false
) => {
if (!value) return null;
const isVisible = showSensitive[fieldName];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
{label}
</label>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)}
>
{isVisible ? <EyeOff className="h-4 w-4"/> : <Eye className="h-4 w-4"/>}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(value, label)}
>
<Copy className="h-4 w-4"/>
</Button>
</div>
</div>
<div className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
{isVisible ? (
<pre
className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
{value}
</pre>
) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{'•'.repeat(isMultiline ? 50 : 20)}
</div>
)}
</div>
</div>
);
};
if (loading || !credentialDetails) {
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw]">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
</div>
</SheetContent>
</Sheet>
);
} }
};
const fetchHostsUsing = async () => {
try {
const response = await getCredentialHosts(credential.id);
setHostsUsing(response);
} catch (error) {
toast.error(t("credentials.failedToFetchHostsUsing"));
} finally {
setLoading(false);
}
};
const toggleSensitiveVisibility = (field: string) => {
setShowSensitive((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t("copiedToClipboard", { field: fieldName }));
} catch (error) {
toast.error(t("credentials.failedToCopy"));
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getAuthIcon = (authType: string) => {
return authType === "password" ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
);
};
const renderSensitiveField = (
value: string | undefined,
fieldName: string,
label: string,
isMultiline = false,
) => {
if (!value) return null;
const isVisible = showSensitive[fieldName];
return ( return (
<Sheet open={true} onOpenChange={onClose}> <div className="space-y-2">
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto"> <div className="flex items-center justify-between">
<SheetHeader className="space-y-6 pb-8"> <label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
<SheetTitle className="flex items-center space-x-4"> {label}
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800"> </label>
{getAuthIcon(credentialDetails.authType)} <div className="flex items-center space-x-2">
</div> <Button
<div className="flex-1"> variant="ghost"
<div className="text-xl font-semibold">{credentialDetails.name}</div> size="sm"
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1"> onClick={() => toggleSensitiveVisibility(fieldName)}
{credentialDetails.description} >
</div> {isVisible ? (
</div> <EyeOff className="h-4 w-4" />
<div className="flex items-center space-x-2"> ) : (
<Badge variant="outline" <Eye className="h-4 w-4" />
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"> )}
{credentialDetails.authType} </Button>
</Badge> <Button
{credentialDetails.keyType && ( variant="ghost"
<Badge variant="secondary" size="sm"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"> onClick={() => copyToClipboard(value, label)}
{credentialDetails.keyType} >
</Badge> <Copy className="h-4 w-4" />
)} </Button>
</div> </div>
</SheetTitle> </div>
</SheetHeader> <div
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
>
{isVisible ? (
<pre
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
>
{value}
</pre>
) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{"•".repeat(isMultiline ? 50 : 20)}
</div>
)}
</div>
</div>
);
};
<div className="space-y-10"> if (loading || !credentialDetails) {
{/* Tab Navigation */} return (
<div <Sheet open={true} onOpenChange={onClose}>
className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg"> <SheetContent className="w-[600px] max-w-[50vw]">
<Button <div className="flex items-center justify-center h-64">
variant={activeTab === 'overview' ? 'default' : 'ghost'} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
size="sm" </div>
onClick={() => setActiveTab('overview')} </SheetContent>
className="flex-1 h-10" </Sheet>
> );
<FileText className="h-4 w-4 mr-2"/> }
{t('credentials.overview')}
</Button> return (
<Button <Sheet open={true} onOpenChange={onClose}>
variant={activeTab === 'security' ? 'default' : 'ghost'} <SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
size="sm" <SheetHeader className="space-y-6 pb-8">
onClick={() => setActiveTab('security')} <SheetTitle className="flex items-center space-x-4">
className="flex-1 h-10" <div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
> {getAuthIcon(credentialDetails.authType)}
<Shield className="h-4 w-4 mr-2"/> </div>
{t('credentials.security')} <div className="flex-1">
</Button> <div className="text-xl font-semibold">
<Button {credentialDetails.name}
variant={activeTab === 'usage' ? 'default' : 'ghost'} </div>
size="sm" <div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
onClick={() => setActiveTab('usage')} {credentialDetails.description}
className="flex-1 h-10" </div>
> </div>
<Server className="h-4 w-4 mr-2"/> <div className="flex items-center space-x-2">
{t('credentials.usage')} <Badge
</Button> variant="outline"
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
>
{credentialDetails.authType}
</Badge>
{credentialDetails.keyType && (
<Badge
variant="secondary"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
>
{credentialDetails.keyType}
</Badge>
)}
</div>
</SheetTitle>
</SheetHeader>
<div className="space-y-10">
{/* Tab Navigation */}
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("overview")}
className="flex-1 h-10"
>
<FileText className="h-4 w-4 mr-2" />
{t("credentials.overview")}
</Button>
<Button
variant={activeTab === "security" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("security")}
className="flex-1 h-10"
>
<Shield className="h-4 w-4 mr-2" />
{t("credentials.security")}
</Button>
<Button
variant={activeTab === "usage" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("usage")}
className="flex-1 h-10"
>
<Server className="h-4 w-4 mr-2" />
{t("credentials.usage")}
</Button>
</div>
{/* Tab Content */}
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">
<CardHeader className="pb-8">
<CardTitle className="text-lg font-semibold">
{t("credentials.basicInformation")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
</div> </div>
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("common.username")}
</div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{credentialDetails.username}
</div>
</div>
</div>
{/* Tab Content */} {credentialDetails.folder && (
{activeTab === 'overview' && ( <div className="flex items-center space-x-4">
<div className="grid gap-10 lg:grid-cols-2"> <Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<Card className="border-zinc-200 dark:border-zinc-700"> <div>
<CardHeader className="pb-8"> <div className="text-sm text-zinc-500 dark:text-zinc-400">
<CardTitle {t("common.folder")}
className="text-lg font-semibold">{t('credentials.basicInformation')}</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
</div>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.username')}</div>
<div
className="font-medium text-zinc-800 dark:text-zinc-200">{credentialDetails.username}</div>
</div>
</div>
{credentialDetails.folder && (
<div className="flex items-center space-x-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('common.folder')}</div>
<div className="font-medium">{credentialDetails.folder}</div>
</div>
</div>
)}
{credentialDetails.tags.length > 0 && (
<div className="flex items-start space-x-4">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1"/>
<div className="flex-1">
<div
className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">{t('hosts.tags')}</div>
<div className="flex flex-wrap gap-2">
{credentialDetails.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
)}
<Separator/>
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.created')}</div>
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
</div>
</div>
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400"/>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastModified')}</div>
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
{credentialDetails.usageCount}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{t('credentials.timesUsed')}
</div>
</div>
{credentialDetails.lastUsed && (
<div
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.lastUsed')}</div>
<div
className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
</div>
</div>
)}
<div
className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/>
<div>
<div
className="text-sm text-zinc-500 dark:text-zinc-400">{t('credentials.connectedHosts')}</div>
<div className="font-medium">{hostsUsing.length}</div>
</div>
</div>
</CardContent>
</Card>
</div> </div>
)} <div className="font-medium">
{credentialDetails.folder}
</div>
</div>
</div>
)}
{activeTab === 'security' && ( {credentialDetails.tags.length > 0 && (
<Card> <div className="flex items-start space-x-4">
<CardHeader> <Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
<CardTitle className="text-lg flex items-center space-x-2"> <div className="flex-1">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/> <div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
<span>{t('credentials.securityDetails')}</span> {t("hosts.tags")}
</CardTitle> </div>
<CardDescription> <div className="flex flex-wrap gap-2">
{t('credentials.securityDetailsDescription')} {credentialDetails.tags.map((tag, index) => (
</CardDescription> <Badge
</CardHeader> key={index}
<CardContent className="space-y-6"> variant="outline"
<div className="text-xs"
className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> >
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400"/> {tag}
<div> </Badge>
<div className="font-medium text-zinc-800 dark:text-zinc-200"> ))}
{t('credentials.credentialSecured')} </div>
</div> </div>
<div className="text-sm text-zinc-700 dark:text-zinc-300"> </div>
{t('credentials.credentialSecuredDescription')} )}
</div>
</div>
</div>
{credentialDetails.authType === 'password' && ( <Separator />
<div>
<h3 className="font-semibold mb-4">{t('credentials.passwordAuthentication')}</h3>
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
</div>
)}
{credentialDetails.authType === 'key' && ( <div className="flex items-center space-x-4">
<div className="space-y-6"> <Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<h3 className="font-semibold mb-2">{t('credentials.keyAuthentication')}</h3> <div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.created")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.createdAt)}
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="flex items-center space-x-4">
<div> <Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div <div>
className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3"> <div className="text-sm text-zinc-500 dark:text-zinc-400">
{t('credentials.keyType')} {t("credentials.lastModified")}
</div> </div>
<Badge variant="outline" className="text-sm"> <div className="font-medium">
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()} {formatDate(credentialDetails.updatedAt)}
</Badge> </div>
</div> </div>
</div> </div>
</CardContent>
</Card>
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)} <Card>
<CardHeader>
<CardTitle className="text-lg">
{t("credentials.usageStatistics")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
{credentialDetails.usageCount}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{t("credentials.timesUsed")}
</div>
</div>
{credentialDetails.keyPassword && renderSensitiveField( {credentialDetails.lastUsed && (
credentialDetails.keyPassword, <div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
'keyPassword', <Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
t('credentials.keyPassphrase') <div>
)} <div className="text-sm text-zinc-500 dark:text-zinc-400">
</div> {t("credentials.lastUsed")}
)} </div>
<div className="font-medium">
{formatDate(credentialDetails.lastUsed)}
</div>
</div>
</div>
)}
<div <div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg"> <Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5"/> <div>
<div className="text-sm"> <div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2"> {t("credentials.connectedHosts")}
{t('credentials.securityReminder')} </div>
</div> <div className="font-medium">{hostsUsing.length}</div>
<div className="text-zinc-700 dark:text-zinc-300"> </div>
{t('credentials.securityReminderText')} </div>
</div> </CardContent>
</div> </Card>
</div> </div>
</CardContent> )}
</Card>
)}
{activeTab === 'usage' && ( {activeTab === "security" && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center space-x-2"> <CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400"/> <Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t('credentials.hostsUsingCredential')}</span> <span>{t("credentials.securityDetails")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge> </CardTitle>
</CardTitle> <CardDescription>
</CardHeader> {t("credentials.securityDetailsDescription")}
<CardContent> </CardDescription>
{hostsUsing.length === 0 ? ( </CardHeader>
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400"> <CardContent className="space-y-6">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600"/> <div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<p>{t('credentials.noHostsUsingCredential')}</p> <CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
</div> <div>
) : ( <div className="font-medium text-zinc-800 dark:text-zinc-200">
<ScrollArea className="h-64"> {t("credentials.credentialSecured")}
<div className="space-y-3"> </div>
{hostsUsing.map((host) => ( <div className="text-sm text-zinc-700 dark:text-zinc-300">
<div {t("credentials.credentialSecuredDescription")}
key={host.id} </div>
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800" </div>
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server
className="h-4 w-4 text-zinc-600 dark:text-zinc-400"/>
</div>
<div>
<div className="font-medium">
{host.name || `${host.ip}:${host.port}`}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{host.ip}:{host.port}
</div>
</div>
</div>
<div
className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)}
</div> </div>
<SheetFooter> {credentialDetails.authType === "password" && (
<Button variant="outline" onClick={onClose}> <div>
{t('common.close')} <h3 className="font-semibold mb-4">
</Button> {t("credentials.passwordAuthentication")}
<Button onClick={onEdit}> </h3>
<Edit3 className="h-4 w-4 mr-2"/> {renderSensitiveField(
{t('credentials.editCredential')} credentialDetails.password,
</Button> "password",
</SheetFooter> t("common.password"),
</SheetContent> )}
</Sheet> </div>
); )}
{credentialDetails.authType === "key" && (
<div className="space-y-6">
<h3 className="font-semibold mb-2">
{t("credentials.keyAuthentication")}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div>
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
{t("credentials.keyType")}
</div>
<Badge variant="outline" className="text-sm">
{credentialDetails.keyType?.toUpperCase() ||
t("unknown").toUpperCase()}
</Badge>
</div>
</div>
{renderSensitiveField(
credentialDetails.key,
"key",
t("credentials.privateKey"),
true,
)}
{credentialDetails.keyPassword &&
renderSensitiveField(
credentialDetails.keyPassword,
"keyPassword",
t("credentials.keyPassphrase"),
)}
</div>
)}
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
<div className="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
{t("credentials.securityReminder")}
</div>
<div className="text-zinc-700 dark:text-zinc-300">
{t("credentials.securityReminderText")}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === "usage" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t("credentials.hostsUsingCredential")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
<p>{t("credentials.noHostsUsingCredential")}</p>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-3">
{hostsUsing.map((host) => (
<div
key={host.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
</div>
<div>
<div className="font-medium">
{host.name || `${host.ip}:${host.port}`}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{host.ip}:{host.port}
</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)}
</div>
<SheetFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" />
{t("credentials.editCredential")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}; };
export default CredentialViewer; export default CredentialViewer;
File diff suppressed because it is too large Load Diff
@@ -1,24 +1,26 @@
import React from "react"; import React from "react";
import {FileManagerTabList} from "./FileManagerTabList.tsx"; import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps { interface FileManagerTopNavbarProps {
tabs: { id: string | number, title: string }[]; tabs: { id: string | number; title: string }[];
activeTab: string | number; activeTab: string | number;
setActiveTab: (tab: string | number) => void; setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void; closeTab: (tab: string | number) => void;
onHomeClick: () => void; onHomeClick: () => void;
} }
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement { export function FIleManagerTopNavbar(
const {tabs, activeTab, setActiveTab, closeTab, onHomeClick} = props; props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return ( return (
<FileManagerTabList <FileManagerTabList
tabs={tabs} tabs={tabs}
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
closeTab={closeTab} closeTab={closeTab}
onHomeClick={onHomeClick} onHomeClick={onHomeClick}
/> />
); );
} }
File diff suppressed because it is too large Load Diff
@@ -1,335 +1,338 @@
import React, {useEffect} from "react"; import React, { useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import {loadLanguage} from '@uiw/codemirror-extensions-langs'; import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link'; import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import {oneDark} from '@codemirror/theme-one-dark'; import { oneDark } from "@codemirror/theme-one-dark";
import {EditorView} from '@codemirror/view'; import { EditorView } from "@codemirror/view";
interface FileManagerCodeEditorProps { interface FileManagerCodeEditorProps {
content: string; content: string;
fileName: string; fileName: string;
onContentChange: (value: string) => void; onContentChange: (value: string) => void;
} }
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) { export function FileManagerFileEditor({
function getLanguageName(filename: string): string { content,
if (!filename || typeof filename !== 'string') { fileName,
return 'text'; onContentChange,
} }: FileManagerCodeEditorProps) {
const lastDotIndex = filename.lastIndexOf('.'); function getLanguageName(filename: string): string {
if (lastDotIndex === -1) { if (!filename || typeof filename !== "string") {
return 'text'; return "text";
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case 'ng':
return 'angular';
case 'apl':
return 'apl';
case 'asc':
return 'asciiArmor';
case 'ast':
return 'asterisk';
case 'bf':
return 'brainfuck';
case 'c':
return 'c';
case 'ceylon':
return 'ceylon';
case 'clj':
return 'clojure';
case 'cmake':
return 'cmake';
case 'cob':
case 'cbl':
return 'cobol';
case 'coffee':
return 'coffeescript';
case 'lisp':
return 'commonLisp';
case 'cpp':
case 'cc':
case 'cxx':
return 'cpp';
case 'cr':
return 'crystal';
case 'cs':
return 'csharp';
case 'css':
return 'css';
case 'cypher':
return 'cypher';
case 'd':
return 'd';
case 'dart':
return 'dart';
case 'diff':
case 'patch':
return 'diff';
case 'dockerfile':
return 'dockerfile';
case 'dtd':
return 'dtd';
case 'dylan':
return 'dylan';
case 'ebnf':
return 'ebnf';
case 'ecl':
return 'ecl';
case 'eiffel':
return 'eiffel';
case 'elm':
return 'elm';
case 'erl':
return 'erlang';
case 'factor':
return 'factor';
case 'fcl':
return 'fcl';
case 'fs':
return 'forth';
case 'f90':
case 'for':
return 'fortran';
case 's':
return 'gas';
case 'feature':
return 'gherkin';
case 'go':
return 'go';
case 'groovy':
return 'groovy';
case 'hs':
return 'haskell';
case 'hx':
return 'haxe';
case 'html':
case 'htm':
return 'html';
case 'http':
return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js':
case 'mjs':
case 'cjs':
return 'javascript';
case 'jinja2':
case 'j2':
return 'jinja2';
case 'json':
return 'json';
case 'jsx':
return 'jsx';
case 'jl':
return 'julia';
case 'kt':
case 'kts':
return 'kotlin';
case 'less':
return 'less';
case 'lezer':
return 'lezer';
case 'liquid':
return 'liquid';
case 'litcoffee':
return 'livescript';
case 'lua':
return 'lua';
case 'md':
return 'markdown';
case 'nb':
case 'mat':
return 'mathematica';
case 'mbox':
return 'mbox';
case 'mmd':
return 'mermaid';
case 'mrc':
return 'mirc';
case 'moo':
return 'modelica';
case 'mscgen':
return 'mscgen';
case 'm':
return 'mumps';
case 'sql':
return 'mysql';
case 'nc':
return 'nesC';
case 'nginx':
return 'nginx';
case 'nix':
return 'nix';
case 'nsi':
return 'nsis';
case 'nt':
return 'ntriples';
case 'mm':
return 'objectiveCpp';
case 'octave':
return 'octave';
case 'oz':
return 'oz';
case 'pas':
return 'pascal';
case 'pl':
case 'pm':
return 'perl';
case 'pgsql':
return 'pgsql';
case 'php':
return 'php';
case 'pig':
return 'pig';
case 'ps1':
return 'powershell';
case 'properties':
return 'properties';
case 'proto':
return 'protobuf';
case 'pp':
return 'puppet';
case 'py':
return 'python';
case 'q':
return 'q';
case 'r':
return 'r';
case 'rb':
return 'ruby';
case 'rs':
return 'rust';
case 'sas':
return 'sas';
case 'sass':
case 'scss':
return 'sass';
case 'scala':
return 'scala';
case 'scm':
return 'scheme';
case 'shader':
return 'shader';
case 'sh':
case 'bash':
return 'shell';
case 'siv':
return 'sieve';
case 'st':
return 'smalltalk';
case 'sol':
return 'solidity';
case 'solr':
return 'solr';
case 'rq':
return 'sparql';
case 'xlsx':
case 'ods':
case 'csv':
return 'spreadsheet';
case 'nut':
return 'squirrel';
case 'tex':
return 'stex';
case 'styl':
return 'stylus';
case 'svelte':
return 'svelte';
case 'swift':
return 'swift';
case 'tcl':
return 'tcl';
case 'textile':
return 'textile';
case 'tiddlywiki':
return 'tiddlyWiki';
case 'tiki':
return 'tiki';
case 'toml':
return 'toml';
case 'troff':
return 'troff';
case 'tsx':
return 'tsx';
case 'ttcn':
return 'ttcn';
case 'ttl':
case 'turtle':
return 'turtle';
case 'ts':
return 'typescript';
case 'vb':
return 'vb';
case 'vbs':
return 'vbscript';
case 'vm':
return 'velocity';
case 'v':
return 'verilog';
case 'vhd':
case 'vhdl':
return 'vhdl';
case 'vue':
return 'vue';
case 'wat':
return 'wast';
case 'webidl':
return 'webIDL';
case 'xq':
case 'xquery':
return 'xQuery';
case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml':
case 'yml':
return 'yaml';
case 'z80':
return 'z80';
default:
return 'text';
}
} }
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "text";
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
useEffect(() => { switch (ext) {
document.body.style.overflowX = 'hidden'; case "ng":
return () => { return "angular";
document.body.style.overflowX = ''; case "apl":
}; return "apl";
}, []); case "asc":
return "asciiArmor";
case "ast":
return "asterisk";
case "bf":
return "brainfuck";
case "c":
return "c";
case "ceylon":
return "ceylon";
case "clj":
return "clojure";
case "cmake":
return "cmake";
case "cob":
case "cbl":
return "cobol";
case "coffee":
return "coffeescript";
case "lisp":
return "commonLisp";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "cr":
return "crystal";
case "cs":
return "csharp";
case "css":
return "css";
case "cypher":
return "cypher";
case "d":
return "d";
case "dart":
return "dart";
case "diff":
case "patch":
return "diff";
case "dockerfile":
return "dockerfile";
case "dtd":
return "dtd";
case "dylan":
return "dylan";
case "ebnf":
return "ebnf";
case "ecl":
return "ecl";
case "eiffel":
return "eiffel";
case "elm":
return "elm";
case "erl":
return "erlang";
case "factor":
return "factor";
case "fcl":
return "fcl";
case "fs":
return "forth";
case "f90":
case "for":
return "fortran";
case "s":
return "gas";
case "feature":
return "gherkin";
case "go":
return "go";
case "groovy":
return "groovy";
case "hs":
return "haskell";
case "hx":
return "haxe";
case "html":
case "htm":
return "html";
case "http":
return "http";
case "idl":
return "idl";
case "java":
return "java";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "jinja2":
case "j2":
return "jinja2";
case "json":
return "json";
case "jsx":
return "jsx";
case "jl":
return "julia";
case "kt":
case "kts":
return "kotlin";
case "less":
return "less";
case "lezer":
return "lezer";
case "liquid":
return "liquid";
case "litcoffee":
return "livescript";
case "lua":
return "lua";
case "md":
return "markdown";
case "nb":
case "mat":
return "mathematica";
case "mbox":
return "mbox";
case "mmd":
return "mermaid";
case "mrc":
return "mirc";
case "moo":
return "modelica";
case "mscgen":
return "mscgen";
case "m":
return "mumps";
case "sql":
return "mysql";
case "nc":
return "nesC";
case "nginx":
return "nginx";
case "nix":
return "nix";
case "nsi":
return "nsis";
case "nt":
return "ntriples";
case "mm":
return "objectiveCpp";
case "octave":
return "octave";
case "oz":
return "oz";
case "pas":
return "pascal";
case "pl":
case "pm":
return "perl";
case "pgsql":
return "pgsql";
case "php":
return "php";
case "pig":
return "pig";
case "ps1":
return "powershell";
case "properties":
return "properties";
case "proto":
return "protobuf";
case "pp":
return "puppet";
case "py":
return "python";
case "q":
return "q";
case "r":
return "r";
case "rb":
return "ruby";
case "rs":
return "rust";
case "sas":
return "sas";
case "sass":
case "scss":
return "sass";
case "scala":
return "scala";
case "scm":
return "scheme";
case "shader":
return "shader";
case "sh":
case "bash":
return "shell";
case "siv":
return "sieve";
case "st":
return "smalltalk";
case "sol":
return "solidity";
case "solr":
return "solr";
case "rq":
return "sparql";
case "xlsx":
case "ods":
case "csv":
return "spreadsheet";
case "nut":
return "squirrel";
case "tex":
return "stex";
case "styl":
return "stylus";
case "svelte":
return "svelte";
case "swift":
return "swift";
case "tcl":
return "tcl";
case "textile":
return "textile";
case "tiddlywiki":
return "tiddlyWiki";
case "tiki":
return "tiki";
case "toml":
return "toml";
case "troff":
return "troff";
case "tsx":
return "tsx";
case "ttcn":
return "ttcn";
case "ttl":
case "turtle":
return "turtle";
case "ts":
return "typescript";
case "vb":
return "vb";
case "vbs":
return "vbscript";
case "vm":
return "velocity";
case "v":
return "verilog";
case "vhd":
case "vhdl":
return "vhdl";
case "vue":
return "vue";
case "wat":
return "wast";
case "webidl":
return "webIDL";
case "xq":
case "xquery":
return "xQuery";
case "xml":
return "xml";
case "yacas":
return "yacas";
case "yaml":
case "yml":
return "yaml";
case "z80":
return "z80";
default:
return "text";
}
}
return ( useEffect(() => {
<div className="w-full h-full relative overflow-hidden flex flex-col"> document.body.style.overflowX = "hidden";
<div return () => {
className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper" document.body.style.overflowX = "";
> };
<CodeMirror }, []);
value={content}
extensions={[ return (
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [], <div className="w-full h-full relative overflow-hidden flex flex-col">
hyperLink, <div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
oneDark, <CodeMirror
EditorView.theme({ value={content}
'&': { extensions={[
backgroundColor: 'var(--color-dark-bg-darkest) !important', loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
}, [],
'.cm-gutters': { hyperLink,
backgroundColor: 'var(--color-dark-bg) !important', oneDark,
}, EditorView.theme({
}) "&": {
]} backgroundColor: "var(--color-dark-bg-darkest) !important",
onChange={(value: any) => onContentChange(value)} },
theme={undefined} ".cm-gutters": {
height="100%" backgroundColor: "var(--color-dark-bg) !important",
basicSetup={{lineNumbers: true}} },
className="min-h-full min-w-full flex-1" }),
/> ]}
</div> onChange={(value: any) => onContentChange(value)}
</div> theme={undefined}
); height="100%"
basicSetup={{ lineNumbers: true }}
className="min-h-full min-w-full flex-1"
/>
</div>
</div>
);
} }
@@ -1,201 +1,234 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react'; import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx'; import {
import {Input} from '@/components/ui/input.tsx'; Tabs,
import {useState} from 'react'; TabsList,
import {useTranslation} from 'react-i18next'; TabsTrigger,
import type {FileItem, ShortcutItem} from '../../../types/index'; TabsContent,
} from "@/components/ui/tabs.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { FileItem, ShortcutItem } from "../../../types/index";
interface FileManagerHomeViewProps { interface FileManagerHomeViewProps {
recent: FileItem[]; recent: FileItem[];
pinned: FileItem[]; pinned: FileItem[];
shortcuts: ShortcutItem[]; shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void; onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void; onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void; onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void; onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void; onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void; onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void; onAddShortcut: (path: string) => void;
} }
export function FileManagerHomeView({ export function FileManagerHomeView({
recent, recent,
pinned, pinned,
shortcuts, shortcuts,
onOpenFile, onOpenFile,
onRemoveRecent, onRemoveRecent,
onPinFile, onPinFile,
onUnpinFile, onUnpinFile,
onOpenShortcut, onOpenShortcut,
onRemoveShortcut, onRemoveShortcut,
onAddShortcut onAddShortcut,
}: FileManagerHomeViewProps) { }: FileManagerHomeViewProps) {
const {t} = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent'); const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
const [newShortcut, setNewShortcut] = useState(''); const [newShortcut, setNewShortcut] = useState("");
const renderFileCard = (
file: FileItem,
onRemove: () => void,
onPin?: () => void,
isPinned = false,
) => (
<div
key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
);
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => ( const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={file.path} <div
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"> key={shortcut.path}
<div className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" >
onClick={() => onOpenFile(file)} <div
> className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
{file.type === 'directory' ? onClick={() => onOpenShortcut(shortcut)}
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> : >
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/> <Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
className="w-full"
>
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="recent"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.recent")}
</TabsTrigger>
<TabsTrigger
value="pinned"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.pinned")}
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.folderShortcuts")}
</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noRecentFiles")}
</span>
</div>
) : (
recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
file.isPinned,
),
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noPinnedFiles")}
</span>
</div>
) : (
pinned.map((file) =>
renderFileCard(file, undefined, () => onUnpinFile(file), true),
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input
placeholder={t("fileManager.enterFolderPath")}
value={newShortcut}
onChange={(e) => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Enter" && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
} }
<div className="flex-1 min-w-0"> }}
<div className="text-sm font-medium text-white break-words leading-tight"> />
{file.name} <Button
</div> size="sm"
</div> variant="ghost"
</div> className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
<div className="flex items-center gap-1 flex-shrink-0"> onClick={() => {
{onPin && ( if (newShortcut.trim()) {
<Button onAddShortcut(newShortcut.trim());
size="sm" setNewShortcut("");
variant="ghost" }
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md" }}
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
> >
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> <Plus className="w-3.5 h-3.5 mr-1" />
<div className="flex-1 min-w-0"> {t("common.add")}
<div className="text-sm font-medium text-white break-words leading-tight"> </Button>
{shortcut.path} </div>
</div> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
</div> {shortcuts.length === 0 ? (
</div> <div className="flex items-center justify-center py-4 col-span-full">
<div className="flex items-center gap-1 flex-shrink-0"> <span className="text-sm text-muted-foreground">
<Button {t("fileManager.noShortcuts")}
size="sm" </span>
variant="ghost" </div>
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md" ) : (
onClick={() => onRemoveShortcut(shortcut)} shortcuts.map((shortcut) => renderShortcutCard(shortcut))
> )}
<Trash2 className="w-3 h-3 text-red-500"/> </div>
</Button> </TabsContent>
</div> </Tabs>
</div> </div>
); );
return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger value="recent"
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.recent')}</TabsTrigger>
<TabsTrigger value="pinned"
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.pinned')}</TabsTrigger>
<TabsTrigger value="shortcuts"
className="data-[state=active]:bg-dark-bg-button">{t('fileManager.folderShortcuts')}</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
</div>
) : recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
file.isPinned
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
</div>
) : pinned.map((file) =>
renderFileCard(
file,
undefined,
() => onUnpinFile(file),
true
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input
placeholder={t('fileManager.enterFolderPath')}
value={newShortcut}
onChange={e => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1"/>
{t('common.add')}
</Button>
</div>
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
</div>
) : shortcuts.map((shortcut) =>
renderShortcutCard(shortcut)
)}
</div>
</TabsContent>
</Tabs>
</div>
);
} }
File diff suppressed because it is too large Load Diff
@@ -1,100 +1,128 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Card} from '@/components/ui/card.tsx'; import { Card } from "@/components/ui/card.tsx";
import {Folder, File, Trash2, Pin} from 'lucide-react'; import { Folder, File, Trash2, Pin } from "lucide-react";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
interface SSHConnection { interface SSHConnection {
id: string; id: string;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
isPinned?: boolean; isPinned?: boolean;
} }
interface FileItem { interface FileItem {
name: string; name: string;
type: 'file' | 'directory' | 'link'; type: "file" | "directory" | "link";
path: string; path: string;
isStarred?: boolean; isStarred?: boolean;
} }
interface FileManagerLeftSidebarVileViewerProps { interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[]; sshConnections: SSHConnection[];
onAddSSH: () => void; onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void; onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void; onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void; onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void; onPinSSH: (conn: SSHConnection) => void;
currentPath: string; currentPath: string;
files: FileItem[]; files: FileItem[];
onOpenFile: (file: FileItem) => void; onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void; onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void; onStarFile: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void; onDeleteFile: (file: FileItem) => void;
isLoading?: boolean; isLoading?: boolean;
error?: string; error?: string;
isSSHMode: boolean; isSSHMode: boolean;
onSwitchToLocal: () => void; onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void; onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection; currentSSH?: SSHConnection;
} }
export function FileManagerLeftSidebarFileViewer({ export function FileManagerLeftSidebarFileViewer({
currentPath, currentPath,
files, files,
onOpenFile, onOpenFile,
onOpenFolder, onOpenFolder,
onStarFile, onStarFile,
onDeleteFile, onDeleteFile,
isLoading, isLoading,
error, error,
isSSHMode, isSSHMode,
}: FileManagerLeftSidebarVileViewerProps) { }: FileManagerLeftSidebarVileViewerProps) {
const {t} = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto"> <div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span <span className="text-xs text-muted-foreground font-semibold">
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span> {isSSHMode ? t("common.sshPath") : t("common.localPath")}
<span className="text-xs text-white truncate">{currentPath}</span> </span>
</div> <span className="text-xs text-white truncate">{currentPath}</span>
{isLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
<File className="w-4 h-4 text-muted-foreground"/>}
<span className="text-sm text-white truncate">{item.name}</span>
</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
</Card>
))}
{files.length === 0 &&
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div>
)}
</div>
</div> </div>
); {isLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card
key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() =>
item.type === "directory"
? onOpenFolder(item)
: onOpenFile(item)
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400" />
) : (
<File className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm text-white truncate">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onStarFile(item)}
>
<Pin
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</Card>
))}
{files.length === 0 && (
<div className="text-xs text-muted-foreground">
No files or folders found.
</div>
)}
</div>
)}
</div>
</div>
);
} }
File diff suppressed because it is too large Load Diff
@@ -1,52 +1,62 @@
import React from 'react'; import React from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {X, Home} from 'lucide-react'; import { X, Home } from "lucide-react";
interface FileManagerTab { interface FileManagerTab {
id: string | number; id: string | number;
title: string; title: string;
} }
interface FileManagerTabList { interface FileManagerTabList {
tabs: FileManagerTab[]; tabs: FileManagerTab[];
activeTab: string | number; activeTab: string | number;
setActiveTab: (tab: string | number) => void; setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void; closeTab: (tab: string | number) => void;
onHomeClick: () => void; onHomeClick: () => void;
} }
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) { export function FileManagerTabList({
return ( tabs,
<div className="inline-flex items-center h-full gap-2"> activeTab,
setActiveTab,
closeTab,
onHomeClick,
}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
<Home className="w-4 h-4" />
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className="inline-flex rounded-md shadow-sm"
role="group"
>
<Button <Button
onClick={onHomeClick} onClick={() => setActiveTab(tab.id)}
variant="outline" variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === 'home' ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`} className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
> >
<Home className="w-4 h-4"/> {tab.title}
</Button> </Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button <Button
onClick={() => closeTab(tab.id)} onClick={() => closeTab(tab.id)}
variant="outline" variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border" className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
> >
<X className="!w-4 !h-4" strokeWidth={2}/> <X className="!w-4 !h-4" strokeWidth={2} />
</Button> </Button>
</div> </div>
); );
})} })}
</div> </div>
); );
} }
+131 -103
View File
@@ -1,114 +1,142 @@
import React, {useState} from "react"; import React, { useState } from "react";
import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx" import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {
import {Separator} from "@/components/ui/separator.tsx"; Tabs,
import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx"; TabsContent,
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx"; TabsList,
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx"; TabsTrigger,
import {useSidebar} from "@/components/ui/sidebar.tsx"; } from "@/components/ui/tabs.tsx";
import {useTranslation} from "react-i18next"; import { Separator } from "@/components/ui/separator.tsx";
import type {SSHHost, HostManagerProps} from '../../../types/index'; import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { export function HostManager({
const {t} = useTranslation(); onSelectView,
const [activeTab, setActiveTab] = useState("host_viewer"); isTopbarOpen,
const [editingHost, setEditingHost] = useState<SSHHost | null>(null); }: HostManagerProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const [editingCredential, setEditingCredential] = useState<any | null>(null); const [editingCredential, setEditingCredential] = useState<any | null>(null);
const {state: sidebarState} = useSidebar(); const { state: sidebarState } = useSidebar();
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
setEditingHost(host); setEditingHost(host);
setActiveTab("add_host"); setActiveTab("add_host");
}; };
const handleFormSubmit = (updatedHost?: SSHHost) => { const handleFormSubmit = (updatedHost?: SSHHost) => {
setEditingHost(null); setEditingHost(null);
setActiveTab("host_viewer"); setActiveTab("host_viewer");
}; };
const handleEditCredential = (credential: any) => { const handleEditCredential = (credential: any) => {
setEditingCredential(credential); setEditingCredential(credential);
setActiveTab("add_credential"); setActiveTab("add_credential");
}; };
const handleCredentialFormSubmit = () => { const handleCredentialFormSubmit = () => {
setEditingCredential(null); setEditingCredential(null);
setActiveTab("credentials"); setActiveTab("credentials");
}; };
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value !== "add_host") {
setEditingHost(null);
}
if (value !== "add_credential") {
setEditingCredential(null);
}
};
const handleTabChange = (value: string) => { const topMarginPx = isTopbarOpen ? 74 : 26;
setActiveTab(value); const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
if (value !== "add_host") { const bottomMarginPx = 8;
setEditingHost(null);
}
if (value !== "add_credential") {
setEditingCredential(null);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26; return (
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; <div>
const bottomMarginPx = 8; <div className="w-full">
<div
return ( className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
<div> style={{
<div className="w-full"> marginLeft: leftMarginPx,
<div marginRight: 17,
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden" marginTop: topMarginPx,
style={{ marginBottom: bottomMarginPx,
marginLeft: leftMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
marginRight: 17, }}
marginTop: topMarginPx, >
marginBottom: bottomMarginPx, <Tabs
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)` value={activeTab}
}} onValueChange={handleTabChange}
> className="flex-1 flex flex-col h-full min-h-0"
<Tabs value={activeTab} onValueChange={handleTabChange} >
className="flex-1 flex flex-col h-full min-h-0"> <TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5"> <TabsTrigger value="host_viewer">
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger> {t("hosts.hostViewer")}
<TabsTrigger value="add_host"> </TabsTrigger>
{editingHost ? t('hosts.editHost') : t('hosts.addHost')} <TabsTrigger value="add_host">
</TabsTrigger> {editingHost ? t("hosts.editHost") : t("hosts.addHost")}
<div className="h-6 w-px bg-dark-border mx-1"></div> </TabsTrigger>
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger> <div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="add_credential"> <TabsTrigger value="credentials">
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')} {t("credentials.credentialsViewer")}
</TabsTrigger> </TabsTrigger>
</TabsList> <TabsTrigger value="add_credential">
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0"> {editingCredential
<Separator className="p-0.25 -mt-0.5 mb-1"/> ? t("credentials.editCredential")
<HostManagerViewer onEditHost={handleEditHost}/> : t("credentials.addCredential")}
</TabsContent> </TabsTrigger>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0"> </TabsList>
<Separator className="p-0.25 -mt-0.5 mb-1"/> <TabsContent
<div className="flex flex-col h-full min-h-0"> value="host_viewer"
<HostManagerEditor className="flex-1 flex flex-col h-full min-h-0"
editingHost={editingHost} >
onFormSubmit={handleFormSubmit} <Separator className="p-0.25 -mt-0.5 mb-1" />
/> <HostManagerViewer onEditHost={handleEditHost} />
</div> </TabsContent>
</TabsContent> <TabsContent
<TabsContent value="credentials" className="flex-1 flex flex-col h-full min-h-0"> value="add_host"
<Separator className="p-0.25 -mt-0.5 mb-1"/> className="flex-1 flex flex-col h-full min-h-0"
<div className="flex flex-col h-full min-h-0 overflow-auto"> >
<CredentialsManager onEditCredential={handleEditCredential}/> <Separator className="p-0.25 -mt-0.5 mb-1" />
</div> <div className="flex flex-col h-full min-h-0">
</TabsContent> <HostManagerEditor
<TabsContent value="add_credential" className="flex-1 flex flex-col h-full min-h-0"> editingHost={editingHost}
<Separator className="p-0.25 -mt-0.5 mb-1"/> onFormSubmit={handleFormSubmit}
<div className="flex flex-col h-full min-h-0"> />
<CredentialEditor </div>
editingCredential={editingCredential} </TabsContent>
onFormSubmit={handleCredentialFormSubmit} <TabsContent
/> value="credentials"
</div> className="flex-1 flex flex-col h-full min-h-0"
</TabsContent> >
</Tabs> <Separator className="p-0.25 -mt-0.5 mb-1" />
</div> <div className="flex flex-col h-full min-h-0 overflow-auto">
</div> <CredentialsManager onEditCredential={handleEditCredential} />
</div>
</TabsContent>
<TabsContent
value="add_credential"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0">
<CredentialEditor
editingCredential={editingCredential}
onFormSubmit={handleCredentialFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div> </div>
) </div>
</div>
);
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+457 -397
View File
@@ -1,418 +1,478 @@
import React from "react"; import React from "react";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Progress} from "@/components/ui/progress.tsx" import { Progress } from "@/components/ui/progress.tsx";
import {Cpu, HardDrive, MemoryStick} from "lucide-react"; import { Cpu, HardDrive, MemoryStick } from "lucide-react";
import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx"; import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts"; import {
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; getServerStatusById,
import {useTranslation} from 'react-i18next'; getServerMetricsById,
import {toast} from 'sonner'; type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface ServerProps { interface ServerProps {
hostConfig?: any; hostConfig?: any;
title?: string; title?: string;
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
embedded?: boolean; embedded?: boolean;
} }
export function Server({ export function Server({
hostConfig, hostConfig,
title, title,
isVisible = true, isVisible = true,
isTopbarOpen = true, isTopbarOpen = true,
embedded = false embedded = false,
}: ServerProps): React.ReactElement { }: ServerProps): React.ReactElement {
const {t} = useTranslation(); const { t } = useTranslation();
const {state: sidebarState} = useSidebar(); const { state: sidebarState } = useSidebar();
const {addTab, tabs} = useTabs() as any; const { addTab, tabs } = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); "offline",
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); );
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [isRefreshing, setIsRefreshing] = React.useState(false); const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setCurrentHostConfig(hostConfig); setCurrentHostConfig(hostConfig);
}, [hostConfig]); }, [hostConfig]);
React.useEffect(() => { React.useEffect(() => {
const fetchLatestHostConfig = async () => { const fetchLatestHostConfig = async () => {
if (hostConfig?.id) { if (hostConfig?.id) {
try { try {
const {getSSHHosts} = await import('@/ui/main-axios.ts'); const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts(); const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id); const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) { if (updatedHost) {
setCurrentHostConfig(updatedHost); setCurrentHostConfig(updatedHost);
} }
} catch (error) { } catch (error) {
toast.error(t('serverStats.failedToFetchHostConfig')); toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
toast.error(t('serverStats.failedToFetchHostConfig'));
}
}
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch (error: any) {
if (!cancelled) {
if (error?.response?.status === 503) {
setServerStatus('offline');
} else if (error?.response?.status === 504) {
setServerStatus('offline');
} else if (error?.response?.status === 404) {
setServerStatus('offline');
} else {
setServerStatus('offline');
}
toast.error(t('serverStats.failedToFetchStatus'));
}
}
};
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
setIsLoadingMetrics(true);
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
}
} catch (error) {
if (!cancelled) {
setMetrics(null);
toast.error(t('serverStats.failedToFetchMetrics'));
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
}
}
};
if (currentHostConfig?.id && isVisible) {
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
if (isVisible) {
fetchStatus();
fetchMetrics();
}
}, 30000);
} }
}
};
return () => { fetchLatestHostConfig();
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible]);
const topMarginPx = isTopbarOpen ? 74 : 16; const handleHostsChanged = async () => {
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8; if (hostConfig?.id) {
const bottomMarginPx = 8; try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
const isFileManagerAlreadyOpen = React.useMemo(() => { window.addEventListener("ssh-hosts:changed", handleHostsChanged);
if (!currentHostConfig) return false; return () =>
return tabs.some((tab: any) => window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
tab.type === 'file_manager' && }, [hostConfig?.id]);
tab.hostConfig?.id === currentHostConfig.id
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded React.useEffect(() => {
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'} let cancelled = false;
: { let intervalId: number | undefined;
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded const fetchStatus = async () => {
? "h-full w-full text-white overflow-hidden bg-transparent" try {
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"; const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: any) {
if (!cancelled) {
if (error?.response?.status === 503) {
setServerStatus("offline");
} else if (error?.response?.status === 504) {
setServerStatus("offline");
} else if (error?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
toast.error(t("serverStats.failedToFetchStatus"));
}
}
};
return ( const fetchMetrics = async () => {
<div style={wrapperStyle} className={containerClass}> if (!currentHostConfig?.id) return;
<div className="h-full w-full flex flex-col"> try {
setIsLoadingMetrics(true);
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
}
} catch (error) {
if (!cancelled) {
setMetrics(null);
toast.error(t("serverStats.failedToFetchMetrics"));
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
}
}
};
{/* Top Header */} if (currentHostConfig?.id && isVisible) {
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3"> fetchStatus();
<div className="flex items-center gap-4 min-w-0"> fetchMetrics();
<div className="min-w-0"> intervalId = window.setInterval(() => {
<h1 className="font-bold text-lg truncate"> if (isVisible) {
{currentHostConfig?.folder} / {title} fetchStatus();
</h1> fetchMetrics();
</div> }
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0"> }, 30000);
<StatusIndicator/> }
</Status>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
const data = await getServerMetricsById(currentHostConfig.id);
setMetrics(data);
} catch (error: any) {
if (error?.response?.status === 503) {
setServerStatus('offline');
} else if (error?.response?.status === 504) {
setServerStatus('offline');
} else if (error?.response?.status === 404) {
setServerStatus('offline');
} else {
setServerStatus('offline');
}
setMetrics(null);
} finally {
setIsRefreshing(false);
}
}
}}
title={t('serverStats.refreshStatusAndMetrics')}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div
className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t('serverStats.refreshing')}
</div>
) : (
t('serverStats.refreshStatus')
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: 'file_manager',
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t('nav.fileManager')}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full"/>
{/* Stats */} return () => {
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4"> cancelled = true;
{isLoadingMetrics && !metrics ? ( if (intervalId) window.clearInterval(intervalId);
<div className="flex items-center justify-center py-8"> };
<div className="flex items-center gap-3"> }, [currentHostConfig?.id, isVisible]);
<div
className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t('serverStats.loadingMetrics')}</span>
</div>
</div>
) : !metrics && serverStatus === 'offline' ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div
className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">{t('serverStats.serverOffline')}</p>
<p className="text-sm text-gray-500">{t('serverStats.cannotFetchMetrics')}</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400"/>
<h3 className="font-semibold text-lg text-white">{t('serverStats.cpuUsage')}</h3>
</div>
<div className="space-y-2"> const topMarginPx = isTopbarOpen ? 74 : 16;
<div className="flex justify-between items-center"> const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
<span className="text-sm text-gray-300"> const bottomMarginPx = 8;
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
return `${pctText} ${t('serverStats.of')} ${coresText}`;
})()}
</span>
</div>
<div className="relative"> const isFileManagerAlreadyOpen = React.useMemo(() => {
<Progress if (!currentHostConfig) return false;
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} return tabs.some(
className="h-2" (tab: any) =>
/> tab.type === "file_manager" &&
</div> tab.hostConfig?.id === currentHostConfig.id,
<div className="text-xs text-gray-500">
{metrics?.cpu?.load ?
`Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}` :
'Load: N/A'
}
</div>
</div>
</div>
{/* Memory Stats */}
<div
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400"/>
<h3 className="font-semibold text-lg text-white">{t('serverStats.memoryUsage')}</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = (typeof used === 'number') ? `${used.toFixed(1)} GiB` : 'N/A';
const totalText = (typeof total === 'number') ? `${total.toFixed(1)} GiB` : 'N/A';
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free = (typeof used === 'number' && typeof total === 'number') ? (total - used).toFixed(1) : 'N/A';
return `Free: ${free} GiB`;
})()}
</div>
</div>
</div>
{/* Disk Stats */}
<div
className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400"/>
<h3 className="font-semibold text-lg text-white">{t('serverStats.rootStorageSpace')}</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A';
return `${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
return used && total ? `Available: ${total}` : 'Available: N/A';
})()}
</div>
</div>
</div>
</div>
)}
</div>
{/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div
className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t('serverStats.feedbackMessage')}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
); );
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
{/* Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
} catch (error: any) {
if (error?.response?.status === 503) {
setServerStatus("offline");
} else if (error?.response?.status === 504) {
setServerStatus("offline");
} else if (error?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
setMetrics(null);
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full" />
{/* Stats */}
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{/* CPU Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const coresText =
typeof cores === "number"
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus");
return `${pctText} ${t("serverStats.of")} ${coresText}`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.cpu?.percent === "number"
? metrics!.cpu!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{metrics?.cpu?.load
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
: "Load: N/A"}
</div>
</div>
</div>
{/* Memory Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText =
typeof used === "number"
? `${used.toFixed(1)} GiB`
: "N/A";
const totalText =
typeof total === "number"
? `${total.toFixed(1)} GiB`
: "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.memory?.percent === "number"
? metrics!.memory!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `Free: ${free} GiB`;
})()}
</div>
</div>
</div>
{/* Disk Stats */}
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.rootStorageSpace")}
</h3>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-300">
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText =
typeof pct === "number" ? `${pct}%` : "N/A";
const usedText = used ?? "N/A";
const totalText = total ?? "N/A";
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
})()}
</span>
</div>
<div className="relative">
<Progress
value={
typeof metrics?.disk?.percent === "number"
? metrics!.disk!.percent!
: 0
}
className="h-2"
/>
</div>
<div className="text-xs text-gray-500">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
return used && total
? `Available: ${total}`
: "Available: N/A";
})()}
</div>
</div>
</div>
</div>
)}
</div>
{/* SSH Tunnels */}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t("serverStats.feedbackMessage")}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
);
} }
File diff suppressed because it is too large Load Diff
+200 -157
View File
@@ -1,163 +1,206 @@
import React, {useState, useEffect, useCallback} from "react"; import React, { useState, useEffect, useCallback } from "react";
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx"; import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts"; import {
import type {SSHHost, TunnelConnection, TunnelStatus, SSHTunnelProps} from '../../../types/index.js'; getSSHHosts,
getTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
} from "@/ui/main-axios.ts";
import type {
SSHHost,
TunnelConnection,
TunnelStatus,
SSHTunnelProps,
} from "../../../types/index.js";
export function Tunnel({filterHostKey}: SSHTunnelProps): React.ReactElement { export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]); const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]); const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<Record<string, TunnelStatus>>({}); const [tunnelStatuses, setTunnelStatuses] = useState<
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>({}); Record<string, TunnelStatus>
>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
{},
);
const prevVisibleHostRef = React.useRef<SSHHost | null>(null); const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const haveTunnelConnectionsChanged = (a: TunnelConnection[] = [], b: TunnelConnection[] = []): boolean => { const haveTunnelConnectionsChanged = (
if (a.length !== b.length) return true; a: TunnelConnection[] = [],
for (let i = 0; i < a.length; i++) { b: TunnelConnection[] = [],
const x = a[i]; ): boolean => {
const y = b[i]; if (a.length !== b.length) return true;
if ( for (let i = 0; i < a.length; i++) {
x.sourcePort !== y.sourcePort || const x = a[i];
x.endpointPort !== y.endpointPort || const y = b[i];
x.endpointHost !== y.endpointHost || if (
x.maxRetries !== y.maxRetries || x.sourcePort !== y.sourcePort ||
x.retryInterval !== y.retryInterval || x.endpointPort !== y.endpointPort ||
x.autoStart !== y.autoStart x.endpointHost !== y.endpointHost ||
) { x.maxRetries !== y.maxRetries ||
return true; x.retryInterval !== y.retryInterval ||
} x.autoStart !== y.autoStart
} ) {
return false; return true;
}
}
return false;
};
const fetchHosts = useCallback(async () => {
const hostsData = await getSSHHosts();
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter((h) => {
const key =
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(
prev.tunnelConnections,
curr.tunnelConnections,
)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
const handleHostsChanged = () => {
fetchHosts();
}; };
window.addEventListener(
const fetchHosts = useCallback(async () => { "ssh-hosts:changed",
const hostsData = await getSSHHosts(); handleHostsChanged as EventListener,
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter(h => {
const key = (h.name && h.name.trim() !== '') ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(prev.tunnelConnections, curr.tunnelConnections)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => {
clearInterval(interval);
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
};
}, [fetchHosts]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions(prev => ({...prev, [tunnelName]: true}));
try {
if (action === 'connect') {
const endpointHost = allHosts.find(h =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost
);
if (!endpointHost) {
throw new Error('Endpoint host not found');
}
const tunnelConfig = {
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.authType === 'password' ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === 'key' ? host.key : undefined,
sourceKeyPassword: host.authType === 'key' ? host.keyPassword : undefined,
sourceKeyType: host.authType === 'key' ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword: endpointHost.authType === 'password' ? endpointHost.password : undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey: endpointHost.authType === 'key' ? endpointHost.key : undefined,
endpointKeyPassword: endpointHost.authType === 'key' ? endpointHost.keyPassword : undefined,
endpointKeyType: endpointHost.authType === 'key' ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin
};
await connectTunnel(tunnelConfig);
} else if (action === 'disconnect') {
await disconnectTunnel(tunnelName);
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch (err) {
} finally {
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
}
};
return (
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
); );
return () => {
clearInterval(interval);
window.removeEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
};
}, [fetchHosts]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleTunnelAction = async (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
try {
if (action === "connect") {
const endpointHost = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (!endpointHost) {
throw new Error("Endpoint host not found");
}
const tunnelConfig = {
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword:
host.authType === "password" ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === "key" ? host.key : undefined,
sourceKeyPassword:
host.authType === "key" ? host.keyPassword : undefined,
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword:
endpointHost.authType === "password"
? endpointHost.password
: undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey:
endpointHost.authType === "key" ? endpointHost.key : undefined,
endpointKeyPassword:
endpointHost.authType === "key"
? endpointHost.keyPassword
: undefined,
endpointKeyType:
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin,
};
await connectTunnel(tunnelConfig);
} else if (action === "disconnect") {
await disconnectTunnel(tunnelName);
} else if (action === "cancel") {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch (err) {
} finally {
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
}
};
return (
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
} }
+512 -414
View File
@@ -1,435 +1,533 @@
import React from "react"; import React from "react";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Card} from "@/components/ui/card.tsx"; import { Card } from "@/components/ui/card.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import { import {
Loader2, Loader2,
Pin, Pin,
Network, Network,
Tag, Tag,
Play, Play,
Square, Square,
AlertCircle, AlertCircle,
Clock, Clock,
Wifi, Wifi,
WifiOff, WifiOff,
X X,
} from "lucide-react"; } from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import type {TunnelStatus, SSHTunnelObjectProps} from '../../../types/index.js'; import type {
TunnelStatus,
SSHTunnelObjectProps,
} from "../../../types/index.js";
export function TunnelObject({ export function TunnelObject({
host, host,
tunnelStatuses, tunnelStatuses,
tunnelActions, tunnelActions,
onTunnelAction, onTunnelAction,
compact = false, compact = false,
bare = false bare = false,
}: SSHTunnelObjectProps): React.ReactElement { }: SSHTunnelObjectProps): React.ReactElement {
const {t} = useTranslation(); const { t } = useTranslation();
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex]; const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName]; return tunnelStatuses[tunnelName];
}; };
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status) return { if (!status)
icon: <WifiOff className="h-4 w-4"/>, return {
text: t('tunnels.unknown'), icon: <WifiOff className="h-4 w-4" />,
color: 'text-muted-foreground', text: t("tunnels.unknown"),
bgColor: 'bg-muted/50', color: "text-muted-foreground",
borderColor: 'border-border' bgColor: "bg-muted/50",
borderColor: "border-border",
};
const statusValue = status.status || "DISCONNECTED";
switch (statusValue.toUpperCase()) {
case "CONNECTED":
return {
icon: <Wifi className="h-4 w-4" />,
text: t("tunnels.connected"),
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10 dark:bg-green-400/10",
borderColor: "border-green-500/20 dark:border-green-400/20",
};
case "CONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.connecting"),
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "DISCONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.disconnecting"),
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
borderColor: "border-orange-500/20 dark:border-orange-400/20",
};
case "DISCONNECTED":
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.disconnected"),
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
};
case "WAITING":
return {
icon: <Clock className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "ERROR":
case "FAILED":
return {
icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || t("tunnels.error"),
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-500/10 dark:bg-red-400/10",
borderColor: "border-red-500/20 dark:border-red-400/20",
};
default:
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
}; };
const statusValue = status.status || 'DISCONNECTED';
switch (statusValue.toUpperCase()) {
case 'CONNECTED':
return {
icon: <Wifi className="h-4 w-4"/>,
text: t('tunnels.connected'),
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
borderColor: 'border-green-500/20 dark:border-green-400/20'
};
case 'CONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: t('tunnels.connecting'),
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'DISCONNECTING':
return {
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
text: t('tunnels.disconnecting'),
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
};
case 'DISCONNECTED':
return {
icon: <WifiOff className="h-4 w-4"/>,
text: t('tunnels.disconnected'),
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
case 'WAITING':
return {
icon: <Clock className="h-4 w-4"/>,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
};
case 'ERROR':
case 'FAILED':
return {
icon: <AlertCircle className="h-4 w-4"/>,
text: status.reason || t('tunnels.error'),
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
borderColor: 'border-red-500/20 dark:border-red-400/20'
};
default:
return {
icon: <WifiOff className="h-4 w-4"/>,
text: statusValue,
color: 'text-muted-foreground',
bgColor: 'bg-muted/30',
borderColor: 'border-border'
};
}
};
if (bare) {
return (
<div className="w-full min-w-0">
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t('tunnels.port')} {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1"/>
{t('tunnels.disconnect')}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1"/>
{t('tunnels.cancel')}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1"/>
{t('tunnels.connect')}
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
</Button>
)}
</div>
</div>
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t('tunnels.checkDockerLogs')} <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
</div>
<div>
{t('tunnels.attempt', {
current: status.retryCount,
max: status.maxRetries
})}
{status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
</div>
)}
</div>
</div>
);
} }
};
if (bare) {
return ( return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0"> <div className="w-full min-w-0">
<div className="p-4"> <div className="space-y-3">
{!compact && ( {host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="flex items-center justify-between gap-2 mb-3"> <div className="space-y-3">
<div className="flex items-center gap-2 flex-1 min-w-0"> {host.tunnelConnections.map((tunnel, tunnelIndex) => {
{host.pin && <Pin className="h-4 w-4 text-yellow-500 flex-shrink-0"/>} const status = getTunnelStatus(tunnelIndex);
<div className="flex-1 min-w-0"> const statusDisplay = getTunnelStatusDisplay(status);
<h3 className="font-semibold text-card-foreground truncate"> const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
{host.name || `${host.username}@${host.ip}`} const isActionLoading = tunnelActions[tunnelName];
</h3> const statusValue =
<p className="text-xs text-muted-foreground truncate"> status?.status?.toUpperCase() || "DISCONNECTED";
{host.ip}:{host.port} {host.username} const isConnected = statusValue === "CONNECTED";
</p> const isConnecting = statusValue === "CONNECTING";
</div> const isDisconnecting = statusValue === "DISCONNECTING";
</div> const isRetrying = statusValue === "RETRYING";
</div> const isWaiting = statusValue === "WAITING";
)}
{!compact && host.tags && host.tags.length > 0 && ( return (
<div className="flex flex-wrap gap-1 mb-3"> <div
{host.tags.slice(0, 3).map((tag, index) => ( key={tunnelIndex}
<Badge key={index} variant="secondary" className="text-xs px-1 py-0"> className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
<Tag className="h-2 w-2 mr-0.5"/> >
{tag} <div className="flex items-start justify-between gap-2">
</Badge> <div className="flex items-start gap-2 flex-1 min-w-0">
))} <span
{host.tags.length > 3 && ( className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
<Badge variant="outline" className="text-xs px-1 py-0"> >
+{host.tags.length - 3} {statusDisplay.icon}
</Badge> </span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)} )}
</div>
</div> </div>
)}
{!compact && <Separator className="mb-3"/>} {(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
</a>{" "}
or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
</a>{" "}
for help.
</div>
</>
)}
</div>
)}
<div className="space-y-3"> {(statusValue === "RETRYING" ||
{!compact && ( statusValue === "WAITING") &&
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2"> status?.retryCount &&
<Network className="h-4 w-4"/> status?.maxRetries && (
{t('tunnels.tunnelConnections')} ({host.tunnelConnections.length}) <div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
</h4> <div className="font-medium mb-1">
)} {statusValue === "WAITING"
{host.tunnelConnections && host.tunnelConnections.length > 0 ? ( ? t("tunnels.waitingForRetry")
<div className="space-y-3"> : t("tunnels.retryingConnection")}
{host.tunnelConnections.map((tunnel, tunnelIndex) => { </div>
const status = getTunnelStatus(tunnelIndex); <div>
const statusDisplay = getTunnelStatusDisplay(status); {t("tunnels.attempt", {
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`; current: status.retryCount,
const isActionLoading = tunnelActions[tunnelName]; max: status.maxRetries,
const statusValue = status?.status?.toUpperCase() || 'DISCONNECTED';
const isConnected = statusValue === 'CONNECTED';
const isConnecting = statusValue === 'CONNECTING';
const isDisconnecting = statusValue === 'DISCONNECTING';
const isRetrying = statusValue === 'RETRYING';
const isWaiting = statusValue === 'WAITING';
return (
<div key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t('tunnels.port')} {tunnel.sourcePort} {tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div className={`text-xs ${statusDisplay.color} font-medium`}>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1"/>
{t('tunnels.disconnect')}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1"/>
{t('tunnels.cancel')}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1"/>
{t('tunnels.connect')}
</Button>
)}
</div>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
</Button>
)}
</div>
</div>
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
<div
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
{status.reason}
{status.reason && status.reason.includes('Max retries exhausted') && (
<>
<div
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t('tunnels.checkDockerLogs')} <a
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</div>
</>
)}
</div>
)}
{(statusValue === 'RETRYING' || statusValue === 'WAITING') && status?.retryCount && status?.maxRetries && (
<div
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
</div>
<div>
{t('tunnels.attempt', {
current: status.retryCount,
max: status.maxRetries
})}
{status.nextRetryIn && (
<span> {t('tunnels.nextRetryIn', {seconds: status.nextRetryIn})}</span>
)}
</div>
</div>
)}
</div>
);
})} })}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div> </div>
) : ( )}
<div className="text-center py-4 text-muted-foreground"> </div>
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/> );
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p> })}
</div>
)}
</div>
</div> </div>
</Card> ) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
); );
}
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && (
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
)}
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" />
{tag}
</Badge>
))}
{host.tags.length > 3 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 3}
</Badge>
)}
</div>
)}
{!compact && <Separator className="mb-3" />}
<div className="space-y-3">
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
const isConnected = statusValue === "CONNECTED";
const isConnecting = statusValue === "CONNECTING";
const isDisconnecting = statusValue === "DISCONNECTING";
const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return (
<div
key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
</Button>
)}
</div>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)}
</div>
</div>
{(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
</a>{" "}
or create a{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
</a>{" "}
for help.
</div>
</>
)}
</div>
)}
{(statusValue === "RETRYING" ||
statusValue === "WAITING") &&
status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div>
<div>
{t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
})}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
</Card>
);
} }
+67 -46
View File
@@ -1,56 +1,77 @@
import React from "react"; import React from "react";
import {TunnelObject} from "./TunnelObject.tsx"; import { TunnelObject } from "./TunnelObject.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import type {SSHHost, TunnelConnection, TunnelStatus} from '../../../types/index.js'; import type {
SSHHost,
TunnelConnection,
TunnelStatus,
} from "../../../types/index.js";
interface SSHTunnelViewerProps { interface SSHTunnelViewerProps {
hosts: SSHHost[]; hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>; tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>; tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>; onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
} }
export function TunnelViewer({ export function TunnelViewer({
hosts = [], hosts = [],
tunnelStatuses = {}, tunnelStatuses = {},
tunnelActions = {}, tunnelActions = {},
onTunnelAction onTunnelAction,
}: SSHTunnelViewerProps): React.ReactElement { }: SSHTunnelViewerProps): React.ReactElement {
const {t} = useTranslation(); const { t } = useTranslation();
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; const activeHost: SSHHost | undefined =
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3>
<p className="text-muted-foreground max-w-md">
{t('tunnels.createFirstTunnelMessage')}
</p>
</div>
);
}
if (
!activeHost ||
!activeHost.tunnelConnections ||
activeHost.tunnelConnections.length === 0
) {
return ( return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0"> <div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<div className="w-full flex-shrink-0 mb-2"> <h3 className="text-lg font-semibold text-foreground mb-2">
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1> {t("tunnels.noSshTunnels")}
</div> </h3>
<div className="min-h-0 flex-1 overflow-auto pr-1"> <p className="text-muted-foreground max-w-md">
<div {t("tunnels.createFirstTunnelMessage")}
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> </p>
{activeHost.tunnelConnections.map((t, idx) => ( </div>
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
compact
bare
/>
))}
</div>
</div>
</div>
); );
}
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{
...activeHost,
tunnelConnections: [activeHost.tunnelConnections[idx]],
}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) =>
onTunnelAction(action, activeHost, idx)
}
compact
bare
/>
))}
</div>
</div>
</div>
);
} }
+173 -151
View File
@@ -1,88 +1,103 @@
import React, {useState, useEffect} from "react" import React, { useState, useEffect } from "react";
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx" import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx" import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx" import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx" import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx" import {
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx"; TabProvider,
import {AdminSettings} from "@/ui/Desktop/Admin/AdminSettings.tsx"; useTabs,
import {UserProfile} from "@/ui/Desktop/User/UserProfile.tsx"; } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {Toaster} from "@/components/ui/sonner.tsx"; import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import {getUserInfo, getCookie} from "@/ui/main-axios.ts"; import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
function AppContent() { function AppContent() {
const [view, setView] = useState<string>("homepage") const [view, setView] = useState<string>("homepage");
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"])) const [mountedViews, setMountedViews] = useState<Set<string>>(
const [isAuthenticated, setIsAuthenticated] = useState(false) new Set(["homepage"]),
const [username, setUsername] = useState<string | null>(null) );
const [isAdmin, setIsAdmin] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authLoading, setAuthLoading] = useState(true) const [username, setUsername] = useState<string | null>(null);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true) const [isAdmin, setIsAdmin] = useState(false);
const {currentTab, tabs} = useTabs(); const [authLoading, setAuthLoading] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const { currentTab, tabs } = useTabs();
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (jwt) { if (jwt) {
setAuthLoading(true); setAuthLoading(true);
getUserInfo() getUserInfo()
.then((meRes) => { .then((meRes) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
}) })
.catch((err) => { .catch((err) => {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie =
}) "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
.finally(() => setAuthLoading(false)); })
} else { .finally(() => setAuthLoading(false));
setIsAuthenticated(false); } else {
setIsAdmin(false); setIsAuthenticated(false);
setUsername(null); setIsAdmin(false);
setAuthLoading(false); setUsername(null);
} setAuthLoading(false);
} }
};
checkAuth() checkAuth();
const handleStorageChange = () => checkAuth() const handleStorageChange = () => checkAuth();
window.addEventListener('storage', handleStorageChange) window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange) return () => window.removeEventListener("storage", handleStorageChange);
}, []) }, []);
const handleSelectView = (nextView: string) => { const handleSelectView = (nextView: string) => {
setMountedViews((prev) => { setMountedViews((prev) => {
if (prev.has(nextView)) return prev if (prev.has(nextView)) return prev;
const next = new Set(prev) const next = new Set(prev);
next.add(nextView) next.add(nextView);
return next return next;
}) });
setView(nextView) setView(nextView);
} };
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { const handleAuthSuccess = (authData: {
setIsAuthenticated(true) isAdmin: boolean;
setIsAdmin(authData.isAdmin) username: string | null;
setUsername(authData.username) userId: string | null;
} }) => {
setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
setUsername(authData.username);
};
const currentTabData = tabs.find(tab => tab.id === currentTab); const currentTabData = tabs.find((tab) => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager'; const showTerminalView =
const showHome = currentTabData?.type === 'home'; currentTabData?.type === "terminal" ||
const showSshManager = currentTabData?.type === 'ssh_manager'; currentTabData?.type === "server" ||
const showAdmin = currentTabData?.type === 'admin'; currentTabData?.type === "file_manager";
const showProfile = currentTabData?.type === 'user_profile'; const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin";
const showProfile = currentTabData?.type === "user_profile";
return ( return (
<div>
{!isAuthenticated && !authLoading && (
<div> <div>
{!isAuthenticated && !authLoading && ( <div
<div> className="absolute inset-0"
<div className="absolute inset-0" style={{ style={{
backgroundImage: `linear-gradient( backgroundImage: `linear-gradient(
135deg, 135deg,
transparent 0%, transparent 0%,
transparent 49%, transparent 49%,
@@ -91,86 +106,93 @@ function AppContent() {
transparent 51%, transparent 51%,
transparent 100% transparent 100%
)`, )`,
backgroundSize: '80px 80px' backgroundSize: "80px 80px",
}}/> }}
</div> />
)}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
{showTerminalView && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AppView isTopbarOpen={isTopbarOpen}/>
</div>
)}
{showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
</div>
)}
{showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AdminSettings isTopbarOpen={isTopbarOpen}/>
</div>
)}
{showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
<UserProfile isTopbarOpen={isTopbarOpen}/>
</div>
)}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div> </div>
) )}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
{showTerminalView && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AppView isTopbarOpen={isTopbarOpen} />
</div>
)}
{showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<HostManager
onSelectView={handleSelectView}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
)}
{showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto">
<UserProfile isTopbarOpen={isTopbarOpen} />
</div>
)}
<TopNavbar
isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen}
/>
</LeftSidebar>
)}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
);
} }
function DesktopApp() { function DesktopApp() {
return ( return (
<TabProvider> <TabProvider>
<AppContent/> <AppContent />
</TabProvider> </TabProvider>
); );
} }
export default DesktopApp export default DesktopApp;
+211 -194
View File
@@ -1,216 +1,233 @@
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect } from "react";
import {Button} from '@/components/ui/button.tsx'; import { Button } from "@/components/ui/button.tsx";
import {Input} from '@/components/ui/input.tsx'; import { Input } from "@/components/ui/input.tsx";
import {Label} from '@/components/ui/label.tsx'; import { Label } from "@/components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert.tsx'; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import {getServerConfig, saveServerConfig, testServerConnection, type ServerConfig} from '@/ui/main-axios.ts'; import {
import {CheckCircle, XCircle, Server, Wifi} from 'lucide-react'; getServerConfig,
saveServerConfig,
testServerConnection,
type ServerConfig,
} from "@/ui/main-axios.ts";
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
interface ServerConfigProps { interface ServerConfigProps {
onServerConfigured: (serverUrl: string) => void; onServerConfigured: (serverUrl: string) => void;
onCancel?: () => void; onCancel?: () => void;
isFirstTime?: boolean; isFirstTime?: boolean;
} }
export function ServerConfig({onServerConfigured, onCancel, isFirstTime = false}: ServerConfigProps) { export function ServerConfig({
const {t} = useTranslation(); onServerConfigured,
const [serverUrl, setServerUrl] = useState(''); onCancel,
const [loading, setLoading] = useState(false); isFirstTime = false,
const [testing, setTesting] = useState(false); }: ServerConfigProps) {
const [error, setError] = useState<string | null>(null); const { t } = useTranslation();
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown'); const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "success" | "error"
>("unknown");
useEffect(() => { useEffect(() => {
loadServerConfig(); loadServerConfig();
}, []); }, []);
const loadServerConfig = async () => { const loadServerConfig = async () => {
try { try {
const config = await getServerConfig(); const config = await getServerConfig();
if (config?.serverUrl) { if (config?.serverUrl) {
setServerUrl(config.serverUrl); setServerUrl(config.serverUrl);
setConnectionStatus('success'); setConnectionStatus("success");
} }
} catch (error) { } catch (error) {}
} };
};
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!serverUrl.trim()) { if (!serverUrl.trim()) {
setError(t('serverConfig.enterServerUrl')); setError(t("serverConfig.enterServerUrl"));
return; return;
} }
setTesting(true); setTesting(true);
setError(null); setError(null);
try { try {
let normalizedUrl = serverUrl.trim(); let normalizedUrl = serverUrl.trim();
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { if (
normalizedUrl = `http://${normalizedUrl}`; !normalizedUrl.startsWith("http://") &&
} !normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`;
}
const result = await testServerConnection(normalizedUrl); const result = await testServerConnection(normalizedUrl);
if (result.success) { if (result.success) {
setConnectionStatus('success'); setConnectionStatus("success");
} else { } else {
setConnectionStatus('error'); setConnectionStatus("error");
setError(result.error || t('serverConfig.connectionFailed')); setError(result.error || t("serverConfig.connectionFailed"));
} }
} catch (error) { } catch (error) {
setConnectionStatus('error'); setConnectionStatus("error");
setError(t('serverConfig.connectionError')); setError(t("serverConfig.connectionError"));
} finally { } finally {
setTesting(false); setTesting(false);
} }
}; };
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
if (!serverUrl.trim()) { if (!serverUrl.trim()) {
setError(t('serverConfig.enterServerUrl')); setError(t("serverConfig.enterServerUrl"));
return; return;
} }
if (connectionStatus !== 'success') { if (connectionStatus !== "success") {
setError(t('serverConfig.testConnectionFirst')); setError(t("serverConfig.testConnectionFirst"));
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
let normalizedUrl = serverUrl.trim(); let normalizedUrl = serverUrl.trim();
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { if (
normalizedUrl = `http://${normalizedUrl}`; !normalizedUrl.startsWith("http://") &&
} !normalizedUrl.startsWith("https://")
) {
normalizedUrl = `http://${normalizedUrl}`;
}
const config: ServerConfig = { const config: ServerConfig = {
serverUrl: normalizedUrl, serverUrl: normalizedUrl,
lastUpdated: new Date().toISOString() lastUpdated: new Date().toISOString(),
}; };
const success = await saveServerConfig(config); const success = await saveServerConfig(config);
if (success) { if (success) {
onServerConfigured(normalizedUrl); onServerConfigured(normalizedUrl);
} else { } else {
setError(t('serverConfig.saveFailed')); setError(t("serverConfig.saveFailed"));
} }
} catch (error) { } catch (error) {
setError(t('serverConfig.saveError')); setError(t("serverConfig.saveError"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleUrlChange = (value: string) => { const handleUrlChange = (value: string) => {
setServerUrl(value); setServerUrl(value);
setConnectionStatus('unknown'); setConnectionStatus("unknown");
setError(null); setError(null);
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center"> <div className="mx-auto mb-4 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
<Server className="w-6 h-6 text-primary"/> <Server className="w-6 h-6 text-primary" />
</div>
<h2 className="text-xl font-semibold">{t('serverConfig.title')}</h2>
<p className="text-sm text-muted-foreground mt-2">
{t('serverConfig.description')}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-url">{t('serverConfig.serverUrl')}</Label>
<div className="flex space-x-2">
<Input
id="server-url"
type="text"
placeholder="http://localhost:8081 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="flex-1 h-10"
disabled={loading}
/>
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testing || !serverUrl.trim() || loading}
className="w-10 h-10 p-0 flex items-center justify-center"
>
{testing ? (
<div
className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
) : (
<Wifi className="w-4 h-4"/>
)}
</Button>
</div>
</div>
{connectionStatus !== 'unknown' && (
<div className="flex items-center space-x-2 text-sm">
{connectionStatus === 'success' ? (
<>
<CheckCircle className="w-4 h-4 text-green-500"/>
<span className="text-green-600">{t('serverConfig.connected')}</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500"/>
<span className="text-red-600">{t('serverConfig.disconnected')}</span>
</>
)}
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex space-x-2">
{onCancel && !isFirstTime && (
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
)}
<Button
type="button"
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
onClick={handleSaveConfig}
disabled={loading || testing || connectionStatus !== 'success'}
>
{loading ? (
<div className="flex items-center space-x-2">
<div
className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
<span>{t('serverConfig.saving')}</span>
</div>
) : (
t('serverConfig.saveConfig')
)}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
{t('serverConfig.helpText')}
</div>
</div>
</div> </div>
); <h2 className="text-xl font-semibold">{t("serverConfig.title")}</h2>
<p className="text-sm text-muted-foreground mt-2">
{t("serverConfig.description")}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
<div className="flex space-x-2">
<Input
id="server-url"
type="text"
placeholder="http://localhost:8081 or https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
className="flex-1 h-10"
disabled={loading}
/>
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testing || !serverUrl.trim() || loading}
className="w-10 h-10 p-0 flex items-center justify-center"
>
{testing ? (
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<Wifi className="w-4 h-4" />
)}
</Button>
</div>
</div>
{connectionStatus !== "unknown" && (
<div className="flex items-center space-x-2 text-sm">
{connectionStatus === "success" ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600">
{t("serverConfig.connected")}
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600">
{t("serverConfig.disconnected")}
</span>
</>
)}
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex space-x-2">
{onCancel && !isFirstTime && (
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={loading}
>
Cancel
</Button>
)}
<Button
type="button"
className={onCancel && !isFirstTime ? "flex-1" : "w-full"}
onClick={handleSaveConfig}
disabled={loading || testing || connectionStatus !== "success"}
>
{loading ? (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>{t("serverConfig.saving")}</span>
</div>
) : (
t("serverConfig.saveConfig")
)}
</Button>
</div>
<div className="text-xs text-muted-foreground text-center">
{t("serverConfig.helpText")}
</div>
</div>
</div>
);
} }
+142 -128
View File
@@ -1,141 +1,155 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx"; import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx"; import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {getUserInfo, getDatabaseHealth, getCookie} from "@/ui/main-axios.ts"; import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
interface HomepageProps { interface HomepageProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
isAuthenticated: boolean; isAuthenticated: boolean;
authLoading: boolean; authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void; onAuthSuccess: (authData: {
isTopbarOpen: boolean; isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
isTopbarOpen: boolean;
} }
export function Homepage({ export function Homepage({
isAuthenticated, isAuthenticated,
authLoading, authLoading,
onAuthSuccess, onAuthSuccess,
isTopbarOpen isTopbarOpen,
}: HomepageProps): React.ReactElement { }: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(null);
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = 26; const leftMarginPx = 26;
const bottomMarginPx = 8; const bottomMarginPx = 8;
useEffect(() => { useEffect(() => {
setLoggedIn(isAuthenticated); setLoggedIn(isAuthenticated);
}, [isAuthenticated]); }, [isAuthenticated]);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (jwt) { if (jwt) {
Promise.all([ Promise.all([getUserInfo(), getDatabaseHealth()])
getUserInfo(), .then(([meRes]) => {
getDatabaseHealth() setIsAdmin(!!meRes.is_admin);
]) setUsername(meRes.username || null);
.then(([meRes]) => { setUserId(meRes.id || null);
setIsAdmin(!!meRes.is_admin); setDbError(null);
setUsername(meRes.username || null); })
setUserId(meRes.id || null); .catch((err) => {
setDbError(null); setIsAdmin(false);
}) setUsername(null);
.catch((err) => { setUserId(null);
setIsAdmin(false); if (err?.response?.data?.error?.includes("Database")) {
setUsername(null); setDbError(
setUserId(null); "Could not connect to the database. Please try again later.",
if (err?.response?.data?.error?.includes("Database")) { );
setDbError("Could not connect to the database. Please try again later."); } else {
} else { setDbError(null);
setDbError(null);
}
});
} }
} });
}, [isAuthenticated]); }
}
}, [isAuthenticated]);
return (
<>
{!loggedIn ? (
<div className="w-full h-full flex items-center justify-center">
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
<div className="flex flex-col items-center gap-6 w-[400px]">
<HomepageUpdateLog loggedIn={loggedIn} />
return ( <div className="flex flex-row items-center gap-3">
<> <Button
{!loggedIn ? ( variant="outline"
<div className="w-full h-full flex items-center justify-center"> size="sm"
<HomepageAuth className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
setLoggedIn={setLoggedIn} onClick={() =>
setIsAdmin={setIsAdmin} window.open("https://github.com/LukeGus/Termix", "_blank")
setUsername={setUsername} }
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div
className="w-full h-full flex items-center justify-center"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
> >
<div className="flex flex-row items-center justify-center gap-8 relative z-10"> GitHub
<div className="flex flex-col items-center gap-6 w-[400px]"> </Button>
<HomepageUpdateLog <div className="w-px h-4 bg-dark-border"></div>
loggedIn={loggedIn} <Button
/> variant="outline"
size="sm"
<div className="flex flex-row items-center gap-3"> className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
<Button onClick={() =>
variant="outline" window.open(
size="sm" "https://github.com/LukeGus/Termix/issues/new",
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" "_blank",
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')} )
> }
GitHub >
</Button> Feedback
<div className="w-px h-4 bg-dark-border"></div> </Button>
<Button <div className="w-px h-4 bg-dark-border"></div>
variant="outline" <Button
size="sm" variant="outline"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" size="sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')} className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
> onClick={() =>
Feedback window.open(
</Button> "https://discord.com/invite/jVQGdvHDrf",
<div className="w-px h-4 bg-dark-border"></div> "_blank",
<Button )
variant="outline" }
size="sm" >
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" Discord
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')} </Button>
> <div className="w-px h-4 bg-dark-border"></div>
Discord <Button
</Button> variant="outline"
<div className="w-px h-4 bg-dark-border"></div> size="sm"
<Button className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
variant="outline" onClick={() =>
size="sm" window.open("https://github.com/sponsors/LukeGus", "_blank")
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" }
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')} >
> Donate
Donate </Button>
</Button> </div>
</div> </div>
</div> </div>
</div> </div>
</div> )}
)} </>
</> );
);
} }
+137 -123
View File
@@ -1,143 +1,157 @@
import React from "react"; import React from "react";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {
import {Button} from "@/components/ui/button.tsx"; Card,
import {Badge} from "@/components/ui/badge.tsx"; CardContent,
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; CardFooter,
import {useTranslation} from "react-i18next"; CardHeader,
import type {TermixAlert} from '../../../types/index.js'; CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
X,
ExternalLink,
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../types/index.js";
interface AlertCardProps { interface AlertCardProps {
alert: TermixAlert; alert: TermixAlert;
onDismiss: (alertId: string) => void; onDismiss: (alertId: string) => void;
onClose: () => void; onClose: () => void;
} }
const getAlertIcon = (type?: string) => { const getAlertIcon = (type?: string) => {
switch (type) { switch (type) {
case 'warning': case "warning":
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>; return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case 'error': case "error":
return <AlertCircle className="h-5 w-5 text-red-500"/>; return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'success': case "success":
return <CheckCircle className="h-5 w-5 text-green-500"/>; return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'info': case "info":
default: default:
return <Info className="h-5 w-5 text-blue-500"/>; return <Info className="h-5 w-5 text-blue-500" />;
} }
}; };
const getPriorityBadgeVariant = (priority?: string) => { const getPriorityBadgeVariant = (priority?: string) => {
switch (priority) { switch (priority) {
case 'critical': case "critical":
return 'destructive'; return "destructive";
case 'high': case "high":
return 'destructive'; return "destructive";
case 'medium': case "medium":
return 'secondary'; return "secondary";
case 'low': case "low":
default: default:
return 'outline'; return "outline";
} }
}; };
const getTypeBadgeVariant = (type?: string) => { const getTypeBadgeVariant = (type?: string) => {
switch (type) { switch (type) {
case 'warning': case "warning":
return 'secondary'; return "secondary";
case 'error': case "error":
return 'destructive'; return "destructive";
case 'success': case "success":
return 'default'; return "default";
case 'info': case "info":
default: default:
return 'outline'; return "outline";
} }
}; };
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { export function HomepageAlertCard({
const {t} = useTranslation(); alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement {
const { t } = useTranslation();
if (!alert) { if (!alert) {
return null; return null;
} }
const handleDismiss = () => { const handleDismiss = () => {
onDismiss(alert.id); onDismiss(alert.id);
onClose(); onClose();
}; };
const formatExpiryDate = (expiryString: string) => { const formatExpiryDate = (expiryString: string) => {
const expiryDate = new Date(expiryString); const expiryDate = new Date(expiryString);
const now = new Date(); const now = new Date();
const diffTime = expiryDate.getTime() - now.getTime(); const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return t('common.expired'); if (diffDays < 0) return t("common.expired");
if (diffDays === 0) return t('common.expiresToday'); if (diffDays === 0) return t("common.expiresToday");
if (diffDays === 1) return t('common.expiresTomorrow'); if (diffDays === 1) return t("common.expiresTomorrow");
return t('common.expiresInDays', {days: diffDays}); return t("common.expiresInDays", { days: diffDays });
}; };
return ( return (
<Card className="w-full max-w-2xl mx-auto"> <Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getAlertIcon(alert.type)} {getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold"> <CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
{alert.title} </div>
</CardTitle> <Button
</div> variant="ghost"
<Button size="icon"
variant="ghost" onClick={onClose}
size="icon" className="h-8 w-8 p-0"
onClick={onClose} >
className="h-8 w-8 p-0" <X className="h-4 w-4" />
> </Button>
<X className="h-4 w-4"/> </div>
</Button> <div className="flex items-center gap-2 mt-2">
</div> {alert.priority && (
<div className="flex items-center gap-2 mt-2"> <Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority && ( {alert.priority.toUpperCase()}
<Badge variant={getPriorityBadgeVariant(alert.priority)}> </Badge>
{alert.priority.toUpperCase()} )}
</Badge> {alert.type && (
)} <Badge variant={getTypeBadgeVariant(alert.type)}>
{alert.type && ( {alert.type}
<Badge variant={getTypeBadgeVariant(alert.type)}> </Badge>
{alert.type} )}
</Badge> <span className="text-sm text-muted-foreground">
)} {formatExpiryDate(alert.expiresAt)}
<span className="text-sm text-muted-foreground"> </span>
{formatExpiryDate(alert.expiresAt)} </div>
</span> </CardHeader>
</div> <CardContent className="pb-4">
</CardHeader> <p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
<CardContent className="pb-4"> {alert.message}
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap"> </p>
{alert.message} </CardContent>
</p> <CardFooter className="flex items-center justify-between pt-0">
</CardContent> <div className="flex gap-2">
<CardFooter className="flex items-center justify-between pt-0"> <Button variant="outline" onClick={handleDismiss}>
<div className="flex gap-2"> Dismiss
<Button </Button>
variant="outline" {alert.actionUrl && alert.actionText && (
onClick={handleDismiss} <Button
> variant="default"
Dismiss onClick={() =>
</Button> window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
{alert.actionUrl && alert.actionText && ( }
<Button className="gap-2"
variant="default" >
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')} {alert.actionText}
className="gap-2" <ExternalLink className="h-4 w-4" />
> </Button>
{alert.actionText} )}
<ExternalLink className="h-4 w-4"/> </div>
</Button> </CardFooter>
)} </Card>
</div> );
</CardFooter>
</Card>
);
} }
+162 -154
View File
@@ -1,171 +1,179 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import { HomepageAlertCard } from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {getUserAlerts, dismissAlert} from "@/ui/main-axios.ts"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import type {TermixAlert} from '../../../types/index.js'; import type { TermixAlert } from "../../../types/index.js";
interface AlertManagerProps { interface AlertManagerProps {
userId: string | null; userId: string | null;
loggedIn: boolean; loggedIn: boolean;
} }
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { export function HomepageAlertManager({
const {t} = useTranslation(); userId,
const [alerts, setAlerts] = useState<TermixAlert[]>([]); loggedIn,
const [currentAlertIndex, setCurrentAlertIndex] = useState(0); }: AlertManagerProps): React.ReactElement {
const [loading, setLoading] = useState(false); const { t } = useTranslation();
const [error, setError] = useState<string | null>(null); const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (loggedIn && userId) { if (loggedIn && userId) {
fetchUserAlerts(); fetchUserAlerts();
}
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch (err) {
const {toast} = await import('sonner');
toast.error(t('homepage.failedToLoadAlerts'));
setError(t('homepage.failedToLoadAlerts'));
} finally {
setLoading(false);
}
};
const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
try {
await dismissAlert(userId, alertId);
setAlerts(prev => {
const newAlerts = prev.filter(alert => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex(prevIndex => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch (err) {
setError(t('homepage.failedToDismissAlert'));
}
};
const handleCloseCurrentAlert = () => {
if (alerts.length === 0) return;
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
} else {
setAlerts([]);
setCurrentAlertIndex(0);
}
};
const handlePreviousAlert = () => {
if (currentAlertIndex > 0) {
setCurrentAlertIndex(currentAlertIndex - 1);
}
};
const handleNextAlert = () => {
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
}
};
if (!loggedIn || !userId) {
return null;
} }
}, [loggedIn, userId]);
if (alerts.length === 0) { const fetchUserAlerts = async () => {
return null; if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
const aPriority =
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority =
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return (
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
);
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch (err) {
const { toast } = await import("sonner");
toast.error(t("homepage.failedToLoadAlerts"));
setError(t("homepage.failedToLoadAlerts"));
} finally {
setLoading(false);
} }
};
const currentAlert = alerts[currentAlertIndex]; const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
if (!currentAlert) { try {
return null; await dismissAlert(userId, alertId);
setAlerts((prev) => {
const newAlerts = prev.filter((alert) => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex((prevIndex) => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength)
return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch (err) {
setError(t("homepage.failedToDismissAlert"));
} }
};
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0}; const handleCloseCurrentAlert = () => {
alerts.forEach(alert => { if (alerts.length === 0) return;
const priority = alert.priority || 'low';
priorityCounts[priority as keyof typeof priorityCounts]++;
});
const hasMultipleAlerts = alerts.length > 1;
return ( if (currentAlertIndex < alerts.length - 1) {
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]"> setCurrentAlertIndex(currentAlertIndex + 1);
<div className="relative w-full max-w-2xl mx-4"> } else {
<HomepageAlertCard setAlerts([]);
alert={currentAlert} setCurrentAlertIndex(0);
onDismiss={handleDismissAlert} }
onClose={handleCloseCurrentAlert} };
/>
{hasMultipleAlerts && ( const handlePreviousAlert = () => {
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2"> if (currentAlertIndex > 0) {
<Button setCurrentAlertIndex(currentAlertIndex - 1);
variant="outline" }
size="sm" };
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{error && ( const handleNextAlert = () => {
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2"> if (currentAlertIndex < alerts.length - 1) {
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm"> setCurrentAlertIndex(currentAlertIndex + 1);
{error} }
</div> };
</div>
)} if (!loggedIn || !userId) {
return null;
}
if (alerts.length === 0) {
return null;
}
const currentAlert = alerts[currentAlertIndex];
if (!currentAlert) {
return null;
}
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
alerts.forEach((alert) => {
const priority = alert.priority || "low";
priorityCounts[priority as keyof typeof priorityCounts]++;
});
const hasMultipleAlerts = alerts.length > 1;
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4">
<HomepageAlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{hasMultipleAlerts && (
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{error && (
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
{error}
</div> </div>
</div> </div>
); )}
</div>
</div>
);
} }
File diff suppressed because it is too large Load Diff
+158 -148
View File
@@ -1,172 +1,182 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import {getReleasesRSS, getVersionInfo} from "@/ui/main-axios.ts"; import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean; loggedIn: boolean;
} }
interface ReleaseItem { interface ReleaseItem {
id: number; id: number;
title: string; title: string;
description: string; description: string;
link: string; link: string;
pubDate: string; pubDate: string;
version: string; version: string;
isPrerelease: boolean; isPrerelease: boolean;
isDraft: boolean; isDraft: boolean;
assets: Array<{ assets: Array<{
name: string; name: string;
size: number; size: number;
download_count: number; download_count: number;
download_url: string; download_url: string;
}>; }>;
} }
interface RSSResponse { interface RSSResponse {
feed: { feed: {
title: string; title: string;
description: string; description: string;
link: string; link: string;
updated: string; updated: string;
}; };
items: ReleaseItem[]; items: ReleaseItem[];
total_count: number; total_count: number;
cached: boolean; cached: boolean;
cache_age?: number; cache_age?: number;
} }
interface VersionResponse { interface VersionResponse {
status: 'up_to_date' | 'requires_update'; status: "up_to_date" | "requires_update";
version: string; version: string;
latest_release: { latest_release: {
name: string; name: string;
published_at: string; published_at: string;
html_url: string; html_url: string;
}; };
cached: boolean; cached: boolean;
cache_age?: number; cache_age?: number;
} }
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) {
const {t} = useTranslation(); const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null); const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null); const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (loggedIn) { if (loggedIn) {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([getReleasesRSS(100), getVersionInfo()])
getReleasesRSS(100), .then(([releasesRes, versionRes]) => {
getVersionInfo() setReleases(releasesRes);
]) setVersionInfo(versionRes);
.then(([releasesRes, versionRes]) => { setError(null);
setReleases(releasesRes); })
setVersionInfo(versionRes); .catch((err) => {
setError(null); setError(t("common.failedToFetchUpdateInfo"));
}) })
.catch(err => { .finally(() => setLoading(false));
setError(t('common.failedToFetchUpdateInfo'));
})
.finally(() => setLoading(false));
}
}, [loggedIn]);
if (!loggedIn) {
return null;
} }
}, [loggedIn]);
const formatDescription = (description: string) => { if (!loggedIn) {
const firstLine = description.split('\n')[0]; return null;
return firstLine }
.replace(/[#*`]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
return ( const formatDescription = (description: string) => {
<div const firstLine = description.split("\n")[0];
className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg"> return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
<div> };
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3>
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border"/> return (
<div className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
<div>
<h3 className="text-lg font-bold mb-3 text-white">
{t("common.updatesAndReleases")}
</h3>
{versionInfo && versionInfo.status === 'requires_update' && ( <Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
<Alert className="bg-dark-bg-darker border-dark-border text-white">
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle> {versionInfo && versionInfo.status === "requires_update" && (
<AlertDescription className="text-gray-300"> <Alert className="bg-dark-bg-darker border-dark-border text-white">
{t('common.newVersionAvailable', {version: versionInfo.version})} <AlertTitle className="text-white">
</AlertDescription> {t("common.updateAvailable")}
</Alert> </AlertTitle>
)} <AlertDescription className="text-gray-300">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
</div>
{versionInfo && versionInfo.status === "requires_update" && (
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
)}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-500 text-red-300"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t("common.preRelease")}
</span>
)}
</div> </div>
{versionInfo && versionInfo.status === 'requires_update' && ( <p className="text-xs text-gray-300 mb-2 leading-relaxed">
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border"/> {formatDescription(release.description)}
)} </p>
<div className="flex-1 overflow-y-auto space-y-3 pr-2"> <div className="flex items-center text-xs text-gray-400">
{loading && ( <span>{new Date(release.pubDate).toLocaleDateString()}</span>
<div className="flex items-center justify-center h-32"> {release.assets.length > 0 && (
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div> <>
</div> <span className="mx-2"></span>
)} <span>
{release.assets.length} asset
{error && ( {release.assets.length !== 1 ? "s" : ""}
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300"> </span>
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle> </>
<AlertDescription className="text-red-300">{error}</AlertDescription> )}
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, '_blank')}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t('common.preRelease')}
</span>
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
</div>
))}
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle>
<AlertDescription className="text-gray-400">
{t('common.noReleasesFound')}
</AlertDescription>
</Alert>
)}
</div> </div>
</div> </div>
); ))}
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>
)}
</div>
</div>
);
} }
+542 -376
View File
@@ -1,394 +1,560 @@
import React, {useEffect, useRef, useState} from "react"; import React, { useEffect, useRef, useState } from "react";
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx"; import { Terminal } from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
import {Server as ServerView} from "@/ui/Desktop/Apps/Server/Server.tsx"; import { Server as ServerView } from "@/ui/Desktop/Apps/Server/Server.tsx";
import {FileManager} from "@/ui/Desktop/Apps/File Manager/FileManager.tsx"; import { FileManager } from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable.tsx";
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react"; import {
import {Button} from "@/components/ui/button.tsx"; LucideRefreshCcw,
LucideRefreshCw,
RefreshCcw,
RefreshCcwDot,
} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
interface TerminalViewProps { interface TerminalViewProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
} }
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement { export function AppView({
const {tabs, currentTab, allSplitScreenTab, removeTab} = useTabs() as any; isTopbarOpen = true,
const {state: sidebarState} = useSidebar(); }: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as any;
const { state: sidebarState } = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager'); const terminalTabs = tabs.filter(
(tab: any) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "file_manager",
);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({}); const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({}); const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>(
const [ready, setReady] = useState<boolean>(true); {},
const [resetKey, setResetKey] = useState<number>(0); );
const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0);
const updatePanelRects = () => { const updatePanelRects = () => {
const next: Record<string, DOMRect | null> = {}; const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => { Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect(); if (el) next[id] = el.getBoundingClientRect();
}); });
setPanelRects(next); setPanelRects(next);
};
const fitActiveAndNotify = () => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
} else {
const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
}
terminalTabs.forEach((t: any) => {
if (visibleIds.includes(t.id)) {
const ref = t.terminalRef?.current;
if (ref?.fit) ref.fit();
if (ref?.notifyResize) ref.notifyResize();
if (ref?.refresh) ref.refresh();
}
});
};
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current)
cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => {
fitActiveAndNotify();
});
});
};
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
useEffect(() => {
hideThenFit();
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(",")]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
const roContainer = containerRef.current
? new ResizeObserver(() => {
updatePanelRects();
fitActiveAndNotify();
})
: null;
if (containerRef.current && roContainer)
roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
useEffect(() => {
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
}; };
window.addEventListener("resize", onWinResize);
return () => window.removeEventListener("resize", onWinResize);
}, []);
const fitActiveAndNotify = () => { const HEADER_H = 28;
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
} else {
const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
}
terminalTabs.forEach((t: any) => {
if (visibleIds.includes(t.id)) {
const ref = t.terminalRef?.current;
if (ref?.fit) ref.fit();
if (ref?.notifyResize) ref.notifyResize();
if (ref?.refresh) ref.refresh();
}
});
};
const layoutScheduleRef = useRef<number | null>(null); const renderTerminalsLayer = () => {
const scheduleMeasureAndFit = () => { const styles: Record<number, React.CSSProperties> = {};
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current); const splitTabs = terminalTabs.filter((tab: any) =>
layoutScheduleRef.current = requestAnimationFrame(() => { allSplitScreenTab.includes(tab.id),
updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => {
fitActiveAndNotify();
});
});
};
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
useEffect(() => {
hideThenFit();
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
const roContainer = containerRef.current ? new ResizeObserver(() => {
updatePanelRects();
fitActiveAndNotify();
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
useEffect(() => {
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
}, []);
const HEADER_H = 28;
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === 'file_manager';
styles[mainTab.id] = {
position: 'absolute',
top: isFileManagerTab ? 0 : 2,
left: isFileManagerTab ? 0 : 2,
right: isFileManagerTab ? 0 : 2,
bottom: isFileManagerTab ? 0 : 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position: 'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0,
};
}
});
}
return (
<div className="absolute inset-0 z-[1]">
{terminalTabs.map((t: any) => {
const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'}
: {
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
} as React.CSSProperties;
const effectiveVisible = isVisible && ready;
return (
<div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-dark-bg">
{t.type === 'terminal' ? (
<Terminal
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
onClose={() => removeTab(t.id)}
/>
) : t.type === 'server' ? (
<ServerView
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : (
<FileManager
embedded
initialHost={t.hostConfig}
onClose={() => removeTab(t.id)}
/>
)}
</div>
</div>
);
})}
</div>
);
};
const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
onClick={onClick}
aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-dark-border-panel bg-dark-bg-panel hover:bg-dark-bg-panel-hover text-white flex items-center justify-center p-0"
>
<RefreshCcw className="h-4 w-4"/>
</Button>
); );
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: any) => t && t.id !== (mainTab && (mainTab as any).id),
),
].filter(Boolean) as any[];
const handleReset = () => { if (allSplitScreenTab.length === 0 && mainTab) {
setResetKey((k) => k + 1); const isFileManagerTab = mainTab.type === "file_manager";
requestAnimationFrame(() => scheduleMeasureAndFit()); styles[mainTab.id] = {
}; position: "absolute",
top: isFileManagerTab ? 0 : 2,
const renderSplitOverlays = () => { left: isFileManagerTab ? 0 : 2,
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id)); right: isFileManagerTab ? 0 : 2,
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab); bottom: isFileManagerTab ? 0 : 2,
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[]; zIndex: 20,
if (allSplitScreenTab.length === 0) return null; display: "block",
pointerEvents: "auto",
const handleStyle = { opacity: ready ? 1 : 0,
pointerEvents: 'auto', };
zIndex: 12, } else {
background: 'var(--color-dark-border)' layoutTabs.forEach((t: any) => {
} as React.CSSProperties; const rect = panelRects[String(t.id)];
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any; const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
if (layoutTabs.length === 2) { styles[t.id] = {
const [a, b] = layoutTabs as any[]; position: "absolute",
return ( top: rect.top - parentRect.top + HEADER_H + 2,
<div className="absolute inset-0 z-[10] pointer-events-none"> left: rect.left - parentRect.left + 2,
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal" width: rect.width - 4,
className="h-full w-full" {...commonGroupProps}> height: rect.height - HEADER_H - 4,
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" zIndex: 20,
id={`panel-${a.id}`} order={1}> display: "block",
<div ref={el => { pointerEvents: "auto",
panelRefs.current[String(a.id)] = el; opacity: ready ? 1 : 0,
}} className="h-full w-full flex flex-col bg-transparent relative"> };
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col bg-transparent relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
} }
if (layoutTabs.length === 3) { });
const [a, b, c] = layoutTabs as any[]; }
return (
<div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
const [a, b, c, d] = layoutTabs as any[];
return (
<div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{c.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${d.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(d.id)] = el;
}} className="h-full w-full flex flex-col relative">
<div
className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">{d.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === 'file_manager';
const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
return ( return (
<div <div className="absolute inset-0 z-[1]">
ref={containerRef} {terminalTabs.map((t: any) => {
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative" const hasStyle = !!styles[t.id];
style={{ const isVisible =
background: (isFileManager && !isSplitScreen) ? 'var(--color-dark-bg-darkest)' : 'var(--color-dark-bg)', hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
marginLeft: leftMarginPx,
marginRight: 17, const finalStyle: React.CSSProperties = hasStyle
marginTop: topMarginPx, ? { ...styles[t.id], overflow: "hidden" }
marginBottom: bottomMarginPx, : ({
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, position: "absolute",
}} inset: 0,
> visibility: "hidden",
{renderTerminalsLayer()} pointerEvents: "none",
{renderSplitOverlays()} zIndex: 0,
</div> } as React.CSSProperties);
const effectiveVisible = isVisible && ready;
return (
<div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-dark-bg">
{t.type === "terminal" ? (
<Terminal
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
onClose={() => removeTab(t.id)}
/>
) : t.type === "server" ? (
<ServerView
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : (
<FileManager
embedded
initialHost={t.hostConfig}
onClose={() => removeTab(t.id)}
/>
)}
</div>
</div>
);
})}
</div>
); );
};
const ResetButton = ({ onClick }: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
onClick={onClick}
aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-dark-border-panel bg-dark-bg-panel hover:bg-dark-bg-panel-hover text-white flex items-center justify-center p-0"
>
<RefreshCcw className="h-4 w-4" />
</Button>
);
const handleReset = () => {
setResetKey((k) => k + 1);
requestAnimationFrame(() => scheduleMeasureAndFit());
};
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) =>
allSplitScreenTab.includes(tab.id),
);
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [
mainTab,
...splitTabs.filter(
(t: any) => t && t.id !== (mainTab && (mainTab as any).id),
),
].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null;
const handleStyle = {
pointerEvents: "auto",
zIndex: 12,
background: "var(--color-dark-border)",
} as React.CSSProperties;
const commonGroupProps = {
onLayout: scheduleMeasureAndFit,
onResize: scheduleMeasureAndFit,
} as any;
if (layoutTabs.length === 2) {
const [a, b] = layoutTabs as any[];
return (
<div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup
key={resetKey}
direction="horizontal"
className="h-full w-full"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div
ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col bg-transparent relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`}
order={2}
>
<div
ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col bg-transparent relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset} />
</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
const [a, b, c] = layoutTabs as any[];
return (
<div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup
key={resetKey}
direction="vertical"
className="h-full w-full"
id="main-vertical"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id="top-panel"
order={1}
>
<ResizablePanelGroup
key={`top-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="top-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div
ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`}
order={2}
>
<div
ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset} />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id="bottom-panel"
order={2}
>
<div
ref={(el) => {
panelRefs.current[String(c.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{c.title}
</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
const [a, b, c, d] = layoutTabs as any[];
return (
<div className="absolute inset-0 z-[10] pointer-events-none">
<ResizablePrimitive.PanelGroup
key={resetKey}
direction="vertical"
className="h-full w-full"
id="main-vertical"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id="top-panel"
order={1}
>
<ResizablePanelGroup
key={`top-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="top-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`}
order={1}
>
<div
ref={(el) => {
panelRefs.current[String(a.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{a.title}
</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`}
order={2}
>
<div
ref={(el) => {
panelRefs.current[String(b.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{b.title}
<ResetButton onClick={handleReset} />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id="bottom-panel"
order={2}
>
<ResizablePanelGroup
key={`bottom-${resetKey}`}
direction="horizontal"
className="h-full w-full"
id="bottom-horizontal"
{...commonGroupProps}
>
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${c.id}`}
order={1}
>
<div
ref={(el) => {
panelRefs.current[String(c.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{c.title}
</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle} />
<ResizablePanel
defaultSize={50}
minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${d.id}`}
order={2}
>
<div
ref={(el) => {
panelRefs.current[String(d.id)] = el;
}}
className="h-full w-full flex flex-col relative"
>
<div className="bg-dark-bg-panel text-white text-[13px] h-[28px] leading-[28px] px-[10px] border-b border-dark-border-panel tracking-[1px] m-0 pointer-events-auto z-[11] relative">
{d.title}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager";
const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
return (
<div
ref={containerRef}
className="border-2 border-dark-border rounded-lg overflow-hidden overflow-x-hidden relative"
style={{
background:
isFileManager && !isSplitScreen
? "var(--color-dark-bg-darkest)"
: "var(--color-dark-bg)",
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
{renderTerminalsLayer()}
{renderSplitOverlays()}
</div>
);
} }
+80 -69
View File
@@ -1,80 +1,91 @@
import React, {useState} from "react"; import React, { useState } from "react";
import {CardTitle} from "@/components/ui/card.tsx"; import { CardTitle } from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react"; import { ChevronDown, Folder } from "lucide-react";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {Host} from "@/ui/Desktop/Navigation/Hosts/Host.tsx"; import { Host } from "@/ui/Desktop/Navigation/Hosts/Host.tsx";
import {Separator} from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
interface SSHHost { interface SSHHost {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
folder: string; folder: string;
tags: string[]; tags: string[];
pin: boolean; pin: boolean;
authType: string; authType: string;
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: any[]; tunnelConnections: any[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
interface FolderCardProps { interface FolderCardProps {
folderName: string; folderName: string;
hosts: SSHHost[]; hosts: SSHHost[];
isFirst: boolean; isFirst: boolean;
isLast: boolean; isLast: boolean;
} }
export function FolderCard({folderName, hosts}: FolderCardProps): React.ReactElement { export function FolderCard({
const [isExpanded, setIsExpanded] = useState(true); folderName,
hosts,
}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => { const toggleExpanded = () => {
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
}; };
return ( return (
<div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0"> <div className="bg-dark-bg-darker border-2 border-dark-border rounded-lg overflow-hidden p-0 m-0">
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-dark-bg-header`}> <div
<div className="flex gap-2 pr-10"> className={`px-4 py-3 relative ${isExpanded ? "border-b-2" : ""} bg-dark-bg-header`}
<div className="flex-shrink-0 flex items-center"> >
<Folder size={16} strokeWidth={3}/> <div className="flex gap-2 pr-10">
</div> <div className="flex-shrink-0 flex items-center">
<div className="flex-1 min-w-0"> <Folder size={16} strokeWidth={3} />
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle> </div>
</div> <div className="flex-1 min-w-0">
</div> <CardTitle className="mb-0 leading-tight break-words text-md">
<Button {folderName}
variant="outline" </CardTitle>
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0" </div>
onClick={toggleExpanded}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
</Button>
</div>
{isExpanded && (
<div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</React.Fragment>
))}
</div>
)}
</div> </div>
) <Button
variant="outline"
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
onClick={toggleExpanded}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? "" : "rotate-180"}`}
/>
</Button>
</div>
{isExpanded && (
<div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => (
<React.Fragment
key={`${folderName}-host-${host.id}-${host.name || host.ip}`}
>
<Host host={host} />
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0" />
</div>
)}
</React.Fragment>
))}
</div>
)}
</div>
);
} }
+100 -86
View File
@@ -1,96 +1,110 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import { ButtonGroup } from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import { Server, Terminal } from "lucide-react";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"; import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {getServerStatusById} from "@/ui/main-axios.ts"; import { getServerStatusById } from "@/ui/main-axios.ts";
import type {HostProps} from '../../../../types/index.js'; import type { HostProps } from "../../../../types/index.js";
export function Host({host}: HostProps): React.ReactElement { export function Host({ host }: HostProps): React.ReactElement {
const {addTab} = useTabs(); const { addTab } = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded'); const [serverStatus, setServerStatus] = useState<
const tags = Array.isArray(host.tags) ? host.tags : []; "online" | "offline" | "degraded"
const hasTags = tags.length > 0; >("degraded");
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`; const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
useEffect(() => { useEffect(() => {
let intervalId: number | undefined; let intervalId: number | undefined;
let cancelled = false; let cancelled = false;
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
const res = await getServerStatusById(host.id); const res = await getServerStatusById(host.id);
if (!cancelled) { if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline'); setServerStatus(res?.status === "online" ? "online" : "offline");
} }
} catch (error: any) { } catch (error: any) {
if (!cancelled) { if (!cancelled) {
if (error?.response?.status === 503) { if (error?.response?.status === 503) {
setServerStatus('offline'); setServerStatus("offline");
} else if (error?.response?.status === 504) { } else if (error?.response?.status === 504) {
setServerStatus('degraded'); setServerStatus("degraded");
} else if (error?.response?.status === 404) { } else if (error?.response?.status === 404) {
setServerStatus('offline'); setServerStatus("offline");
} else { } else {
setServerStatus('offline'); setServerStatus("offline");
} }
} }
} }
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [host.id]);
const handleTerminalClick = () => {
addTab({type: 'terminal', title, hostConfig: host});
}; };
const handleServerClick = () => { fetchStatus();
addTab({type: 'server', title, hostConfig: host});
};
return ( intervalId = window.setInterval(fetchStatus, 10000);
<div>
<div className="flex items-center gap-2"> return () => {
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0"> cancelled = true;
<StatusIndicator/> if (intervalId) window.clearInterval(intervalId);
</Status> };
<p className="font-semibold flex-1 min-w-0 break-words text-sm"> }, [host.id]);
{host.name || host.ip}
</p> const handleTerminalClick = () => {
<ButtonGroup className="flex-shrink-0"> addTab({ type: "terminal", title, hostConfig: host });
<Button variant="outline" className="!px-2 border-1 border-dark-border" onClick={handleServerClick}> };
<Server/>
</Button> const handleServerClick = () => {
{host.enableTerminal && ( addTab({ type: "server", title, hostConfig: host });
<Button };
variant="outline"
className="!px-2 border-1 border-dark-border" return (
onClick={handleTerminalClick} <div>
> <div className="flex items-center gap-2">
<Terminal/> <Status
</Button> status={serverStatus}
)} className="!bg-transparent !p-0.75 flex-shrink-0"
</ButtonGroup> >
<StatusIndicator />
</Status>
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
{host.name || host.ip}
</p>
<ButtonGroup className="flex-shrink-0">
<Button
variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={handleServerClick}
>
<Server />
</Button>
{host.enableTerminal && (
<Button
variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={handleTerminalClick}
>
<Terminal />
</Button>
)}
</ButtonGroup>
</div>
{hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div
key={tag}
className="bg-dark-bg border-1 border-dark-border pl-2 pr-2 rounded-[10px]"
>
<p className="text-sm">{tag}</p>
</div> </div>
{hasTags && ( ))}
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div key={tag} className="bg-dark-bg border-1 border-dark-border pl-2 pr-2 rounded-[10px]">
<p className="text-sm">{tag}</p>
</div>
))}
</div>
)}
</div> </div>
) )}
</div>
);
} }
File diff suppressed because it is too large Load Diff
+152 -132
View File
@@ -1,145 +1,165 @@
import React from "react"; import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import { ButtonGroup } from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {useTranslation} from 'react-i18next'; import { useTranslation } from "react-i18next";
import { import {
Home, Home,
SeparatorVertical, SeparatorVertical,
X, X,
Terminal as TerminalIcon, Terminal as TerminalIcon,
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon User as UserIcon,
} from "lucide-react"; } from "lucide-react";
interface TabProps { interface TabProps {
tabType: string; tabType: string;
title?: string; title?: string;
isActive?: boolean; isActive?: boolean;
onActivate?: () => void; onActivate?: () => void;
onClose?: () => void; onClose?: () => void;
onSplit?: () => void; onSplit?: () => void;
canSplit?: boolean; canSplit?: boolean;
canClose?: boolean; canClose?: boolean;
disableActivate?: boolean; disableActivate?: boolean;
disableSplit?: boolean; disableSplit?: boolean;
disableClose?: boolean; disableClose?: boolean;
} }
export function Tab({ export function Tab({
tabType, tabType,
title, title,
isActive, isActive,
onActivate, onActivate,
onClose, onClose,
onSplit, onSplit,
canSplit = false, canSplit = false,
canClose = false, canClose = false,
disableActivate = false, disableActivate = false,
disableSplit = false, disableSplit = false,
disableClose = false disableClose = false,
}: TabProps): React.ReactElement { }: TabProps): React.ReactElement {
const {t} = useTranslation(); const { t } = useTranslation();
if (tabType === "home") { if (tabType === "home") {
return ( return (
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
<Home/> <Home />
</Button> </Button>
); );
} }
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager" || tabType === "user_profile") { if (
const isServer = tabType === 'server'; tabType === "terminal" ||
const isFileManager = tabType === 'file_manager'; tabType === "server" ||
const isUserProfile = tabType === 'user_profile'; tabType === "file_manager" ||
return ( tabType === "user_profile"
<ButtonGroup> ) {
<Button const isServer = tabType === "server";
variant="outline" const isFileManager = tabType === "file_manager";
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} const isUserProfile = tabType === "user_profile";
onClick={onActivate} return (
disabled={disableActivate} <ButtonGroup>
> <Button
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ? variant="outline"
<FolderIcon className="mr-1 h-4 w-4"/> : isUserProfile ? className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
<UserIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>} onClick={onActivate}
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : isUserProfile ? t('nav.userProfile') : t('nav.terminal'))} disabled={disableActivate}
</Button> >
{canSplit && ( {isServer ? (
<Button <ServerIcon className="mr-1 h-4 w-4" />
variant="outline" ) : isFileManager ? (
className="!px-2 border-1 border-dark-border" <FolderIcon className="mr-1 h-4 w-4" />
onClick={onSplit} ) : isUserProfile ? (
disabled={disableSplit} <UserIcon className="mr-1 h-4 w-4" />
title={disableSplit ? t('nav.cannotSplitTab') : t('nav.splitScreen')} ) : (
> <TerminalIcon className="mr-1 h-4 w-4" />
<SeparatorVertical className="w-[28px] h-[28px]"/> )}
</Button> {title ||
)} (isServer
{canClose && ( ? t("nav.serverStats")
<Button : isFileManager
variant="outline" ? t("nav.fileManager")
className="!px-2 border-1 border-dark-border" : isUserProfile
onClick={onClose} ? t("nav.userProfile")
disabled={disableClose} : t("nav.terminal"))}
> </Button>
<X/> {canSplit && (
</Button> <Button
)} variant="outline"
</ButtonGroup> className="!px-2 border-1 border-dark-border"
); onClick={onSplit}
} disabled={disableSplit}
title={
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
}
>
<SeparatorVertical className="w-[28px] h-[28px]" />
</Button>
)}
{canClose && (
<Button
variant="outline"
className="!px-2 border-1 border-dark-border"
onClick={onClose}
disabled={disableClose}
>
<X />
</Button>
)}
</ButtonGroup>
);
}
if (tabType === "ssh_manager") { if (tabType === "ssh_manager") {
return ( return (
<ButtonGroup> <ButtonGroup>
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{title || t('nav.sshManager')} {title || t("nav.sshManager")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
> >
<X/> <X />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
); );
} }
if (tabType === "admin") { if (tabType === "admin") {
return ( return (
<ButtonGroup> <ButtonGroup>
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${isActive ? '!bg-dark-bg-active !text-white !border-dark-border-active' : ''}`} className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{title || t('nav.admin')} {title || t("nav.admin")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
> >
<X/> <X />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
); );
} }
return null; return null;
} }
+150 -122
View File
@@ -1,145 +1,173 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react'; import React, {
import {useTranslation} from 'react-i18next'; createContext,
import type {TabContextTab} from '../../../types/index.js'; useContext,
useState,
useRef,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import type { TabContextTab } from "../../../types/index.js";
export type Tab = TabContextTab; export type Tab = TabContextTab;
interface TabContextType { interface TabContextType {
tabs: Tab[]; tabs: Tab[];
currentTab: number | null; currentTab: number | null;
allSplitScreenTab: number[]; allSplitScreenTab: number[];
addTab: (tab: Omit<Tab, 'id'>) => number; addTab: (tab: Omit<Tab, "id">) => number;
removeTab: (tabId: number) => void; removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void; setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void; setSplitScreenTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined; getTab: (tabId: number) => Tab | undefined;
updateHostConfig: (hostId: number, newHostConfig: any) => void; updateHostConfig: (hostId: number, newHostConfig: any) => void;
} }
const TabContext = createContext<TabContextType | undefined>(undefined); const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() { export function useTabs() {
const context = useContext(TabContext); const context = useContext(TabContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider'); throw new Error("useTabs must be used within a TabProvider");
} }
return context; return context;
} }
interface TabProviderProps { interface TabProviderProps {
children: ReactNode; children: ReactNode;
} }
export function TabProvider({children}: TabProviderProps) { export function TabProvider({ children }: TabProviderProps) {
const {t} = useTranslation(); const { t } = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([ const [tabs, setTabs] = useState<Tab[]>([
{id: 1, type: 'home', title: t('nav.home')} { id: 1, type: "home", title: t("nav.home") },
]); ]);
const [currentTab, setCurrentTab] = useState<number>(1); const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]); const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2); const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(
const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal')); tabType: Tab["type"],
const baseTitle = (desiredTitle || defaultTitle).trim(); desiredTitle: string | undefined,
const match = baseTitle.match(/^(.*) \((\d+)\)$/); ): string {
const root = match ? match[1] : baseTitle; const defaultTitle =
tabType === "server"
? t("nav.serverStats")
: tabType === "file_manager"
? t("nav.fileManager")
: t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>(); const usedNumbers = new Set<number>();
let rootUsed = false; let rootUsed = false;
tabs.forEach(t => { tabs.forEach((t) => {
if (!t.title) return; if (!t.title) return;
if (t.title === root) { if (t.title === root) {
rootUsed = true; rootUsed = true;
return; return;
} }
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`)); const m = t.title.match(
if (m) { new RegExp(
const n = parseInt(m[1], 10); `^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`,
if (!isNaN(n)) usedNumbers.add(n); ),
} );
}); if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
if (!rootUsed) return root; if (!rootUsed) return root;
let n = 2; let n = 2;
while (usedNumbers.has(n)) n += 1; while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`; return `${root} (${n})`;
}
const addTab = (tabData: Omit<Tab, "id">): number => {
const id = nextTabId.current++;
const needsUniqueTitle =
tabData.type === "terminal" ||
tabData.type === "server" ||
tabData.type === "file_manager";
const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || "";
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef:
tabData.type === "terminal" ? React.createRef<any>() : undefined,
};
setTabs((prev) => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id));
return id;
};
const removeTab = (tabId: number) => {
const tab = tabs.find((t) => t.id === tabId);
if (
tab &&
tab.terminalRef?.current &&
typeof tab.terminalRef.current.disconnect === "function"
) {
tab.terminalRef.current.disconnect();
} }
const addTab = (tabData: Omit<Tab, 'id'>): number => { setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
const id = nextTabId.current++; setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
};
setTabs(prev => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
return id;
};
const removeTab = (tabId: number) => { if (currentTab === tabId) {
const tab = tabs.find(t => t.id === tabId); const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") { setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
tab.terminalRef.current.disconnect(); }
};
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab((prev) => {
if (prev.includes(tabId)) {
return prev.filter((id) => id !== tabId);
} else if (prev.length < 3) {
return [...prev, tabId];
}
return prev;
});
};
const getTab = (tabId: number) => {
return tabs.find((tab) => tab.id === tabId);
};
const updateHostConfig = (hostId: number, newHostConfig: any) => {
setTabs((prev) =>
prev.map((tab) => {
if (tab.hostConfig && tab.hostConfig.id === hostId) {
return {
...tab,
hostConfig: newHostConfig,
title: newHostConfig.name?.trim()
? newHostConfig.name
: `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}`,
};
} }
return tab;
setTabs(prev => prev.filter(tab => tab.id !== tabId)); }),
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
}
};
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab(prev => {
if (prev.includes(tabId)) {
return prev.filter(id => id !== tabId);
} else if (prev.length < 3) {
return [...prev, tabId];
}
return prev;
});
};
const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId);
};
const updateHostConfig = (hostId: number, newHostConfig: any) => {
setTabs(prev => prev.map(tab => {
if (tab.hostConfig && tab.hostConfig.id === hostId) {
return {
...tab,
hostConfig: newHostConfig,
title: newHostConfig.name?.trim() ? newHostConfig.name : `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}`
};
}
return tab;
}));
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
updateHostConfig,
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
); );
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
updateHostConfig,
};
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
} }

Some files were not shown because too many files have changed in this diff Show More