diff --git a/client/package.json b/client/package.json index f05aff712..487553078 100644 --- a/client/package.json +++ b/client/package.json @@ -9,9 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "@codemirror/autocomplete": "^6.20.0", - "@codemirror/language": "^6.11.3", - "@codemirror/legacy-modes": "^6.5.2", "@fontsource/fira-code": "^5.2.7", "@fontsource/inconsolata": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", @@ -19,11 +16,9 @@ "@fontsource/roboto-mono": "^5.2.8", "@fontsource/source-code-pro": "^5.2.7", "@fontsource/ubuntu-mono": "^5.2.8", - "@lezer/highlight": "^1.2.3", "@mdi/js": "^7.4.47", "@mdi/react": "^1.6.1", - "@uiw/codemirror-theme-github": "^4.25.3", - "@uiw/react-codemirror": "^4.25.3", + "@monaco-editor/react": "^4.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "chart.js": "^4.5.1", @@ -33,6 +28,7 @@ "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "monaco-editor": "^0.55.1", "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-chartjs-2": "^5.3.1", diff --git a/client/public/assets/locales/en.json b/client/public/assets/locales/en.json index a00aa507f..d3aeb4fa8 100644 --- a/client/public/assets/locales/en.json +++ b/client/public/assets/locales/en.json @@ -4,7 +4,12 @@ "cancel": "Cancel", "confirm": "Confirm", "login": "Login", - "register": "Register" + "register": "Register", + "save": "Save", + "download": "Download", + "close": "Close", + "maximize": "Maximize", + "restore": "Restore" }, "labels": { "username": "Username", @@ -43,7 +48,6 @@ "monitoring": "Monitoring", "snippets": "Snippets", "audit": "Audit", - "apps": "Apps", "logout": "Logout", "collapseTitle": "Collapse Sidebar", "logoutConfirmText": "This will log you out of the {{username}} account. Are you sure?", @@ -129,7 +133,13 @@ "page": { "title": "Snippets", "subtitle": "Manage your command snippets for quick access in terminals", - "addSnippet": "Add Snippet" + "addSnippet": "Add Snippet", + "personal": "Personal", + "selectOrganization": "Select Organization", + "tabs": { + "snippets": "Snippets", + "scripts": "Scripts" + } }, "list": { "empty": "You don't have any snippets yet. Create your first one!", @@ -175,6 +185,102 @@ } } }, + "scripts": { + "page": { + "title": "Scripts", + "subtitle": "Manage your automation scripts for server tasks", + "addScript": "Add Script", + "personal": "Personal", + "selectOrganization": "Select Organization", + "tabs": { + "snippets": "Snippets", + "scripts": "Scripts" + } + }, + "execution": { + "progressPanel": { + "title": "Script Execution", + "noSteps": "No steps yet", + "noStepsDescription": "Steps will appear here as the script executes", + "steps": "Steps" + }, + "dialogs": { + "enterValue": "Enter value", + "selectOption": "Select an option", + "confirm": "Confirm", + "submit": "Submit", + "cancel": "Cancel", + "ok": "OK", + "yes": "Yes", + "no": "No", + "copyCell": "Copy Cell", + "copyRow": "Copy Row", + "copyTable": "Copy Table", + "close": "Close" + }, + "status": { + "running": "Running", + "completed": "Completed", + "error": "Error", + "pending": "Pending" + } + }, + "list": { + "empty": "You don't have any scripts yet. Create your first one!", + "actions": { + "edit": "Edit script", + "delete": "Delete script" + } + }, + "dialog": { + "title": { + "create": "Create Script", + "edit": "Edit Script" + }, + "scriptDetails": "Script Details", + "scriptContent": "Script Content", + "helpTip": "Use @NEXTERM: directives for interactive features", + "fields": { + "name": "Name", + "description": "Description" + }, + "placeholders": { + "name": "Script name", + "description": "What does this script do?" + }, + "actions": { + "cancel": "Cancel", + "create": "Create", + "update": "Update", + "creating": "Creating...", + "updating": "Updating..." + }, + "errors": { + "nameRequired": "Script name is required", + "descriptionRequired": "Script description is required", + "contentRequired": "Script content is required", + "createFailed": "Failed to create script", + "updateFailed": "Failed to update script" + } + }, + "messages": { + "success": { + "created": "Script created successfully", + "updated": "Script updated successfully", + "deleted": "Script deleted successfully" + }, + "errors": { + "loadFailed": "Failed to load script data", + "deleteFailed": "Failed to delete script" + } + }, + "menu": { + "searchPlaceholder": "Search scripts...", + "noScripts": "No scripts available. Create some in the Scripts section.", + "noMatch": "No scripts match your search.", + "selectIdentity": "Select Identity" + } + }, "monitoring": { "page": { "title": "Server Monitoring", @@ -291,6 +397,7 @@ "organizations": "Organizations", "users": "Users", "authentication": "Authentication", + "sources": "Sources", "ai": "AI" }, "account": { @@ -627,6 +734,54 @@ "updateFailed": "Failed to update identity" } } + }, + "sources": { + "title": "Script & Snippet Sources", + "description": "Configure external sources to automatically sync scripts and snippets.", + "loading": "Loading sources...", + "noSources": "No Sources Configured", + "noSourcesDescription": "Add external sources to automatically sync scripts and snippets.", + "addSource": "Add Source", + "snippets": "snippets", + "scripts": "scripts", + "neverSynced": "Never synced", + "sync": "Sync now", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete the source \"{{name}}\"? All synced scripts and snippets from this source will also be deleted.", + "status": { + "success": "Synced", + "error": "Error", + "pending": "Pending", + "disabled": "Disabled" + }, + "dialog": { + "createTitle": "Add Source", + "editTitle": "Edit Source", + "name": "Name", + "namePlaceholder": "e.g. Company Scripts", + "url": "Source URL", + "urlPlaceholder": "https://scripts.example.com", + "enabled": "Enabled", + "enabledDescription": "When disabled, this source will not be synced", + "validating": "Validating URL...", + "validationSuccess": "Valid source found: {{snippets}} snippets, {{scripts}} scripts", + "create": "Create" + }, + "messages": { + "created": "Source created successfully", + "updated": "Source updated successfully", + "deleted": "Source deleted successfully", + "synced": "Source synced successfully" + }, + "errors": { + "loadFailed": "Failed to load sources", + "saveFailed": "Failed to save source", + "deleteFailed": "Failed to delete source", + "syncFailed": "Failed to sync source", + "validationFailed": "Failed to validate URL", + "requiredFields": "Name and URL are required" + } } }, "servers": { @@ -666,6 +821,7 @@ "connect": "Connect", "quickConnect": "Quick Connect", "openSFTP": "Open SFTP", + "runScript": "Run Script", "editServer": "Edit Server", "editIntegration": "Edit Integration", "duplicateServer": "Duplicate Server", @@ -824,11 +980,22 @@ "createFolder": "Create Folder", "upload": "Upload File", "edit": "Edit", + "preview": "Preview", "bigFileConfirm": "This file is {{size}} MB large. Are you sure you want to edit it?", "selectAll": "Select All", "insertSnippet": "Insert Snippet", "sendCtrlC": "Send Ctrl+C", "clearTerminal": "Clear Terminal" + }, + "filePreview": { + "title": "File Preview", + "loading": "Loading...", + "cannotPreview": "Cannot Preview File", + "unsupportedFileType": "This file type is not supported for preview", + "videoNotSupported": "Your browser does not support video playback", + "audioNotSupported": "Your browser does not support audio playback", + "download": "Download", + "close": "Close" } }, "aiAssistant": { @@ -861,132 +1028,5 @@ "createIdentityFailed": "Failed to create identity", "updateIdentityFailed": "Failed to update identity" } - }, - "apps": { - "title": "Apps", - "navigation": { - "categories": { - "all": "All", - "results": "Results", - "scripts": "Scripts", - "networking": "Networking", - "media": "Media", - "cloud": "Cloud", - "development": "Development", - "utilities": "Utilities" - } - }, - "store": { - "appStore": "App Store", - "scripts": "Scripts", - "appStoreDescription": "Your favorite apps, deployed with a single click.", - "scriptsDescription": "Automate your server tasks with custom scripts.", - "manageSources": "Manage sources", - "createScript": "Create Script", - "source": "Source:", - "sourceFilter": { - "all": "All", - "custom": "Custom" - } - }, - "list": { - "noApps": "More apps coming soon", - "noScripts": "No scripts available", - "selectApp": "Select app to continue", - "selectScript": "Select script to continue" - }, - "actions": { - "deploy": "Deploy", - "run": "Run", - "view": "View", - "edit": "Edit", - "delete": "Delete", - "logs": "Logs", - "open": "Open", - "stop": "Stop Script", - "version": "Version" - }, - "items": { - "customBadge": "Custom", - "scriptType": "Script", - "running": "Running...", - "runScript": "Run" - }, - "installer": { - "deploymentFailed": "Deployment failed", - "deploymentCompleted": "Deployment completed", - "deploying": "Deploying...", - "installationLog": "Installation Log", - "steps": { - "lookupDistro": "Look up Linux distro", - "checkPermissions": "Check permissions", - "installDocker": "Install Docker Engine", - "downloadImage": "Download base image", - "preInstall": "Run pre-install command", - "startContainer": "Start Docker container", - "postInstall": "Run post-install command", - "detected": "Detected {{os}}" - } - }, - "scriptExecutor": { - "executionFailed": "Script execution failed", - "executionCompleted": "Script execution completed", - "executing": "Executing script...", - "initializing": "Initializing script execution", - "finished": "Script execution finished", - "cancelled": "Script execution cancelled by user" - }, - "dialogs": { - "deployServer": { - "deployTitle": "Deploy {{name}}", - "runTitle": "Run {{name}}", - "noServersAvailable": "No SSH servers available" - }, - "source": { - "title": "App sources", - "refreshApps": "Refresh apps", - "addNewSource": "Add new source" - }, - "script": { - "createTitle": "Create Custom Script", - "editTitle": "Edit Script", - "viewTitle": "View Script", - "source": "Source: {{source}}", - "scriptDetails": "Script Details", - "scriptContent": "Script Content", - "helpTip": "Use @NEXTERM: commands for interactive prompts", - "fields": { - "name": "Script Name *", - "description": "Description *", - "namePlaceholder": "My Awesome Script", - "descriptionPlaceholder": "What does this script do?" - }, - "actions": { - "close": "Close", - "cancel": "Cancel", - "create": "Create Script", - "update": "Update Script", - "creating": "Creating...", - "updating": "Updating..." - }, - "errors": { - "nameRequired": "Script name is required", - "descriptionRequired": "Script description is required", - "contentRequired": "Script content cannot be empty", - "createFailed": "Failed to create script", - "updateFailed": "Failed to update script" - }, - "success": { - "created": "Script created successfully", - "updated": "Script updated successfully" - } - } - }, - "messages": { - "onlyCustomScriptsCanBeDeleted": "Only custom scripts can be deleted", - "scriptDeletedSuccessfully": "Script deleted successfully", - "failedToDeleteScript": "Failed to delete script", - "deleteScriptConfirmation": "Are you sure you want to delete the script \"{{name}}\"? This action cannot be undone." - } } } \ No newline at end of file diff --git a/client/src/App.jsx b/client/src/App.jsx index 37b281137..fc8bdd309 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -12,7 +12,6 @@ import Loading from "@/common/components/Loading"; const Servers = lazy(() => import("@/pages/Servers")); const Settings = lazy(() => import("@/pages/Settings")); -const Apps = lazy(() => import("@/pages/Apps")); const Snippets = lazy(() => import("@/pages/Snippets")); const Monitoring = lazy(() => import("@/pages/Monitoring")); const Audit = lazy(() => import("@/pages/Audit")); @@ -39,7 +38,6 @@ const App = () => { { path: "/monitoring", element: }, { path: "/audit", element: }, { path: "/settings/*", element: }, - { path: "/apps/*", element: }, { path: "/snippets", element: } ], }, diff --git a/client/src/common/codemirror/nexterm-lang.js b/client/src/common/codemirror/nexterm-lang.js deleted file mode 100644 index 300f68938..000000000 --- a/client/src/common/codemirror/nexterm-lang.js +++ /dev/null @@ -1,160 +0,0 @@ -import { StreamLanguage } from "@codemirror/language"; -import { shell } from "@codemirror/legacy-modes/mode/shell"; -import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; -import { tags } from "@lezer/highlight"; -import { autocompletion } from "@codemirror/autocomplete"; - -const nextermShellMode = { - name: "nexterm", - - startState: function() { - const shellState = shell.startState ? shell.startState() : {}; - return { ...shellState, inNextermCommand: false, nextermType: null }; - }, - - token: function(stream, state) { - if (stream.sol()) { - state.inNextermCommand = false; - state.nextermType = null; - } - - if (stream.match(/@NEXTERM:(STEP|INPUT|SELECT|WARN|INFO|SUCCESS|CONFIRM|PROGRESS|SUMMARY|TABLE|MSGBOX)/)) { - state.inNextermCommand = true; - state.nextermType = stream.current().split(":")[1]; - return "keyword"; - } - - if (state.inNextermCommand) { - if (stream.eatSpace()) return null; - - if ((state.nextermType === "INPUT" || state.nextermType === "SELECT") && - stream.match(/[A-Z_][A-Z0-9_]*/)) { - return "variableName"; - } - - if (stream.match(/"[^"]*"/)) return "string"; - - - if (stream.match(/[a-zA-Z0-9_-]+/)) return "atom"; - - if (stream.eol()) { - state.inNextermCommand = false; - state.nextermType = null; - return null; - } - - stream.next(); - return null; - } - - return shell.token(stream, state); - }, - fold: shell.fold, - electricInput: shell.electricInput, - blockCommentStart: shell.blockCommentStart, - blockCommentEnd: shell.blockCommentEnd, - lineComment: shell.lineComment, -}; - -const nextermHighlightStyle = HighlightStyle.define([ - { tag: tags.keyword, color: "#ff6b6b", fontWeight: "bold", backgroundColor: "rgba(255, 107, 107, 0.1)" }, - { tag: tags.variableName, color: "#4ecdc4", fontStyle: "italic", fontWeight: "600" }, - { tag: tags.string, color: "#95e1d3" }, - { tag: tags.atom, color: "#ffc947" }, -]); - -const nextermCompletions = [ - { - label: "@NEXTERM:STEP", - type: "keyword", - info: "Define a step in the script execution", - detail: "Step definition", - apply: "@NEXTERM:STEP \"Step description\"", - }, - { - label: "@NEXTERM:INPUT", - type: "keyword", - info: "Request user input with optional default value", - detail: "User input prompt", - apply: "@NEXTERM:INPUT VARIABLE_NAME \"Prompt text\" \"default_value\"", - }, - { - label: "@NEXTERM:SELECT", - type: "keyword", - info: "Present user with selection options", - detail: "Selection prompt", - apply: "@NEXTERM:SELECT VARIABLE_NAME \"Choose option\" \"Option 1\" \"Option 2\" option3", - }, - { - label: "@NEXTERM:WARN", - type: "keyword", - info: "Display a warning message", - detail: "Warning message", - apply: "@NEXTERM:WARN \"Warning message\"", - }, - { - label: "@NEXTERM:INFO", - type: "keyword", - info: "Display an informational message", - detail: "Info message", - apply: "@NEXTERM:INFO \"Information message\"", - }, - { - label: "@NEXTERM:SUCCESS", - type: "keyword", - info: "Display a success message", - detail: "Success message", - apply: "@NEXTERM:SUCCESS \"Success message\"", - }, - { - label: "@NEXTERM:CONFIRM", - type: "keyword", - info: "Ask user for Yes/No confirmation", - detail: "Confirmation prompt", - apply: "@NEXTERM:CONFIRM \"Are you sure you want to proceed?\"", - }, - { - label: "@NEXTERM:PROGRESS", - type: "keyword", - info: "Display progress information with percentage", - detail: "Progress indicator", - apply: "@NEXTERM:PROGRESS 50", - }, - { - label: "@NEXTERM:SUMMARY", - type: "keyword", - info: "Display a summary dialog with key-value data", - detail: "Summary dialog", - apply: "@NEXTERM:SUMMARY \"System Information\" \"OS\" \"Ubuntu 22.04\" \"CPU\" \"4 cores\" \"Memory\" \"8GB\"", - }, - { - label: "@NEXTERM:TABLE", - type: "keyword", - info: "Display a table dialog with structured data", - detail: "Table dialog", - apply: "@NEXTERM:TABLE \"Process List\" \"PID,Name,CPU%,Memory\" \"1234,nginx,2.5%,45MB\" \"5678,apache,1.8%,32MB\"", - }, - { - label: "@NEXTERM:MSGBOX", - type: "keyword", - info: "Display a message box dialog that halts execution until closed", - detail: "Message box dialog", - apply: "@NEXTERM:MSGBOX \"Information\" \"This is an important message that requires acknowledgment.\"", - }, -]; - -const nextermAutocompletion = (context) => { - const word = context.matchBefore(/(@NEXTERM:?[A-Z]*)/); - if (!word) return null; - - return { - options: nextermCompletions.filter(completion => completion.label.toLowerCase().includes(word.text.toLowerCase())), - from: word.from, to: word.to, - }; -}; - -export const nextermLanguage = () => [ - StreamLanguage.define(nextermShellMode), - syntaxHighlighting(nextermHighlightStyle), - autocompletion({ override: [nextermAutocompletion] }), -]; diff --git a/client/src/common/components/Dialog/Dialog.jsx b/client/src/common/components/Dialog/Dialog.jsx index a31ee11a9..67197cf34 100644 --- a/client/src/common/components/Dialog/Dialog.jsx +++ b/client/src/common/components/Dialog/Dialog.jsx @@ -1,4 +1,5 @@ import React, { createContext, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import Icon from "@mdi/react"; import { mdiClose } from "@mdi/js"; import "./styles.sass"; @@ -47,21 +48,23 @@ export const DialogProvider = ({ disableClosing, open, children, onClose }) => { } }; + const dialogContent = isVisible ? ( +
+
+ {!disableClosing && ( + + )} + {children} +
+
+ ) : null; + return ( - {isVisible && ( -
-
- {!disableClosing && ( - - )} - {children} -
-
- )} + {createPortal(dialogContent, document.body)}
); }; diff --git a/client/src/common/components/Dialog/styles.sass b/client/src/common/components/Dialog/styles.sass index d2fdc2586..b647ece26 100644 --- a/client/src/common/components/Dialog/styles.sass +++ b/client/src/common/components/Dialog/styles.sass @@ -11,43 +11,29 @@ background-color: colors.$dialog-background display: flex align-items: center - z-index: 1000 + z-index: 10000 justify-content: center backdrop-filter: blur(0.3rem) transition: all 0.2s - animation: opacity 0.3s + isolation: isolate .dialog-area-hidden opacity: 0 - animation: opacity 0.3s reverse .dialog - padding: 1.5rem + padding: 1.25rem background-color: colors.$lighter-background border: 1px solid colors.$dark-gray color: colors.$white border-radius: 0.75rem max-width: 80% max-height: 90vh - overflow-y: auto + overflow: hidden box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3) transition: all 0.2s animation: fadeIn 0.3s - scrollbar-width: thin - scrollbar-color: colors.$gray colors.$lighter-background position: relative - &::-webkit-scrollbar - width: 6px - - &::-webkit-scrollbar-track - background: transparent - border-radius: 0 0.75rem 0.75rem 0 - - &::-webkit-scrollbar-thumb - background-color: colors.$gray - border-radius: 10px - .dialog-close-btn position: absolute top: 1rem diff --git a/client/src/common/components/FileEditorWindow/FileEditorWindow.jsx b/client/src/common/components/FileEditorWindow/FileEditorWindow.jsx new file mode 100644 index 000000000..3c25d2acd --- /dev/null +++ b/client/src/common/components/FileEditorWindow/FileEditorWindow.jsx @@ -0,0 +1,138 @@ +import { useContext, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { UserContext } from "@/common/contexts/UserContext.jsx"; +import { useTheme } from "@/common/contexts/ThemeContext.jsx"; +import { downloadRequest } from "@/common/utils/RequestUtil.js"; +import { ActionConfirmDialog } from "@/common/components/ActionConfirmDialog/ActionConfirmDialog.jsx"; +import { useWindowControls } from "@/common/hooks/useWindowControls.js"; +import Editor, { loader } from "@monaco-editor/react"; +import Icon from "@mdi/react"; +import { mdiClose, mdiContentSave, mdiTextBox, mdiArrowAll, mdiWindowMaximize, mdiWindowRestore } from "@mdi/js"; +import "./styles.sass"; +import * as monaco from "monaco-editor"; + +loader.config({ monaco }); + +export const FileEditorWindow = ({ file, session, onClose, sendOperation, zIndex = 9999 }) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + const { sessionToken } = useContext(UserContext); + const [fileContent, setFileContent] = useState(""); + const [fileContentChanged, setFileContentChanged] = useState(false); + const [unsavedChangesDialog, setUnsavedChangesDialog] = useState(false); + + const { + windowRef, headerRef, isMaximized, handleMouseDown, handleResizeStart, toggleMaximize, + getWindowStyle, getWindowClasses, + } = useWindowControls(); + + const toBase64 = (bytes) => btoa(String.fromCodePoint(...bytes)); + + useEffect(() => { + if (!file) return; + + const url = `/api/entries/sftp-download?sessionId=${session.id}&path=${file}&sessionToken=${sessionToken}`; + downloadRequest(url).then((res) => { + const reader = new FileReader(); + reader.onload = () => setFileContent(reader.result); + reader.readAsText(res); + }); + }, [file]); + + const saveFile = () => { + sendOperation(0x2, { path: file }); + const encoder = new TextEncoder(); + for (let i = 0; i < fileContent.length; i += 1024) { + sendOperation(0x3, { chunk: toBase64(encoder.encode(fileContent.substring(i, i + 1024))) }); + } + sendOperation(0x4); + setFileContentChanged(false); + }; + + const closeFile = () => fileContentChanged ? setUnsavedChangesDialog(true) : onClose(); + + const updateContent = (value) => { + setFileContentChanged(true); + setFileContent(value); + }; + + if (!file) return null; + + return ( +
+ + +
+
+ +

{file.split("/").pop()}

+ {fileContentChanged && } +
+
+ + + +
+
+ +
+ +
+ + {!isMaximized && ( +
+ +
+ )} +
+ ); +}; diff --git a/client/src/common/components/FileEditorWindow/index.js b/client/src/common/components/FileEditorWindow/index.js new file mode 100644 index 000000000..f32d25d50 --- /dev/null +++ b/client/src/common/components/FileEditorWindow/index.js @@ -0,0 +1 @@ +export { FileEditorWindow as default } from "./FileEditorWindow"; diff --git a/client/src/common/components/FileEditorWindow/styles.sass b/client/src/common/components/FileEditorWindow/styles.sass new file mode 100644 index 000000000..55377dc24 --- /dev/null +++ b/client/src/common/components/FileEditorWindow/styles.sass @@ -0,0 +1,140 @@ +@use "@/common/styles/colors" + +.file-editor-window + position: fixed + display: flex + flex-direction: column + background-color: colors.$lighter-background + border: 1px solid colors.$gray + border-radius: 0.75rem + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3) + overflow: hidden + animation: slideUp 0.2s cubic-bezier(0.4, 0, 0.2, 1) + transition: box-shadow 0.2s ease + + &.dragging + cursor: move + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 8px 24px rgba(0, 0, 0, 0.4) + + &.maximized + border-radius: 0 + animation: none + +.file-editor-header + display: flex + align-items: center + justify-content: space-between + padding: 1rem 1.5rem + background-color: colors.$terminal + border-bottom: 1px solid colors.$gray + flex-shrink: 0 + cursor: move + user-select: none + + .file-editor-title + display: flex + align-items: center + gap: 0.75rem + min-width: 0 + flex: 1 + + svg + width: 1.25rem + height: 1.25rem + color: colors.$primary + flex-shrink: 0 + + h2 + margin: 0 + font-size: 0.95rem + font-weight: 600 + color: colors.$white + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + letter-spacing: -0.01em + + .modified-indicator + color: colors.$primary + font-size: 1.5rem + line-height: 0 + margin-left: -0.5rem + + .file-editor-actions + display: flex + gap: 0.5rem + flex-shrink: 0 + + .action-btn + display: flex + align-items: center + justify-content: center + width: 2rem + height: 2rem + background: transparent + border: none + border-radius: 0.5rem + cursor: pointer + transition: all 0.1s ease + color: colors.$white + opacity: 0.9 + padding: 0 + + svg + width: 1.125rem + height: 1.125rem + + &:hover:not(:disabled) + background-color: colors.$gray + color: colors.$primary + + &:active:not(:disabled) + transform: scale(0.95) + + &:disabled + opacity: 0.3 + cursor: not-allowed + + &.close-btn:hover + background-color: colors.$error-opacity + color: colors.$error + +.file-editor-content + flex: 1 + min-height: 0 + overflow: hidden + position: relative + + > div + height: 100% + width: 100% + +.resize-handle + position: absolute + bottom: 0 + right: 0 + width: 2rem + height: 2rem + display: flex + align-items: center + justify-content: center + cursor: nwse-resize + color: colors.$subtext + opacity: 0.5 + transition: opacity 0.2s ease + + svg + width: 1rem + height: 1rem + transform: rotate(45deg) + + &:hover + opacity: 1 + +@keyframes slideUp + from + opacity: 0 + transform: translateY(20px) scale(0.95) + to + opacity: 1 + transform: translateY(0) scale(1) diff --git a/client/src/common/components/FilePreviewWindow/FilePreviewWindow.jsx b/client/src/common/components/FilePreviewWindow/FilePreviewWindow.jsx new file mode 100644 index 000000000..7fee79119 --- /dev/null +++ b/client/src/common/components/FilePreviewWindow/FilePreviewWindow.jsx @@ -0,0 +1,158 @@ +import { useContext, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { UserContext } from "@/common/contexts/UserContext.jsx"; +import { useWindowControls } from "@/common/hooks/useWindowControls.js"; +import Icon from "@mdi/react"; +import { + mdiClose, + mdiImage, + mdiFileDownload, + mdiArrowAll, + mdiWindowMaximize, + mdiWindowRestore, +} from "@mdi/js"; +import "./styles.sass"; + +export const FilePreviewWindow = ({ file, session, onClose, zIndex = 9999 }) => { + const { t } = useTranslation(); + const { sessionToken } = useContext(UserContext); + const [fileUrl, setFileUrl] = useState(null); + const [fileType, setFileType] = useState(null); + + const { + windowRef, headerRef, isMaximized, handleMouseDown, + handleResizeStart, toggleMaximize, getWindowStyle, getWindowClasses, + } = useWindowControls(); + + useEffect(() => { + if (!file) { + setFileUrl(null); + setFileType(null); + return; + } + + const extension = file.split(".").pop()?.toLowerCase(); + const url = `/api/entries/sftp-download?sessionId=${session.id}&path=${file}&sessionToken=${sessionToken}&preview=true`; + + setFileUrl(url); + + if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(extension)) { + setFileType("image"); + } else if (["mp4", "webm", "ogg", "mov"].includes(extension)) { + setFileType("video"); + } else if (["mp3", "wav", "ogg", "flac", "m4a"].includes(extension)) { + setFileType("audio"); + } else if (["pdf"].includes(extension)) { + setFileType("pdf"); + } else { + setFileType("unknown"); + } + }, [file, session.id, sessionToken]); + + const downloadFile = () => { + const link = document.createElement("a"); + link.href = fileUrl; + link.download = file.split("/").pop(); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const renderPreview = () => { + switch (fileType) { + case "image": + return ( +
+ {file} +
+ ); + case "video": + return ( +
+ +
+ ); + case "audio": + return ( +
+ +

{file.split("/").pop()}

+ +
+ ); + case "pdf": + return ( +
+