Spaces:
Running
Running
| 'use client' | |
| import React, { useState, useEffect } from 'react' | |
| import Editor from '@monaco-editor/react' | |
| import { | |
| FloppyDisk, | |
| X, | |
| Minus, | |
| Square, | |
| Check, | |
| FileText, | |
| WarningCircle | |
| } from '@phosphor-icons/react' | |
| import Window from './Window' | |
| interface TextEditorProps { | |
| onClose: () => void | |
| onMinimize?: () => void | |
| onMaximize?: () => void | |
| onFocus?: () => void | |
| zIndex?: number | |
| initialContent?: string | |
| fileName?: string | |
| filePath?: string | |
| passkey?: string | |
| } | |
| export function TextEditor({ | |
| onClose, | |
| onMinimize, | |
| onMaximize, | |
| onFocus, | |
| zIndex, | |
| initialContent = '', | |
| fileName = 'untitled.txt', | |
| filePath = '', | |
| passkey = '' | |
| }: TextEditorProps) { | |
| const [code, setCode] = useState(initialContent) | |
| const [isSaving, setIsSaving] = useState(false) | |
| const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') | |
| const [lastSaved, setLastSaved] = useState<Date | null>(null) | |
| const [hasChanges, setHasChanges] = useState(false) | |
| // Note: PDF compilation is not available in the web environment | |
| // Users should download .tex files and compile locally | |
| // Detect file language from extension | |
| const getLanguage = (filename: string) => { | |
| const ext = filename.split('.').pop()?.toLowerCase() | |
| switch (ext) { | |
| case 'tex': | |
| return 'latex' | |
| case 'js': | |
| case 'jsx': | |
| return 'javascript' | |
| case 'ts': | |
| case 'tsx': | |
| return 'typescript' | |
| case 'py': | |
| return 'python' | |
| case 'java': | |
| return 'java' | |
| case 'cpp': | |
| case 'cc': | |
| case 'cxx': | |
| return 'cpp' | |
| case 'c': | |
| case 'h': | |
| return 'c' | |
| case 'cs': | |
| return 'csharp' | |
| case 'json': | |
| return 'json' | |
| case 'xml': | |
| return 'xml' | |
| case 'html': | |
| case 'htm': | |
| return 'html' | |
| case 'css': | |
| return 'css' | |
| case 'md': | |
| return 'markdown' | |
| case 'sh': | |
| case 'bash': | |
| return 'shell' | |
| case 'dart': | |
| return 'dart' | |
| default: | |
| return 'plaintext' | |
| } | |
| } | |
| const language = getLanguage(fileName) | |
| const isLatexFile = language === 'latex' | |
| // Track changes | |
| useEffect(() => { | |
| if (code !== initialContent) { | |
| setHasChanges(true) | |
| setSaveStatus('idle') | |
| } | |
| }, [code, initialContent]) | |
| // Download file | |
| const handleDownload = () => { | |
| const ext = fileName.split('.').pop()?.toLowerCase() || 'txt' | |
| let mimeType = 'text/plain' | |
| const mimeTypes: Record<string, string> = { | |
| 'js': 'text/javascript', | |
| 'ts': 'text/typescript', | |
| 'tsx': 'text/typescript', | |
| 'jsx': 'text/javascript', | |
| 'json': 'application/json', | |
| 'html': 'text/html', | |
| 'css': 'text/css', | |
| 'md': 'text/markdown', | |
| 'py': 'text/x-python', | |
| 'java': 'text/x-java', | |
| 'c': 'text/x-c', | |
| 'cpp': 'text/x-c++', | |
| 'dart': 'application/vnd.dart', | |
| 'tex': 'application/x-tex', | |
| 'xml': 'text/xml', | |
| 'yaml': 'text/yaml', | |
| 'yml': 'text/yaml', | |
| 'sh': 'application/x-sh', | |
| 'sql': 'application/sql' | |
| } | |
| if (mimeTypes[ext]) { | |
| mimeType = mimeTypes[ext] | |
| } | |
| const blob = new Blob([code], { type: mimeType }) | |
| const url = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = url | |
| a.download = fileName | |
| a.click() | |
| URL.revokeObjectURL(url) | |
| } | |
| const handleSave = async () => { | |
| // Determine if this is a public file or secure file | |
| // If passkey is empty, we assume it's a public file (unless explicitly told otherwise, but we don't have an isPublic prop) | |
| // Ideally, we should have an isPublic prop, but for now, empty passkey implies public context in this app structure. | |
| const isPublic = !passkey | |
| if (!passkey && !isPublic) { | |
| alert('Please enter your passkey first!') | |
| return | |
| } | |
| setIsSaving(true) | |
| setSaveStatus('saving') | |
| try { | |
| const endpoint = isPublic ? '/api/public' : '/api/data' | |
| const response = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| action: 'save_file', | |
| passkey: passkey, // Optional for public | |
| fileName: fileName, | |
| content: code, | |
| folder: filePath | |
| }) | |
| }) | |
| const result = await response.json() | |
| if (result.success) { | |
| setSaveStatus('saved') | |
| setLastSaved(new Date()) | |
| setHasChanges(false) | |
| // Reset status after 3 seconds | |
| setTimeout(() => { | |
| setSaveStatus('idle') | |
| }, 3000) | |
| } else { | |
| setSaveStatus('error') | |
| alert(`Error saving file: ${result.error || 'Unknown error'}`) | |
| } | |
| } catch (error) { | |
| console.error('Save error:', error) | |
| setSaveStatus('error') | |
| alert('Failed to save file. Please try again.') | |
| } finally { | |
| setIsSaving(false) | |
| } | |
| } | |
| // Keyboard shortcut: Cmd/Ctrl + S to save | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if ((e.metaKey || e.ctrlKey) && e.key === 's') { | |
| e.preventDefault() | |
| handleSave() | |
| } | |
| } | |
| window.addEventListener('keydown', handleKeyDown) | |
| return () => window.removeEventListener('keydown', handleKeyDown) | |
| }, [code, passkey, fileName, filePath]) | |
| return ( | |
| <Window | |
| id="text-editor" | |
| title={`Text Editor - ${fileName}${isLatexFile ? ' (LaTeX)' : ''}`} | |
| isOpen={true} | |
| onClose={onClose} | |
| onMinimize={onMinimize} | |
| onMaximize={onMaximize} | |
| onFocus={onFocus} | |
| zIndex={zIndex} | |
| width={1000} | |
| height={700} | |
| x={100} | |
| y={80} | |
| className="text-editor-window" | |
| > | |
| <div className="flex flex-col h-full bg-[#1e1e1e]"> | |
| {/* Toolbar */} | |
| <div className="h-10 sm:h-12 bg-[#2d2d2d] border-b border-[#1e1e1e] flex items-center justify-between px-2 sm:px-4 gap-2"> | |
| <div className="flex items-center gap-1.5 sm:gap-3 min-w-0 flex-1"> | |
| <div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1 sm:py-1.5 bg-[#1e1e1e] rounded text-[10px] sm:text-xs text-gray-400 border border-[#333] min-w-0"> | |
| <FileText size={12} className="text-blue-400 sm:w-3.5 sm:h-3.5 flex-shrink-0" /> | |
| <span className="truncate max-w-[60px] xs:max-w-[100px] sm:max-w-none">{fileName}</span> | |
| </div> | |
| {hasChanges && ( | |
| <span className="text-[10px] sm:text-xs text-yellow-500 flex-shrink-0">● <span className="hidden xs:inline">Modified</span></span> | |
| )} | |
| {saveStatus === 'saved' && ( | |
| <div className="hidden xs:flex items-center gap-1 text-[10px] sm:text-xs text-green-500"> | |
| <Check size={12} className="sm:w-3.5 sm:h-3.5" /> | |
| <span className="hidden sm:inline">Saved {lastSaved?.toLocaleTimeString()}</span> | |
| <span className="sm:hidden">Saved</span> | |
| </div> | |
| )} | |
| {saveStatus === 'error' && ( | |
| <div className="flex items-center gap-1 text-[10px] sm:text-xs text-red-500"> | |
| <WarningCircle size={12} className="sm:w-3.5 sm:h-3.5" /> | |
| <span className="hidden xs:inline">Save failed</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-1 sm:gap-2 flex-shrink-0"> | |
| <button | |
| onClick={handleDownload} | |
| className="flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1 sm:py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded text-[10px] sm:text-xs font-medium transition-colors" | |
| title="Download file" | |
| > | |
| <svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" className="sm:w-3.5 sm:h-3.5"> | |
| <path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V40a8,8,0,0,0-16,0v84.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"></path> | |
| </svg> | |
| <span className="hidden xs:inline">Download</span> | |
| </button> | |
| <div className="hidden sm:block h-4 w-[1px] bg-gray-600 mx-1" /> | |
| <button | |
| onClick={handleSave} | |
| disabled={isSaving || !hasChanges} | |
| className="flex items-center gap-1 sm:gap-2 px-2 sm:px-4 py-1 sm:py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded text-[10px] sm:text-xs font-medium transition-colors" | |
| > | |
| {isSaving ? ( | |
| <> | |
| <div className="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| <span className="hidden xs:inline">Saving...</span> | |
| </> | |
| ) : ( | |
| <> | |
| <FloppyDisk size={12} weight="fill" className="sm:w-3.5 sm:h-3.5" /> | |
| <span className="hidden xs:inline">Save</span> | |
| <span className="hidden sm:inline"> (⌘S)</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Editor */} | |
| <div className="flex-1"> | |
| <Editor | |
| height="100%" | |
| language={language} | |
| theme="vs-dark" | |
| value={code} | |
| onChange={(value) => setCode(value || '')} | |
| options={{ | |
| minimap: { enabled: typeof window !== 'undefined' && window.innerWidth >= 768 }, | |
| fontSize: typeof window !== 'undefined' && window.innerWidth < 640 ? 12 : 14, | |
| fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", | |
| lineNumbers: 'on', | |
| scrollBeyondLastLine: false, | |
| automaticLayout: true, | |
| padding: { top: 12, bottom: 12 }, | |
| renderLineHighlight: 'all', | |
| smoothScrolling: true, | |
| cursorBlinking: 'smooth', | |
| cursorSmoothCaretAnimation: 'on', | |
| wordWrap: 'on', | |
| wrappingIndent: 'indent', | |
| tabSize: 2, | |
| insertSpaces: true | |
| }} | |
| /> | |
| </div> | |
| {/* Status Bar */} | |
| <div className="h-5 sm:h-6 bg-[#007acc] flex items-center justify-between px-2 sm:px-4 text-[9px] sm:text-xs text-white"> | |
| <div className="flex items-center gap-2 sm:gap-4"> | |
| <span>{language.toUpperCase()}</span> | |
| <span className="hidden xs:inline">|</span> | |
| <span className="hidden xs:inline">{code.split('\n').length} lines</span> | |
| <span className="hidden sm:inline">|</span> | |
| <span className="hidden sm:inline">{code.length} chars</span> | |
| </div> | |
| <div className="hidden md:block"> | |
| {isLatexFile && <span>💡 Download and compile .tex file locally</span>} | |
| </div> | |
| </div> | |
| </div> | |
| </Window> | |
| ) | |
| } | |