* feat: add draggable server stats dashboard with customizable widgets * fix: widget deletion and layout persistence issues * fix: improve widget deletion UX and add debug logs for persistence * fix: resolve widget deletion and layout persistence issues - Add drag handles to widget title bars for precise drag control - Prevent delete button from triggering drag via event stopPropagation - Include statsConfig field in all GET/PUT API responses - Remove debug console logs from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete statsConfig field support across all API routes - Add statsConfig to POST /db/host (create) route - Add statsConfig to all GET routes for consistent API responses - Remove incorrect statsConfig schema from HostManagerEditor - statsConfig is now only managed by Server page layout editor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add statsConfig to metrics API response - Add statsConfig field to SSHHostWithCredentials interface - Include statsConfig in resolveHostCredentials baseHost object - Ensures /metrics/:id API returns complete host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: include statsConfig in SSH host create/update requests The statsConfig field was being dropped by createSSHHost and updateSSHHost functions in main-axios.ts, preventing layout customization from persisting. Fixed by adding statsConfig to the submitData object in both functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: refactor server stats widgets into modular structure Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exit edit mode after saving layout * feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. * refactor: simplify server stats widget system Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture. * feat: add system, uptime, network and processes widgets Add four new server statistics widgets: - SystemWidget: displays hostname, OS, and kernel information - UptimeWidget: shows server total uptime with formatted display - NetworkWidget: lists network interfaces with IP and status - ProcessesWidget: displays top processes by CPU usage Backend changes: - Extended SSH metrics collection to gather network, uptime, process, and system data - Added commands to parse /proc/uptime, ip addr, ps aux output Frontend changes: - Created 4 new widget components with consistent styling - Updated widget type definitions and HostManagerEditor - Unified all widget heights to 280px for consistent layout - Added translations for all new widgets (EN/ZH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve widget styling and UX consistency Enhance all server stats widgets with improved styling and user experience: Widget improvements: - Fix hardcoded titles, now use i18n translations for all widgets - Improve data formatting with consistent translation keys - Enhance empty state displays with better visual hierarchy - Add smooth hover transitions and visual feedback - Standardize spacing and layout patterns across widgets Specific optimizations: - CPU: Use translated load average display - Memory: Translate "Free" label - Disk: Translate "Available" label - System: Improve icon colors and spacing consistency - Network: Better empty state, enhanced card styling - Processes: Improved card borders and spacing Visual polish: - Unified icon sizing and opacity for empty states - Consistent border radius (rounded-lg) - Better hover states with subtle transitions - Enhanced font weights for improved readability * fix: replace explicit any types with proper TypeScript types - Replace 'any' with 'unknown' in catch blocks and add type assertions - Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle) - Fix window/document object type extensions - Update Electron API type definitions - Improve type safety in database routes and utilities - Add proper types to Terminal components (Desktop & Mobile) - Fix navigation component types (TopNavbar, LeftSidebar, AppView) Reduces TypeScript lint errors from 394 to 358 (-36 errors) Fixes 45 @typescript-eslint/no-explicit-any violations * fix: replace explicit any types with proper TypeScript types - Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders) - Add type definitions for WebSocket messages and SSH connection data - Use generic types in DataCrypto methods instead of any return types - Define proper interfaces for file manager data structures - Replace catch block any types with unknown and proper type assertions - Add HostConfig and TabData interfaces for Server component Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files * fix: resolve 6 TypeScript compilation errors Fixed field name mismatches and generic type issues: - database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields - simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in backend utils Fixed @typescript-eslint/no-unused-vars errors in: - starter.ts: removed unused error variables (2 fixes) - auto-ssl-setup.ts: removed unused error variable (1 fix) - ssh-key-utils.ts: removed unused error variables (3 fixes) - user-crypto.ts: removed unused error variables (5 fixes) - data-crypto.ts: removed unused plaintextFields and error variables (2 fixes) - simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes) Total: 15 unused variable errors fixed Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variable in terminal.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused userPayload variable (line 123) - Removed unused cols and rows from destructuring (line 348) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in server-stats.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused _reject parameter in Promise (line 64) - Removed shadowed now variable in pollStatusesOnce (line 1130) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in tunnel.ts Removed 5 unused variables: - Removed unused data parameter from stdout event handler - Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in main-axios.ts Removed 8 unused variables: - Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse) - Removed unused apiPort variable - Removed unused error variables in 3 catch blocks * fix: remove unused variables in terminal.ts and starter.ts Removed 2 unused variables: - Removed unused JWTPayload type import from terminal.ts - Removed unused _promise parameter from starter.ts * fix: remove unused variables in sidebar.tsx Removed 9 unused variables: - Removed 5 unused Sheet component imports - Removed unused SIDEBAR_WIDTH_MOBILE constant - Removed 3 unused variables from useSidebar destructuring * fix: remove 13 unused variables in frontend files - version-check-modal.tsx: removed 4 unused imports and functions - main.tsx: removed unused isMobile state - AdminSettings.tsx: removed 8 unused imports and error variables * fix: remove 28 unused variables across frontend components Cleaned up unused imports, state variables, and function parameters: - CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports) - FileManager.tsx: removed 10 unused variables (icons, state, functions) - Terminal.tsx (Desktop): removed 5 unused variables (state, handlers) - Terminal.tsx (Mobile): removed 5 unused variables (imports, state) Reduced lint errors from 271 to 236 (35 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in File Manager and config files Cleaned up more unused imports, parameters, and variables: - FileManagerGrid.tsx: removed 4 unused variables (params, function) - FileManagerContextMenu.tsx: removed Share import - FileManagerSidebar.tsx: removed onLoadDirectory parameter - DraggableWindow.tsx: removed Square import - FileWindow.tsx: removed updateWindow variable - ServerConfig.tsx: removed 2 unused error parameters Reduced lint errors from 236 to 222 (14 errors fixed total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 7 unused variables in widgets and Homepage components Cleaned up unused imports, parameters, and variables: - DiskWidget.tsx: removed metricsHistory parameter - FileManagerContextMenu.tsx: removed ExternalLink import - Homepage.tsx: removed useTranslation import - HomepageAlertManager.tsx: removed loading variable - HomepageAuth.tsx: removed setCookie import (Desktop & Mobile) - HompageUpdateLog.tsx: removed err parameter Reduced lint errors from 222 to 216 (6 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 8 unused variables in File Manager and Host Manager components Cleaned up unused imports, state variables, and function parameters: - DiffViewer.tsx: removed unused error parameter in catch block - FileViewer.tsx: removed ReactPlayer import, unused originalContent state, node parameters from markdown code components, audio variable - HostManager.tsx: removed onSelectView and updatedHost parameters - TunnelViewer.tsx: removed TunnelConnection import Reduced lint errors from 271 to 208 (63 errors fixed total) * fix: remove 7 unused variables in UI hooks and components Cleaned up unused parameters and functions: - status/index.tsx: removed unused className parameter from StatusIndicator - useDragToDesktop.ts: removed unused sshHost parameter and from dependency arrays (4 occurrences) - useDragToSystemDesktop.ts: removed unused sshHost parameter and getLastSaveDirectory function (29 lines removed) Continued reducing frontend lint errors * fix: remove 2 unused variables in hooks and TabContext - useDragToDesktop.ts: removed unused onSuccess in dragFolderToDesktop - TabContext.tsx: removed unused useTranslation import and t variable Continued reducing frontend lint errors * fix: remove 2 unused variables in Homepage component - Removed unused isAdmin state variable (changed to setter only) - Removed unused jwt variable by inlining getCookie check Continued reducing frontend lint errors * fix: remove 3 unused variables in Mobile navigation components - Host.tsx: removed unused Server icon import - LeftSidebar.tsx: removed unused setHostsLoading setter and err parameter Continued reducing frontend lint errors * fix: remove 9 unused variables across multiple files Fixed unused variables in: - database-file-encryption.ts: removed currentFingerprint (backend) - FileManagerContextMenu.tsx: removed ExternalLink import, hasDirectories - frontend-logger.ts: removed 5 unused shortUrl variables Continued reducing lint errors * fix: remove 18 unused variables across 4 files - HostManagerViewer.tsx: remove 9 unused error variables and parameters - HostManagerEditor.tsx: remove WidgetType import, hosts/loading states, error variable - CredentialViewer.tsx: remove 3 unused error variables - Server.tsx: remove 2 unused error variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across 4 files - SnippetsSidebar.tsx: remove 3 unused err variables in catch blocks - TunnelViewer.tsx: remove 2 unused parameters from callback - DesktopApp.tsx: remove getCookie import and unused state variables - HomepageAlertManager.tsx: remove 2 unused err variables in catch blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables and imports across 4 navigation files - Homepage.tsx: remove unused username state variable - AppView.tsx: remove 3 unused Lucide icon imports - TopNavbar.tsx: remove 4 unused Accordion component imports - LeftSidebar.tsx: remove 2 unused variables (err, jwt) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 5 unused variables across 4 user/credentials files - PasswordReset.tsx: remove unused result variable - UserProfile.tsx: remove unused Key import and err variable - version-check-modal.tsx: remove unused setVersionDismissed setter - CredentialsManager.tsx: remove unused e parameter from handleDragLeave 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 2 unused variables in FileViewer and TerminalWindow - FileViewer.tsx: remove unused node parameter from code component - TerminalWindow.tsx: remove unused handleMinimize function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in HomepageAuth.tsx Removed unused variables: - getCookie import - dbError prop - visibility state and toggleVisibility - error state variable - result variable in handleInitiatePasswordReset - token URL parameter - err parameters in catch blocks - retryDatabaseConnection function - Multiple setError(null) calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across multiple files Files fixed: - DesktopApp.tsx: Removed _nextView parameter - TerminalWindow.tsx: Removed minimizeWindow - Mobile Host.tsx: Removed Server import - Mobile LeftSidebar.tsx: Removed setHostsLoading, err in catch - Desktop LeftSidebar.tsx: Removed getCookie, setCookie, onSelectView, getView, setHostsLoading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in Mobile files Files fixed: - MobileApp.tsx: Removed getCookie, removeTab, isAdmin, id, err parameters - Mobile/HomepageAuth.tsx: Removed getCookie, error state, result, token, err parameters All @typescript-eslint/no-unused-vars errors in frontend now resolved! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused t variable in TabContext Removed useTranslation import and unused t variable in Mobile TabContext.tsx All @typescript-eslint/no-unused-vars errors now resolved! Total fixed: 154 unused variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve TypeScript and ESLint errors across the codebase - Fixed @typescript-eslint/no-unused-vars errors (31 instances) - Fixed @typescript-eslint/no-explicit-any errors in backend (~22 instances) - Fixed @typescript-eslint/no-explicit-any errors in frontend (~60 instances) - Fixed prefer-const errors (5 instances) - Fixed no-empty-object-type and rules-of-hooks errors - Added proper type assertions for database operations - Improved type safety in authentication and encryption modules - Enhanced type definitions for API routes and SSH operations All TypeScript compilation errors resolved. Application builds and runs successfully. * fix: disable react-refresh/only-export-components rule for component files Disable the react-refresh/only-export-components ESLint rule in files that export both components and related utilities (hooks, types, constants). This is a pragmatic solution to maintain code organization without splitting files unnecessarily. * style: fix prettier formatting issues Fix code style issues in translation file and TOTP dialog component to pass CI prettier check. * chore: fix rollup optional dependencies installation in CI Add step to force reinstall rollup after npm ci to fix the known npm bug with optional dependencies on Linux x64 platform. * chore: fix lightningcss optional dependencies in CI Add lightningcss to the force reinstall step to fix npm optional dependencies bug for both rollup and lightningcss on Linux x64. * chore: fix npm optional dependencies bug in CI Remove package-lock.json and node_modules before install to properly handle optional dependencies for rollup, lightningcss, and tailwindcss native bindings on Linux x64 platform as recommended by npm. * Update src/types/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Set terminal environment variables for SSH Added environment variables for terminal configuration. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1442 lines
52 KiB
TypeScript
1442 lines
52 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
FileText,
|
||
Image as ImageIcon,
|
||
Film,
|
||
Music,
|
||
File as FileIcon,
|
||
Code,
|
||
AlertCircle,
|
||
Download,
|
||
Eye,
|
||
Edit,
|
||
Save,
|
||
RotateCcw,
|
||
Keyboard,
|
||
Search,
|
||
} from "lucide-react";
|
||
import {
|
||
SiJavascript,
|
||
SiTypescript,
|
||
SiPython,
|
||
SiOracle,
|
||
SiCplusplus,
|
||
SiC,
|
||
SiDotnet,
|
||
SiPhp,
|
||
SiRuby,
|
||
SiGo,
|
||
SiRust,
|
||
SiHtml5,
|
||
SiCss3,
|
||
SiSass,
|
||
SiLess,
|
||
SiJson,
|
||
SiXml,
|
||
SiYaml,
|
||
SiToml,
|
||
SiShell,
|
||
SiVuedotjs,
|
||
SiSvelte,
|
||
SiMarkdown,
|
||
SiGnubash,
|
||
SiMysql,
|
||
SiDocker,
|
||
} from "react-icons/si";
|
||
import { Button } from "@/components/ui/button";
|
||
import CodeMirror from "@uiw/react-codemirror";
|
||
import { oneDark } from "@codemirror/theme-one-dark";
|
||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||
import { EditorView, keymap } from "@codemirror/view";
|
||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||
import {
|
||
defaultKeymap,
|
||
history,
|
||
historyKeymap,
|
||
toggleComment,
|
||
} from "@codemirror/commands";
|
||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||
import "react-photo-view/dist/react-photo-view.css";
|
||
import AudioPlayer from "react-h5-audio-player";
|
||
import "react-h5-audio-player/lib/styles.css";
|
||
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||
import { Document, Page, pdfjs } from "react-pdf";
|
||
|
||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||
|
||
interface FileItem {
|
||
name: string;
|
||
type: "file" | "directory" | "link";
|
||
path: string;
|
||
size?: number;
|
||
modified?: string;
|
||
permissions?: string;
|
||
owner?: string;
|
||
group?: string;
|
||
}
|
||
|
||
interface FileViewerProps {
|
||
file: FileItem;
|
||
content?: string;
|
||
savedContent?: string;
|
||
isLoading?: boolean;
|
||
isEditable?: boolean;
|
||
onContentChange?: (content: string) => void;
|
||
onSave?: (content: string) => void;
|
||
onRevert?: () => void;
|
||
onDownload?: () => void;
|
||
onMediaDimensionsChange?: (dimensions: {
|
||
width: number;
|
||
height: number;
|
||
}) => void;
|
||
}
|
||
|
||
function getLanguageIcon(filename: string): React.ReactNode {
|
||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||
const baseName = filename.toLowerCase();
|
||
|
||
if (["dockerfile"].includes(baseName)) {
|
||
return <SiDocker className="w-6 h-6 text-blue-400" />;
|
||
}
|
||
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||
return <SiRuby className="w-6 h-6 text-red-500" />;
|
||
}
|
||
|
||
const iconMap: Record<string, React.ReactNode> = {
|
||
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
|
||
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
|
||
py: <SiPython className="w-6 h-6 text-blue-400" />,
|
||
java: <SiOracle className="w-6 h-6 text-red-500" />,
|
||
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
|
||
c: <SiC className="w-6 h-6 text-blue-700" />,
|
||
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
|
||
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
|
||
rb: <SiRuby className="w-6 h-6 text-red-500" />,
|
||
go: <SiGo className="w-6 h-6 text-cyan-500" />,
|
||
rs: <SiRust className="w-6 h-6 text-orange-600" />,
|
||
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
|
||
css: <SiCss3 className="w-6 h-6 text-blue-500" />,
|
||
scss: <SiSass className="w-6 h-6 text-pink-500" />,
|
||
sass: <SiSass className="w-6 h-6 text-pink-500" />,
|
||
less: <SiLess className="w-6 h-6 text-blue-600" />,
|
||
json: <SiJson className="w-6 h-6 text-yellow-500" />,
|
||
xml: <SiXml className="w-6 h-6 text-orange-500" />,
|
||
yaml: <SiYaml className="w-6 h-6 text-red-400" />,
|
||
yml: <SiYaml className="w-6 h-6 text-red-400" />,
|
||
toml: <SiToml className="w-6 h-6 text-orange-400" />,
|
||
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
|
||
sh: <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||
bash: <SiGnubash className="w-6 h-6 text-gray-700" />,
|
||
zsh: <SiShell className="w-6 h-6 text-gray-700" />,
|
||
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
|
||
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
|
||
md: <SiMarkdown className="w-6 h-6 text-gray-600" />,
|
||
conf: <SiShell className="w-6 h-6 text-gray-600" />,
|
||
ini: <Code className="w-6 h-6 text-gray-600" />,
|
||
};
|
||
|
||
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
|
||
}
|
||
|
||
function getFileType(filename: string): {
|
||
type: string;
|
||
icon: React.ReactNode;
|
||
color: string;
|
||
} {
|
||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||
|
||
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
|
||
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
|
||
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
|
||
const textExts = ["txt", "readme"];
|
||
const markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"];
|
||
const pdfExts = ["pdf"];
|
||
const codeExts = [
|
||
"js",
|
||
"ts",
|
||
"jsx",
|
||
"tsx",
|
||
"py",
|
||
"java",
|
||
"cpp",
|
||
"c",
|
||
"cs",
|
||
"php",
|
||
"rb",
|
||
"go",
|
||
"rs",
|
||
"html",
|
||
"css",
|
||
"scss",
|
||
"less",
|
||
"json",
|
||
"xml",
|
||
"yaml",
|
||
"yml",
|
||
"toml",
|
||
"ini",
|
||
"conf",
|
||
"sh",
|
||
"bash",
|
||
"zsh",
|
||
"sql",
|
||
"vue",
|
||
"svelte",
|
||
];
|
||
|
||
if (imageExts.includes(ext)) {
|
||
return {
|
||
type: "image",
|
||
icon: <ImageIcon className="w-6 h-6" />,
|
||
color: "text-green-500",
|
||
};
|
||
} else if (videoExts.includes(ext)) {
|
||
return {
|
||
type: "video",
|
||
icon: <Film className="w-6 h-6" />,
|
||
color: "text-purple-500",
|
||
};
|
||
} else if (audioExts.includes(ext)) {
|
||
return {
|
||
type: "audio",
|
||
icon: <Music className="w-6 h-6" />,
|
||
color: "text-pink-500",
|
||
};
|
||
} else if (markdownExts.includes(ext)) {
|
||
return {
|
||
type: "markdown",
|
||
icon: <FileText className="w-6 h-6" />,
|
||
color: "text-blue-600",
|
||
};
|
||
} else if (pdfExts.includes(ext)) {
|
||
return {
|
||
type: "pdf",
|
||
icon: <FileText className="w-6 h-6" />,
|
||
color: "text-red-600",
|
||
};
|
||
} else if (textExts.includes(ext)) {
|
||
return {
|
||
type: "text",
|
||
icon: <FileText className="w-6 h-6" />,
|
||
color: "text-blue-500",
|
||
};
|
||
} else if (codeExts.includes(ext)) {
|
||
return {
|
||
type: "code",
|
||
icon: getLanguageIcon(filename),
|
||
color: "text-yellow-500",
|
||
};
|
||
} else {
|
||
return {
|
||
type: "unknown",
|
||
icon: <FileIcon className="w-6 h-6" />,
|
||
color: "text-gray-500",
|
||
};
|
||
}
|
||
}
|
||
|
||
function getLanguageExtension(filename: string) {
|
||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||
const baseName = filename.toLowerCase();
|
||
|
||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||
return loadLanguage(baseName);
|
||
}
|
||
|
||
const langMap: Record<string, string> = {
|
||
js: "javascript",
|
||
jsx: "jsx",
|
||
ts: "typescript",
|
||
tsx: "tsx",
|
||
py: "python",
|
||
java: "java",
|
||
cpp: "cpp",
|
||
c: "c",
|
||
cs: "csharp",
|
||
php: "php",
|
||
rb: "ruby",
|
||
go: "go",
|
||
rs: "rust",
|
||
html: "html",
|
||
css: "css",
|
||
scss: "sass",
|
||
less: "less",
|
||
json: "json",
|
||
xml: "xml",
|
||
yaml: "yaml",
|
||
yml: "yaml",
|
||
toml: "toml",
|
||
sql: "sql",
|
||
sh: "shell",
|
||
bash: "shell",
|
||
zsh: "shell",
|
||
vue: "vue",
|
||
svelte: "svelte",
|
||
md: "markdown",
|
||
conf: "shell",
|
||
ini: "properties",
|
||
};
|
||
|
||
const language = langMap[ext];
|
||
return language ? loadLanguage(language) : null;
|
||
}
|
||
|
||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||
const sizes = ["B", "KB", "MB", "GB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||
}
|
||
|
||
export function FileViewer({
|
||
file,
|
||
content = "",
|
||
savedContent = "",
|
||
isLoading = false,
|
||
isEditable = false,
|
||
onContentChange,
|
||
onSave,
|
||
onRevert,
|
||
onDownload,
|
||
onMediaDimensionsChange,
|
||
}: FileViewerProps) {
|
||
const { t } = useTranslation();
|
||
const [editedContent, setEditedContent] = useState(content);
|
||
const [, setOriginalContent] = useState(savedContent || content);
|
||
const [hasChanges, setHasChanges] = useState(false);
|
||
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
|
||
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
||
const [editorFocused, setEditorFocused] = useState(false);
|
||
const [imageLoadError, setImageLoadError] = useState(false);
|
||
const [imageLoading, setImageLoading] = useState(true);
|
||
const [numPages, setNumPages] = useState<number | null>(null);
|
||
const [pageNumber, setPageNumber] = useState(1);
|
||
const [pdfScale, setPdfScale] = useState(1.2);
|
||
const [pdfError, setPdfError] = useState(false);
|
||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||
const editorRef = useRef<{
|
||
view?: { dispatch: (transaction: unknown) => void };
|
||
} | null>(null);
|
||
|
||
const fileTypeInfo = getFileType(file.name);
|
||
|
||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||
|
||
const shouldShowAsText =
|
||
fileTypeInfo.type === "text" ||
|
||
fileTypeInfo.type === "code" ||
|
||
(fileTypeInfo.type === "unknown" &&
|
||
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
|
||
|
||
const isLargeFile = file.size && file.size > WARNING_SIZE;
|
||
const isTooLarge = file.size && file.size > MAX_SIZE;
|
||
|
||
useEffect(() => {
|
||
setEditedContent(content);
|
||
if (savedContent) {
|
||
setOriginalContent(savedContent);
|
||
}
|
||
setHasChanges(content !== savedContent);
|
||
|
||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||
setShowLargeFileWarning(true);
|
||
} else {
|
||
setShowLargeFileWarning(false);
|
||
}
|
||
|
||
if (
|
||
fileTypeInfo.type === "image" &&
|
||
file.name.toLowerCase().endsWith(".svg") &&
|
||
content
|
||
) {
|
||
setImageLoading(false);
|
||
setImageLoadError(false);
|
||
}
|
||
}, [
|
||
content,
|
||
savedContent,
|
||
fileTypeInfo.type,
|
||
isLargeFile,
|
||
forceShowAsText,
|
||
file.name,
|
||
]);
|
||
|
||
const handleContentChange = (newContent: string) => {
|
||
setEditedContent(newContent);
|
||
setHasChanges(newContent !== savedContent);
|
||
onContentChange?.(newContent);
|
||
};
|
||
|
||
const handleSave = () => {
|
||
onSave?.(editedContent);
|
||
};
|
||
|
||
const handleRevert = () => {
|
||
if (onRevert) {
|
||
onRevert();
|
||
} else {
|
||
setEditedContent(savedContent);
|
||
setHasChanges(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!editorFocused || !isEditable) return;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
const isCtrl = e.ctrlKey || e.metaKey;
|
||
if (isCtrl && e.key.toLowerCase() === "s") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
handleSave();
|
||
}
|
||
};
|
||
|
||
document.addEventListener("keydown", handleKeyDown, true);
|
||
|
||
return () => {
|
||
document.removeEventListener("keydown", handleKeyDown, true);
|
||
};
|
||
}, [editorFocused, isEditable, handleSave]);
|
||
|
||
const handleConfirmOpenAsText = () => {
|
||
setForceShowAsText(true);
|
||
setShowLargeFileWarning(false);
|
||
};
|
||
|
||
const handleCancelOpenAsText = () => {
|
||
setShowLargeFileWarning(false);
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||
<p className="text-sm text-gray-600">Loading file...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col bg-background">
|
||
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
||
{fileTypeInfo.icon}
|
||
</div>
|
||
<div>
|
||
<h3 className="font-medium text-foreground">{file.name}</h3>
|
||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||
<span>{formatFileSize(file.size, t)}</span>
|
||
{file.modified && (
|
||
<span>
|
||
{t("fileManager.modified")}: {file.modified}
|
||
</span>
|
||
)}
|
||
<span
|
||
className={cn(
|
||
"px-2 py-1 rounded-full text-xs",
|
||
fileTypeInfo.color,
|
||
"bg-muted",
|
||
)}
|
||
>
|
||
{fileTypeInfo.type.toUpperCase()}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{isEditable && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
if (editorRef.current) {
|
||
const view = editorRef.current.view;
|
||
if (view) {
|
||
openSearchPanel(view);
|
||
}
|
||
}
|
||
}}
|
||
className="flex items-center gap-2"
|
||
title={t("fileManager.searchInFile")}
|
||
>
|
||
<Search className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
{isEditable && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
|
||
className="flex items-center gap-2"
|
||
title={t("fileManager.showKeyboardShortcuts")}
|
||
>
|
||
<Keyboard className="w-4 h-4" />
|
||
</Button>
|
||
)}
|
||
{hasChanges && (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRevert}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
Revert
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
onClick={handleSave}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Save className="w-4 h-4" />
|
||
Save
|
||
</Button>
|
||
</>
|
||
)}
|
||
{onDownload && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onDownload}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
{t("fileManager.download")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showKeyboardShortcuts && isEditable && (
|
||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-sm font-semibold">
|
||
{t("fileManager.keyboardShortcuts")}
|
||
</h3>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setShowKeyboardShortcuts(false)}
|
||
className="h-6 w-6 p-0"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||
<div className="space-y-2">
|
||
<h4 className="font-medium text-muted-foreground">
|
||
{t("fileManager.searchAndReplace")}
|
||
</h4>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.search")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+F
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.replace")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+H
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.findNext")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
F3
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.findPrevious")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Shift+F3
|
||
</kbd>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<h4 className="font-medium text-muted-foreground">
|
||
{t("fileManager.editing")}
|
||
</h4>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.save")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+S
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.selectAll")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+A
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.undo")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+Z
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.redo")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+Y
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.toggleComment")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+/
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.autoComplete")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Ctrl+Space
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.moveLineUp")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Alt+↑
|
||
</kbd>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>{t("fileManager.moveLineDown")}</span>
|
||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||
Alt+↓
|
||
</kbd>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 overflow-hidden">
|
||
{showLargeFileWarning && (
|
||
<div className="h-full flex items-center justify-center bg-background">
|
||
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
|
||
<div className="flex items-start gap-3 mb-4">
|
||
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
|
||
<div>
|
||
<h3 className="font-medium text-foreground mb-2">
|
||
{t("fileManager.largeFileWarning")}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground mb-3">
|
||
{t("fileManager.largeFileWarningDesc", {
|
||
size: formatFileSize(file.size, t),
|
||
})}
|
||
</p>
|
||
{isTooLarge ? (
|
||
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
|
||
<p className="text-sm text-destructive font-medium">
|
||
File is too large (> 10MB) and cannot be opened as
|
||
text for security reasons.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Do you want to continue opening this file as text? This
|
||
may slow down your browser.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
{!isTooLarge && (
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
onClick={handleConfirmOpenAsText}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
Open as Text
|
||
</Button>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onDownload}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
{t("fileManager.downloadInstead")}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleCancelOpenAsText}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||
<div className="p-6 flex items-center justify-center h-full relative">
|
||
{imageLoadError ? (
|
||
<div className="text-center text-muted-foreground">
|
||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h3 className="text-lg font-medium mb-2">
|
||
{t("fileManager.imageLoadError")}
|
||
</h3>
|
||
<p className="text-sm mb-4">{file.name}</p>
|
||
{onDownload && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={onDownload}
|
||
className="flex items-center gap-2 mx-auto"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
{t("fileManager.download")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : file.name.toLowerCase().endsWith(".svg") ? (
|
||
<div
|
||
className="max-w-full max-h-full flex items-center justify-center"
|
||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||
dangerouslySetInnerHTML={{ __html: content }}
|
||
onLoad={() => {
|
||
setImageLoading(false);
|
||
setImageLoadError(false);
|
||
}}
|
||
/>
|
||
) : (
|
||
<PhotoProvider maskOpacity={0.7}>
|
||
<PhotoView src={`data:image/*;base64,${content}`}>
|
||
<img
|
||
src={`data:image/*;base64,${content}`}
|
||
alt={file.name}
|
||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
|
||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||
onLoad={(e) => {
|
||
setImageLoading(false);
|
||
setImageLoadError(false);
|
||
|
||
const img = e.currentTarget;
|
||
if (
|
||
onMediaDimensionsChange &&
|
||
img.naturalWidth &&
|
||
img.naturalHeight
|
||
) {
|
||
onMediaDimensionsChange({
|
||
width: img.naturalWidth,
|
||
height: img.naturalHeight,
|
||
});
|
||
}
|
||
}}
|
||
onError={() => {
|
||
setImageLoading(false);
|
||
setImageLoadError(true);
|
||
}}
|
||
/>
|
||
</PhotoView>
|
||
</PhotoProvider>
|
||
)}
|
||
|
||
{imageLoading && !imageLoadError && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Loading image...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{shouldShowAsText && !showLargeFileWarning && (
|
||
<div className="h-full flex flex-col">
|
||
{isEditable ? (
|
||
<CodeMirror
|
||
ref={editorRef}
|
||
value={editedContent}
|
||
onChange={(value) => handleContentChange(value)}
|
||
onFocus={() => setEditorFocused(true)}
|
||
onBlur={() => setEditorFocused(false)}
|
||
extensions={[
|
||
...(getLanguageExtension(file.name)
|
||
? [getLanguageExtension(file.name)!]
|
||
: []),
|
||
history(),
|
||
search(),
|
||
autocompletion(),
|
||
keymap.of([
|
||
...defaultKeymap,
|
||
...searchKeymap,
|
||
...historyKeymap,
|
||
...completionKeymap,
|
||
{
|
||
key: "Mod-/",
|
||
run: toggleComment,
|
||
preventDefault: true,
|
||
},
|
||
{
|
||
key: "Mod-h",
|
||
run: () => {
|
||
return false;
|
||
},
|
||
preventDefault: true,
|
||
},
|
||
]),
|
||
EditorView.theme({
|
||
"&": {
|
||
height: "100%",
|
||
},
|
||
".cm-scroller": {
|
||
overflow: "auto",
|
||
},
|
||
".cm-editor": {
|
||
height: "100%",
|
||
},
|
||
}),
|
||
]}
|
||
theme={oneDark}
|
||
placeholder={t("fileManager.startTyping")}
|
||
className="h-full"
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: true,
|
||
dropCursor: false,
|
||
allowMultipleSelections: false,
|
||
indentOnInput: true,
|
||
bracketMatching: true,
|
||
closeBrackets: true,
|
||
autocompletion: true,
|
||
highlightSelectionMatches: false,
|
||
scrollPastEnd: false,
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
|
||
{editedContent || content || t("fileManager.fileIsEmpty")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
|
||
<div className="p-6 flex items-center justify-center h-full">
|
||
<div className="w-full max-w-4xl">
|
||
{(() => {
|
||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||
const mimeType = (() => {
|
||
switch (ext) {
|
||
case "mp4":
|
||
return "video/mp4";
|
||
case "webm":
|
||
return "video/webm";
|
||
case "mkv":
|
||
return "video/x-matroska";
|
||
case "avi":
|
||
return "video/x-msvideo";
|
||
case "mov":
|
||
return "video/quicktime";
|
||
case "wmv":
|
||
return "video/x-ms-wmv";
|
||
case "flv":
|
||
return "video/x-flv";
|
||
default:
|
||
return "video/mp4";
|
||
}
|
||
})();
|
||
|
||
const videoUrl = `data:${mimeType};base64,${content}`;
|
||
|
||
return (
|
||
<div className="relative">
|
||
<video
|
||
controls
|
||
className="w-full rounded-lg shadow-sm"
|
||
style={{
|
||
maxHeight: "calc(100vh - 200px)",
|
||
backgroundColor: "#000",
|
||
}}
|
||
preload="metadata"
|
||
onError={(e) => {
|
||
console.error(
|
||
"Video playback error:",
|
||
e.currentTarget.error,
|
||
);
|
||
}}
|
||
onLoadedMetadata={(e) => {
|
||
const video = e.currentTarget;
|
||
if (
|
||
onMediaDimensionsChange &&
|
||
video.videoWidth &&
|
||
video.videoHeight
|
||
) {
|
||
onMediaDimensionsChange({
|
||
width: video.videoWidth,
|
||
height: video.videoHeight,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<source src={videoUrl} type={mimeType} />
|
||
<div className="text-center text-muted-foreground p-4">
|
||
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
|
||
<p>
|
||
Your browser does not support video playback for this
|
||
format.
|
||
</p>
|
||
{onDownload && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={onDownload}
|
||
className="mt-2 flex items-center gap-2 mx-auto"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
Download to play externally
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</video>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
|
||
<div className="h-full flex flex-col">
|
||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant={markdownEditMode ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setMarkdownEditMode(true)}
|
||
>
|
||
<Edit className="w-4 h-4 mr-1" />
|
||
{t("fileManager.edit")}
|
||
</Button>
|
||
<Button
|
||
variant={!markdownEditMode ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setMarkdownEditMode(false)}
|
||
>
|
||
<Eye className="w-4 h-4 mr-1" />
|
||
{t("fileManager.preview")}
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-2"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 flex overflow-hidden">
|
||
{markdownEditMode ? (
|
||
<>
|
||
<div className="flex-1 border-r border-border">
|
||
<div className="h-full p-4 bg-background">
|
||
<textarea
|
||
value={editedContent}
|
||
onChange={(e) => {
|
||
setEditedContent(e.target.value);
|
||
onContentChange?.(e.target.value);
|
||
}}
|
||
className="w-full h-full resize-none border-0 bg-transparent text-foreground font-mono text-sm leading-relaxed focus:outline-none focus:ring-0"
|
||
placeholder={t("fileManager.startWritingMarkdown")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto bg-muted/10">
|
||
<div className="p-4">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
components={{
|
||
code({ inline, className, children, ...props }) {
|
||
const match = /language-(\w+)/.exec(
|
||
className || "",
|
||
);
|
||
return !inline && match ? (
|
||
<SyntaxHighlighter
|
||
style={syntaxTheme}
|
||
language={match[1]}
|
||
PreTag="div"
|
||
className="rounded-lg"
|
||
{...props}
|
||
>
|
||
{String(children).replace(/\n$/, "")}
|
||
</SyntaxHighlighter>
|
||
) : (
|
||
<code
|
||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||
{...props}
|
||
>
|
||
{children}
|
||
</code>
|
||
);
|
||
},
|
||
h1: ({ children }) => (
|
||
<h1 className="text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2">
|
||
{children}
|
||
</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className="text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1">
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className="text-lg font-semibold mb-2 mt-4 text-foreground">
|
||
{children}
|
||
</h3>
|
||
),
|
||
h4: ({ children }) => (
|
||
<h4 className="text-base font-semibold mb-2 mt-3 text-foreground">
|
||
{children}
|
||
</h4>
|
||
),
|
||
p: ({ children }) => (
|
||
<p className="mb-3 text-foreground leading-relaxed">
|
||
{children}
|
||
</p>
|
||
),
|
||
ul: ({ children }) => (
|
||
<ul className="mb-3 ml-4 list-disc text-foreground">
|
||
{children}
|
||
</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<ol className="mb-3 ml-4 list-decimal text-foreground">
|
||
{children}
|
||
</ol>
|
||
),
|
||
li: ({ children }) => (
|
||
<li className="mb-1 text-foreground">{children}</li>
|
||
),
|
||
blockquote: ({ children }) => (
|
||
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
table: ({ children }) => (
|
||
<div className="mb-3 overflow-x-auto">
|
||
<table className="min-w-full border border-border rounded-lg text-sm">
|
||
{children}
|
||
</table>
|
||
</div>
|
||
),
|
||
thead: ({ children }) => (
|
||
<thead className="bg-muted">{children}</thead>
|
||
),
|
||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||
tr: ({ children }) => (
|
||
<tr className="border-b border-border">
|
||
{children}
|
||
</tr>
|
||
),
|
||
th: ({ children }) => (
|
||
<th className="px-3 py-2 text-left font-semibold text-foreground">
|
||
{children}
|
||
</th>
|
||
),
|
||
td: ({ children }) => (
|
||
<td className="px-3 py-2 text-foreground">
|
||
{children}
|
||
</td>
|
||
),
|
||
a: ({ href, children }) => (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 hover:text-blue-800 underline"
|
||
>
|
||
{children}
|
||
</a>
|
||
),
|
||
}}
|
||
>
|
||
{editedContent || "Nothing to preview yet..."}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* Full preview mode */
|
||
<div className="flex-1 overflow-auto p-6">
|
||
<div className="max-w-4xl mx-auto">
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
components={{
|
||
code({ inline, className, children, ...props }) {
|
||
const match = /language-(\w+)/.exec(className || "");
|
||
return !inline && match ? (
|
||
<SyntaxHighlighter
|
||
style={syntaxTheme}
|
||
language={match[1]}
|
||
PreTag="div"
|
||
className="rounded-lg"
|
||
{...props}
|
||
>
|
||
{String(children).replace(/\n$/, "")}
|
||
</SyntaxHighlighter>
|
||
) : (
|
||
<code
|
||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||
{...props}
|
||
>
|
||
{children}
|
||
</code>
|
||
);
|
||
},
|
||
h1: ({ children }) => (
|
||
<h1 className="text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2">
|
||
{children}
|
||
</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className="text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1">
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className="text-xl font-semibold mb-3 mt-4 text-foreground">
|
||
{children}
|
||
</h3>
|
||
),
|
||
h4: ({ children }) => (
|
||
<h4 className="text-lg font-semibold mb-2 mt-3 text-foreground">
|
||
{children}
|
||
</h4>
|
||
),
|
||
p: ({ children }) => (
|
||
<p className="mb-4 text-foreground leading-relaxed">
|
||
{children}
|
||
</p>
|
||
),
|
||
ul: ({ children }) => (
|
||
<ul className="mb-4 ml-6 list-disc text-foreground">
|
||
{children}
|
||
</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<ol className="mb-4 ml-6 list-decimal text-foreground">
|
||
{children}
|
||
</ol>
|
||
),
|
||
li: ({ children }) => (
|
||
<li className="mb-1 text-foreground">{children}</li>
|
||
),
|
||
blockquote: ({ children }) => (
|
||
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
table: ({ children }) => (
|
||
<div className="mb-4 overflow-x-auto">
|
||
<table className="min-w-full border border-border rounded-lg">
|
||
{children}
|
||
</table>
|
||
</div>
|
||
),
|
||
thead: ({ children }) => (
|
||
<thead className="bg-muted">{children}</thead>
|
||
),
|
||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||
tr: ({ children }) => (
|
||
<tr className="border-b border-border">{children}</tr>
|
||
),
|
||
th: ({ children }) => (
|
||
<th className="px-4 py-2 text-left font-semibold text-foreground">
|
||
{children}
|
||
</th>
|
||
),
|
||
td: ({ children }) => (
|
||
<td className="px-4 py-2 text-foreground">
|
||
{children}
|
||
</td>
|
||
),
|
||
a: ({ href, children }) => (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-blue-600 hover:text-blue-800 underline"
|
||
>
|
||
{children}
|
||
</a>
|
||
),
|
||
}}
|
||
>
|
||
{editedContent}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
||
<div className="h-full flex flex-col bg-background">
|
||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
|
||
disabled={pageNumber <= 1}
|
||
>
|
||
{t("fileManager.previous")}
|
||
</Button>
|
||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||
{t("fileManager.pageXOfY", {
|
||
current: pageNumber,
|
||
total: numPages || 0,
|
||
})}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||
}
|
||
disabled={!numPages || pageNumber >= numPages}
|
||
>
|
||
{t("fileManager.next")}
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
|
||
>
|
||
{t("fileManager.zoomOut")}
|
||
</Button>
|
||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
|
||
{Math.round(pdfScale * 100)}%
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
|
||
>
|
||
{t("fileManager.zoomIn")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||
<div className="flex justify-center">
|
||
{pdfError ? (
|
||
<div className="text-center text-muted-foreground p-8">
|
||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h3 className="text-lg font-medium mb-2">
|
||
Cannot load PDF
|
||
</h3>
|
||
<p className="text-sm mb-4">
|
||
There was an error loading this PDF file.
|
||
</p>
|
||
{onDownload && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={onDownload}
|
||
className="flex items-center gap-2 mx-auto"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
{t("fileManager.download")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<Document
|
||
file={`data:application/pdf;base64,${content}`}
|
||
onLoadSuccess={({ numPages }) => {
|
||
setNumPages(numPages);
|
||
setPdfError(false);
|
||
|
||
if (onMediaDimensionsChange) {
|
||
onMediaDimensionsChange({
|
||
width: 800,
|
||
height: 600,
|
||
});
|
||
}
|
||
}}
|
||
onLoadError={(error) => {
|
||
console.error("PDF load error:", error);
|
||
setPdfError(true);
|
||
}}
|
||
loading={
|
||
<div className="text-center p-8">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Loading PDF...
|
||
</p>
|
||
</div>
|
||
}
|
||
>
|
||
<Page
|
||
pageNumber={pageNumber}
|
||
scale={pdfScale}
|
||
className="shadow-lg"
|
||
loading={
|
||
<div className="text-center p-4">
|
||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Loading page...
|
||
</p>
|
||
</div>
|
||
}
|
||
/>
|
||
</Document>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||
<div className="p-6 flex items-center justify-center h-full">
|
||
<div className="w-full max-w-2xl">
|
||
{(() => {
|
||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||
const mimeType = (() => {
|
||
switch (ext) {
|
||
case "mp3":
|
||
return "audio/mpeg";
|
||
case "wav":
|
||
return "audio/wav";
|
||
case "flac":
|
||
return "audio/flac";
|
||
case "ogg":
|
||
return "audio/ogg";
|
||
case "aac":
|
||
return "audio/aac";
|
||
case "m4a":
|
||
return "audio/mp4";
|
||
default:
|
||
return "audio/mpeg";
|
||
}
|
||
})();
|
||
|
||
const audioUrl = `data:${mimeType};base64,${content}`;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-center">
|
||
<div
|
||
className={cn(
|
||
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
|
||
fileTypeInfo.color,
|
||
)}
|
||
>
|
||
<Music className="w-16 h-16 text-pink-600" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-center">
|
||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||
{file.name.replace(/\.[^/.]+$/, "")}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{ext.toUpperCase()} • {formatFileSize(file.size, t)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-lg overflow-hidden">
|
||
<AudioPlayer
|
||
src={audioUrl}
|
||
onLoadedMetadata={() => {
|
||
if (onMediaDimensionsChange) {
|
||
onMediaDimensionsChange({
|
||
width: 600,
|
||
height: 400,
|
||
});
|
||
}
|
||
}}
|
||
onError={(e) => {
|
||
console.error("Audio playback error:", e);
|
||
}}
|
||
showJumpControls={false}
|
||
showSkipControls={false}
|
||
showDownloadProgress={true}
|
||
customAdditionalControls={[]}
|
||
customVolumeControls={[]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{fileTypeInfo.type === "unknown" &&
|
||
!shouldShowAsText &&
|
||
!showLargeFileWarning && (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center text-muted-foreground">
|
||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||
<h3 className="text-lg font-medium mb-2">
|
||
Cannot preview this file type
|
||
</h3>
|
||
<p className="text-sm mb-4">
|
||
This file type is not supported for preview. You can download
|
||
it to view in an external application.
|
||
</p>
|
||
{onDownload && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={onDownload}
|
||
className="flex items-center gap-2 mx-auto"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
{t("fileManager.downloadFile")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||
<div className="flex justify-between items-center">
|
||
<span>{file.path}</span>
|
||
{hasChanges && (
|
||
<span className="text-orange-600 font-medium">
|
||
● Unsaved changes
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|