veyrix

Veyrix IDE is a lightweight, p...
Log | Files | Refs | README | LICENSE

web.html (41516B)


      1 <!DOCTYPE html>
      2 <html lang="en">
      3 <head>
      4     <meta charset="UTF-8">
      5     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
      6     <title>Veyrix Web</title>
      7     <link rel="manifest" href="assets/manifest.json">
      8     <link rel="icon" type="image/svg+xml" href="assets/veyrix.svg">
      9     <meta name="theme-color" content="#121b2a">
     10     <!-- Dependencies: Ace Editor only (JSZip is lazy loaded) -->
     11     <script src="assets/ace.js"></script>
     12     <script src="assets/ext-beautify.js"></script>
     13 
     14     <style>
     15         /* * VEYRIX CUSTOM CSS 
     16          * Stable Flexbox Layout & High-Performance UI
     17          */
     18         :root {
     19             --bg: #0a0f1a;
     20             --surface: #121b2a;
     21             --border: #1e293b;
     22             --accent: #3b82f6;
     23             --accent-hover: #2563eb;
     24             --text: #e2e8f0;
     25             --muted: #94a3b8;
     26             --danger: #ef4444;
     27             --danger-bg: rgba(239, 68, 68, 0.1);
     28             --header-h: 48px;
     29             --nav-h: 60px;
     30         }
     31 
     32         * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
     33         body { 
     34             margin: 0; padding: 0; 
     35             font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 
     36             background-color: var(--bg); color: var(--text);
     37             height: 100vh; display: flex; flex-direction: column;
     38             font-size: 14px; overflow: hidden;
     39         }
     40 
     41         button { font-family: inherit; cursor: pointer; border: none; background: transparent; color: inherit; }
     42         button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 4px; }
     43         input { font-family: inherit; }
     44 
     45         /* Header */
     46         .header {
     47             height: var(--header-h); background: var(--surface); border-bottom: 1px solid var(--border);
     48             display: flex; align-items: center; justify-content: space-between; padding: 0 12px; flex-shrink: 0; z-index: 60;
     49         }
     50         
     51         /* Branding alignment - SINGLE LINE */
     52         .brand-container {
     53             display: flex; align-items: center; gap: 10px; white-space: nowrap; overflow: hidden;
     54         }
     55 
     56         /* Jon Suh Hamburger - Spin */
     57         .hamburger {
     58             display: inline-flex; align-items: center; justify-content: center;
     59             cursor: pointer; transition: opacity 0.15s linear; border: 0; margin: 0;
     60             background: transparent; padding: 8px; border-radius: 4px; flex-shrink: 0;
     61         }
     62         .hamburger-box { width: 20px; height: 14px; display: inline-block; position: relative; }
     63         .hamburger-inner { display: block; top: 50%; margin-top: -1px; }
     64         .hamburger-inner, .hamburger-inner::before, .hamburger-inner::after {
     65             width: 20px; height: 2px; background-color: var(--text); border-radius: 4px;
     66             position: absolute; transition: transform 0.15s ease;
     67         }
     68         .hamburger-inner::before, .hamburger-inner::after { content: ""; display: block; }
     69         .hamburger-inner::before { top: -6px; }
     70         .hamburger-inner::after { bottom: -6px; }
     71         .hamburger--spin .hamburger-inner { transition-duration: 0.22s; transition-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); }
     72         .hamburger--spin.is-active .hamburger-inner { transform: rotate(225deg); transition-delay: 0.12s; transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
     73         .hamburger--spin.is-active .hamburger-inner::before { top: 0; opacity: 0; transition: top 0.1s ease-out, opacity 0.1s 0.12s ease-out; }
     74         .hamburger--spin.is-active .hamburger-inner::after { bottom: 0; transform: rotate(-90deg); transition: bottom 0.1s ease-out, transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1); }
     75 
     76         .main-workspace { flex: 1; display: flex; overflow: hidden; position: relative; width: 100%; }
     77 
     78         /* Sidebar */
     79         .sidebar {
     80             width: 260px; background: var(--surface); border-right: 1px solid var(--border);
     81             display: flex; flex-direction: column; flex-shrink: 0; transition: width 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
     82             z-index: 55;
     83         }
     84         .sidebar-header { padding: 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
     85         .project-name-input {
     86             width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);
     87             padding: 8px; border-radius: 6px; font-size: 13px; font-weight: 600;
     88         }
     89         .file-tree { flex: 1; overflow-y: auto; padding: 8px 0; }
     90         
     91         /* Tree Items */
     92         .tree-item { display: flex; align-items: center; padding: 8px 12px; cursor: pointer; color: var(--text); user-select: none; }
     93         .tree-item:hover { background: rgba(255,255,255,0.05); }
     94         .tree-item.active { background: rgba(59, 130, 246, 0.15); color: #fff; }
     95         .tree-item-icon { width: 16px; height: 16px; margin-right: 8px; flex-shrink: 0; }
     96         .tree-item-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
     97         .tree-item-actions { display: flex; gap: 4px; margin-left: 8px; flex-shrink: 0; }
     98         .action-icon { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); border-radius: 6px; }
     99 
    100         /* Context Menu */
    101         .context-menu {
    102             position: fixed; background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
    103             box-shadow: 0 10px 30px rgba(0,0,0,0.5); z-index: 1000; padding: 6px; display: flex; flex-direction: column; min-width: 160px;
    104         }
    105         .context-menu button { padding: 10px 12px; text-align: left; font-size: 13px; border-radius: 6px; display: flex; align-items: center; gap: 10px; color: var(--text); }
    106         .context-menu button:active { background: rgba(255,255,255,0.1); }
    107         .context-menu button.danger { color: var(--danger); }
    108 
    109         /* Editor Pane */
    110         .editor-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); position: relative; }
    111         .tabs-container { display: flex; background: var(--surface); border-bottom: 1px solid var(--border); overflow-x: auto; flex-shrink: 0; }
    112         .tabs-container::-webkit-scrollbar { display: none; }
    113         .tab {
    114             padding: 10px 14px; font-size: 13px; color: var(--muted); border-right: 1px solid var(--border); border-bottom: 2px solid transparent;
    115             display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: pointer;
    116         }
    117         .tab.active { color: var(--accent); border-bottom-color: var(--accent); background: var(--bg); }
    118         .tab-close { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 4px; }
    119         
    120         .editor-wrapper { flex: 1; position: relative; min-height: 0; width: 100%; display: flex; flex-direction: column; }
    121         #editor { flex: 1; width: 100%; min-height: 0; }
    122         
    123         .empty-state {
    124             position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
    125             color: var(--muted); background: var(--surface); z-index: 10; text-align: center; padding: 20px; pointer-events: none;
    126         }
    127 
    128         /* Resizer - PC ONLY */
    129         .resizer {
    130             width: 4px; height: 100%; cursor: col-resize; background: var(--border); flex-shrink: 0; transition: background 0.2s;
    131             position: relative; z-index: 40;
    132         }
    133         .resizer:hover, .resizer.dragging { background: var(--accent); }
    134         .resizer::after {
    135             content: ''; position: absolute; left: -4px; right: -4px; top: 0; bottom: 0;
    136         }
    137 
    138         /* Preview Pane */
    139         .preview-pane { width: 40%; background: #fff; border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; position: relative; }
    140         .preview-iframe { flex: 1; width: 100%; border: none; background: #fff; }
    141         .iframe-overlay { position: absolute; inset: 0; z-index: 20; background: transparent; display: none; }
    142         .dragging .iframe-overlay { display: block; }
    143         
    144         /* Floating Reload Button */
    145         .preview-reload-btn {
    146             position: absolute; bottom: 16px; right: 16px; width: 40px; height: 40px; 
    147             background: var(--accent); color: white; border-radius: 50%; 
    148             display: flex; align-items: center; justify-content: center;
    149             box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 30; opacity: 0.8; transition: opacity 0.2s;
    150         }
    151 
    152         /* UI Components */
    153         .btn-icon { padding: 8px; border-radius: 8px; color: var(--muted); display: inline-flex; align-items: center; justify-content: center; }
    154         .btn-primary { background: var(--accent); color: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
    155 
    156         /* Modals */
    157         .modal-overlay {
    158             position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
    159             background: rgba(0,0,0,0.85); backdrop-filter: blur(8px); z-index: 9999;
    160             display: none; align-items: center; justify-content: center; padding: 20px;
    161         }
    162         .modal-overlay.active { display: flex; }
    163         .modal-content {
    164             background: var(--surface); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 400px;
    165             box-shadow: 0 20px 40px rgba(0,0,0,0.6); pointer-events: auto;
    166         }
    167         .modal-header { padding: 16px; border-bottom: 1px solid var(--border); font-weight: 700; display: flex; justify-content: space-between; align-items: center; }
    168         .modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
    169         .modal-footer { padding: 12px 20px; background: rgba(0,0,0,0.2); border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; border-radius: 0 0 12px 12px; }
    170 
    171         /* Bottom Nav */
    172         .bottom-nav {
    173             display: none; height: var(--nav-h); background: var(--surface); border-top: 1px solid var(--border); flex-shrink: 0; z-index: 40;
    174             padding-bottom: env(safe-area-inset-bottom);
    175         }
    176         .nav-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--muted); font-size: 11px; gap: 4px; font-weight: 500; }
    177         .nav-item.active { color: var(--accent); }
    178 
    179         /* Sidebar Backdrop */
    180         .sidebar-backdrop { 
    181             display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 45; opacity: 0; transition: opacity 0.2s ease;
    182         }
    183 
    184         @media (max-width: 768px) {
    185             .sidebar { position: absolute; height: 100%; left: 0; top: 0; transform: translateX(-100%); width: 280px; box-shadow: 10px 0 30px rgba(0,0,0,0.5); }
    186             .sidebar.open { transform: translateX(0); width: 280px; }
    187             .sidebar-backdrop.open { display: block; opacity: 1; }
    188             .preview-pane { display: none; position: absolute; inset: 0; width: 100%; height: 100%; z-index: 20; border-left: none; }
    189             .resizer { display: none; }
    190             .bottom-nav { display: flex; }
    191             .mobile-view-preview .preview-pane { display: flex; }
    192             .mobile-view-preview .editor-pane { display: none; }
    193             /* Modal fix for Mobile Keyboard */
    194             .modal-overlay { align-items: flex-start; padding-top: 15vh; }
    195         }
    196 
    197         @media (min-width: 769px) {
    198             .sidebar.collapsed { width: 0; border-right: none; transform: translateX(-100%); opacity: 0; pointer-events: none; }
    199             .mobile-only { display: none !important; }
    200         }
    201 
    202         .hidden { display: none !important; }
    203     </style>
    204 </head>
    205 <body id="app-body">
    206 
    207     <!-- Modals -->
    208     <div id="create-modal" class="modal-overlay">
    209         <div class="modal-content">
    210             <div class="modal-header">
    211                 <span id="create-modal-title">Create Item</span>
    212                 <button onclick="UI.hideModal('create-modal')">✕</button>
    213             </div>
    214             <div class="modal-body">
    215                 <input type="text" id="create-input" class="project-name-input" placeholder="/path/file.js">
    216                 <input type="hidden" id="create-type">
    217             </div>
    218             <div class="modal-footer">
    219                 <button class="btn-primary" onclick="App.executeCreate()">Create</button>
    220             </div>
    221         </div>
    222     </div>
    223 
    224     <div id="export-modal" class="modal-overlay">
    225         <div class="modal-content">
    226             <div class="modal-header">
    227                 <span>Export Project</span>
    228                 <button onclick="UI.hideModal('export-modal')">✕</button>
    229             </div>
    230             <div class="modal-body" style="padding: 20px; gap: 12px;">
    231                 <button class="btn-primary" style="width:100%; justify-content:center;" onclick="ExportEngine.exportSingleFile()">Combine HTML</button>
    232                 <button class="btn-primary" style="width:100%; justify-content:center; background:#ca8a04" onclick="ExportEngine.exportZip()">Download Project ZIP</button>
    233             </div>
    234         </div>
    235     </div>
    236 
    237     <div id="upload-modal" class="modal-overlay">
    238         <div class="modal-content">
    239             <div class="modal-header">
    240                 <span>Upload Path</span>
    241                 <button onclick="UI.hideModal('upload-modal')">✕</button>
    242             </div>
    243             <div class="modal-body">
    244                 <input type="text" id="upload-path-input" class="project-name-input">
    245             </div>
    246             <div class="modal-footer">
    247                 <button class="btn-primary" onclick="App.executeUpload()">Confirm</button>
    248             </div>
    249         </div>
    250     </div>
    251 
    252     <!-- Header -->
    253     <header class="header">
    254         <div class="brand-container">
    255             <button class="hamburger hamburger--spin" id="hamburger-btn" type="button" aria-expanded="false" aria-controls="sidebar" onclick="App.toggleSidebar()">
    256                 <span class="hamburger-box"><span class="hamburger-inner"></span></span>
    257             </button>
    258             <div style="display:flex; align-items:center; gap:8px;">
    259                 <img src="assets/veyrix.svg" alt="Logo" style="width:22px;height:22px;">
    260                 <span style="font-weight:700; letter-spacing:0.5px; color: white; font-size: 16px;">Veyrix Web</span>
    261             </div>
    262         </div>
    263         <div class="flex items-center gap-1">
    264             <!-- Mobile Run Toggle -->
    265             <button class="btn-icon mobile-only" id="mobile-run-toggle" onclick="App.toggleRunMode()" title="Run Preview">
    266                 <svg id="icon-run" style="width:20px;height:20px;color:#22c55e" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"></path></svg>
    267                 <svg id="icon-editor" class="hidden" style="width:20px;height:20px;color:var(--accent)" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
    268             </button>
    269             <button class="btn-icon" onclick="App.saveCurrentFile()" title="Save (Ctrl+S)">
    270                 <svg style="width:18px;height:18px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path></svg>
    271             </button>
    272             <button class="btn-primary" style="margin-left:8px" onclick="UI.showExportModal()">
    273                 <svg style="width:16px;height:16px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
    274                 <span class="desktop-only" style="display:none; sm:inline;">Export</span>
    275             </button>
    276         </div>
    277     </header>
    278 
    279     <!-- Main Content -->
    280     <main class="main-workspace" id="workspace-main">
    281         <div id="sidebar-backdrop" class="sidebar-backdrop" onclick="App.toggleSidebar(false)"></div>
    282 
    283         <aside class="sidebar" id="sidebar">
    284             <div class="sidebar-header">
    285                 <input type="text" id="project-name-input" class="project-name-input" value="my-website" placeholder="Project Name">
    286                 <div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px;">
    287                     <span style="font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase;">Files</span>
    288                     <div style="display:flex; gap:2px;">
    289                         <button class="btn-icon" onclick="UI.showCreateModal('file')" title="New File"><svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg></button>
    290                         <button class="btn-icon" onclick="UI.showCreateModal('folder')" title="New Folder"><svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path></svg></button>
    291                         <button class="btn-icon" onclick="UI.triggerUpload()" title="Upload"><svg style="width:14px;height:14px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg></button>
    292                     </div>
    293                 </div>
    294             </div>
    295             <div class="file-tree" id="file-tree"></div>
    296         </aside>
    297 
    298         <section class="editor-pane">
    299             <div class="tabs-container" id="tabs-container"></div>
    300             <div class="editor-wrapper" id="editor-wrapper">
    301                 <div id="empty-state" class="empty-state">
    302                     <img src="assets/veyrix.svg" style="width:48px;height:48px;opacity:0.1;margin-bottom:16px;">
    303                     <span>Select a file to start coding</span>
    304                 </div>
    305                 <div id="editor"></div>
    306                 <div id="binary-preview" class="empty-state hidden" style="background:var(--bg); z-index:15; pointer-events:auto;">
    307                     <img id="binary-image" style="max-width:90%; max-height:80%; object-fit:contain; border-radius:8px;">
    308                     <p id="binary-text" style="margin-top:16px; font-size:12px; color:var(--muted)"></p>
    309                 </div>
    310             </div>
    311         </section>
    312 
    313         <!-- Slider between Editor and Preview -->
    314         <div class="resizer" id="resizer"></div>
    315 
    316         <section class="preview-pane" id="preview-pane">
    317             <div class="iframe-overlay"></div>
    318             <button class="preview-reload-btn" onclick="PreviewEngine.reload()" title="Reload Preview">
    319                 <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
    320             </button>
    321             <iframe id="preview-frame" class="preview-iframe" sandbox="allow-scripts allow-modals"></iframe>
    322         </section>
    323     </main>
    324 
    325     <!-- Mobile Bottom Navigation -->
    326     <nav class="bottom-nav">
    327         <button class="nav-item active" id="nav-btn-editor" onclick="App.setMobileView('editor')">
    328             <svg style="width:20px;height:20px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
    329             Editor
    330         </button>
    331         <button class="nav-item" id="nav-btn-preview" onclick="App.setMobileView('preview')">
    332             <svg style="width:20px;height:20px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
    333             Preview
    334         </button>
    335     </nav>
    336 
    337     <!-- Context Dropdown -->
    338     <div id="tree-dropdown" class="context-menu hidden">
    339         <button onclick="App.executeDropdownAction('download')">Download</button>
    340         <button onclick="App.executeDropdownAction('delete')" class="danger">Delete</button>
    341     </div>
    342 
    343     <input type="file" id="file-upload" class="hidden" onchange="App.handleFileUpload(event)">
    344 
    345 <script>
    346 /**
    347  * VEYRIX WEB ROOT EDITOR - STABILIZED & OPTIMIZED
    348  */
    349 
    350 const VFS = {
    351     db: null, files: {},
    352     init: () => new Promise((resolve) => {
    353         const req = indexedDB.open('VeyrixWebFS', 1);
    354         req.onsuccess = () => { VFS.db = req.result; resolve(); };
    355         req.onupgradeneeded = (e) => e.target.result.createObjectStore('files', { keyPath: 'path' });
    356     }),
    357     loadAll: () => new Promise((resolve) => {
    358         const tx = VFS.db.transaction('files', 'readonly');
    359         const req = tx.objectStore('files').getAll();
    360         req.onsuccess = () => {
    361             req.result.forEach(f => { f.savedContent = f.content; f.unsaved = false; VFS.files[f.path] = f; });
    362             if (!VFS.files['/index.html']) {
    363                 const def = { path: '/index.html', type: 'file', content: '<!DOCTYPE html>\n<html>\n<head>\n  <meta charset="UTF-8">\n  <title>Veyrix Web</title>\n</head>\n<body style="background:#0a0f1a;color:#fff;text-align:center;padding-top:100px;font-family:sans-serif;">\n  <h1>Veyrix Web Project</h1>\n  <p>Start editing index.html to see changes.</p>\n</body>\n</html>', savedContent: '', unsaved: false };
    364                 VFS.files['/index.html'] = def;
    365                 VFS.save('/index.html');
    366             }
    367             resolve();
    368         };
    369     }),
    370     save: (path) => {
    371         const f = VFS.files[path]; if (!f) return;
    372         const tx = VFS.db.transaction('files', 'readwrite');
    373         tx.objectStore('files').put({ path: f.path, type: f.type, content: f.content, isBase64: f.isBase64 });
    374     },
    375     remove: (path) => {
    376         const toDelete = Object.keys(VFS.files).filter(p => p === path || p.startsWith(path + '/'));
    377         const tx = VFS.db.transaction('files', 'readwrite');
    378         toDelete.forEach(p => { delete VFS.files[p]; tx.objectStore('files').delete(p); });
    379     }
    380 };
    381 
    382 const PathUtils = {
    383     resolve: (base, rel) => {
    384         if (rel.startsWith('http') || rel.startsWith('/')) return rel;
    385         const parts = base.split('/').filter(Boolean); parts.pop();
    386         const relParts = rel.split('/').filter(Boolean);
    387         for (const p of relParts) { if (p === '..') parts.pop(); else if (p !== '.') parts.push(p); }
    388         return '/' + parts.join('/');
    389     },
    390     getExt: (p) => p.includes('.') ? p.split('.').pop().toLowerCase() : '',
    391     getName: (p) => p.split('/').pop()
    392 };
    393 
    394 const PreviewEngine = {
    395     currentPath: '/index.html',
    396     debounceTimer: null,
    397     scheduleUpdate: () => {
    398         clearTimeout(PreviewEngine.debounceTimer);
    399         PreviewEngine.debounceTimer = setTimeout(() => PreviewEngine.render(), 300);
    400     },
    401     reload: () => PreviewEngine.render(),
    402     render: () => {
    403         if (App.activeFile && VFS.files[App.activeFile] && !VFS.files[App.activeFile].isBase64) {
    404             VFS.files[App.activeFile].content = App.editor.getValue();
    405         }
    406         const iframe = document.getElementById('preview-frame');
    407         const file = VFS.files[PreviewEngine.currentPath] || VFS.files['/index.html'];
    408         if (!file) {
    409             iframe.srcdoc = '<body style="background:#0a0f1a;color:#94a3b8;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;">No index.html found.</body>';
    410             return;
    411         }
    412         
    413         let html = file.content;
    414         const parser = new DOMParser();
    415         const doc = parser.parseFromString(html, 'text/html');
    416 
    417         doc.querySelectorAll('link[rel="stylesheet"]').forEach(l => {
    418             const abs = PathUtils.resolve(file.path, l.getAttribute('href'));
    419             if (VFS.files[abs]) { const s = doc.createElement('style'); s.textContent = VFS.files[abs].content; l.replaceWith(s); }
    420         });
    421         doc.querySelectorAll('script[src]').forEach(s => {
    422             const abs = PathUtils.resolve(file.path, s.getAttribute('src'));
    423             if (VFS.files[abs]) { const sc = doc.createElement('script'); sc.textContent = VFS.files[abs].content; s.replaceWith(sc); }
    424         });
    425         doc.querySelectorAll('img[src]').forEach(i => {
    426             const abs = PathUtils.resolve(file.path, i.getAttribute('src'));
    427             if (VFS.files[abs] && VFS.files[abs].isBase64) i.src = VFS.files[abs].content;
    428         });
    429         iframe.srcdoc = doc.documentElement.outerHTML;
    430     }
    431 };
    432 
    433 window.addEventListener('message', e => {
    434     if (e.data?.type === 'vfs-nav') PreviewEngine.navigate(PathUtils.resolve(PreviewEngine.currentPath, e.data.path));
    435 });
    436 
    437 const ExportEngine = {
    438     loadJSZip: () => window.JSZip ? Promise.resolve() : new Promise(r => { const s = document.createElement('script'); s.src = 'assets/jszip.min.js'; s.onload = r; document.head.appendChild(s); }),
    439     exportSingleFile: () => {
    440         PreviewEngine.render();
    441         const blob = new Blob([document.getElementById('preview-frame').srcdoc], { type: 'application/octet-stream' });
    442         const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'index.html'; a.click();
    443     },
    444     exportZip: async () => {
    445         await ExportEngine.loadJSZip();
    446         const zip = new JSZip();
    447         let name = document.getElementById('project-name-input').value.trim().replace(/\s+/g, '-') || 'website';
    448         Object.keys(VFS.files).forEach(p => {
    449             const f = VFS.files[p];
    450             if (f.type === 'file') {
    451                 const zPath = p.substring(1);
    452                 f.isBase64 ? zip.file(zPath, f.content.split(',')[1], {base64: true}) : zip.file(zPath, f.content);
    453             }
    454         });
    455         const blob = await zip.generateAsync({type:"blob"});
    456         const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name + '.zip'; a.click();
    457     },
    458     downloadItem: async (p) => {
    459         const f = VFS.files[p]; if (!f) return;
    460         const blob = f.isBase64 ? await (await fetch(f.content)).blob() : new Blob([f.content], { type: 'application/octet-stream' });
    461         const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = PathUtils.getName(p); a.click();
    462     }
    463 };
    464 
    465 const UI = {
    466     showModal: id => document.getElementById(id).classList.add('active'),
    467     hideModal: id => document.getElementById(id).classList.remove('active'),
    468     showCreateModal: t => {
    469         document.getElementById('create-modal-title').textContent = 'Create ' + t;
    470         document.getElementById('create-type').value = t;
    471         document.getElementById('create-input').value = '/';
    472         UI.showModal('create-modal');
    473         setTimeout(() => document.getElementById('create-input').focus(), 100);
    474     },
    475     triggerUpload: () => document.getElementById('file-upload').click(),
    476     showExportModal: () => UI.showModal('export-modal')
    477 };
    478 
    479 const App = {
    480     editor: null, openTabs: [], activeFile: null, collapsedFolders: new Set(), contextTarget: null,
    481     _listenersBound: false, _resizeTimeout: null, _dragging: false,
    482 
    483     init: async () => {
    484         await VFS.init();
    485         await VFS.loadAll();
    486         App.editor = ace.edit("editor");
    487         ace.config.set('basePath', 'assets/');
    488         App.editor.setTheme("ace/theme/tomorrow_night_eighties");
    489         App.editor.setOptions({ fontSize: "14px", useWorker: false, wrap: true, fontFamily: "'Fira Code', monospace" });
    490         App.editor.setValue("", -1);
    491 
    492         if (!App._listenersBound) {
    493             if (window.visualViewport) {
    494                 window.visualViewport.addEventListener('resize', App.handleViewportResize);
    495             }
    496             window.addEventListener('resize', App.handleViewportResize);
    497             document.getElementById('sidebar').addEventListener('transitionend', (e) => {
    498                 if (e.propertyName === 'width') App.editor?.resize();
    499             });
    500             
    501             // Drag Resizer logic
    502             const resizer = document.getElementById('resizer');
    503             const preview = document.getElementById('preview-pane');
    504             const workspace = document.getElementById('workspace-main');
    505 
    506             resizer.addEventListener('mousedown', (e) => {
    507                 App._dragging = true;
    508                 resizer.classList.add('dragging');
    509                 document.body.classList.add('dragging');
    510             });
    511 
    512             document.addEventListener('mousemove', (e) => {
    513                 if (!App._dragging) return;
    514                 const workspaceRect = workspace.getBoundingClientRect();
    515                 const newWidth = workspaceRect.right - e.clientX;
    516                 const percent = (newWidth / workspaceRect.width) * 100;
    517                 
    518                 if (percent > 10 && percent < 80) {
    519                     preview.style.width = percent + '%';
    520                     App.editor?.resize();
    521                 }
    522             });
    523 
    524             document.addEventListener('mouseup', () => {
    525                 if (App._dragging) {
    526                     App._dragging = false;
    527                     resizer.classList.remove('dragging');
    528                     document.body.classList.remove('dragging');
    529                     App.editor?.resize();
    530                 }
    531             });
    532 
    533             App._listenersBound = true;
    534         }
    535 
    536         App.editor.on('change', () => {
    537             if (App.activeFile && VFS.files[App.activeFile]) {
    538                 const isDirty = App.editor.getValue() !== VFS.files[App.activeFile].savedContent;
    539                 if (VFS.files[App.activeFile].unsaved !== isDirty) {
    540                     VFS.files[App.activeFile].unsaved = isDirty;
    541                     App.renderTabs();
    542                 }
    543                 PreviewEngine.scheduleUpdate();
    544             }
    545         });
    546 
    547         document.addEventListener('click', e => {
    548             if (!e.target.closest('#tree-dropdown') && !e.target.closest('.dropdown-trigger')) {
    549                 document.getElementById('tree-dropdown').classList.add('hidden');
    550             }
    551         });
    552 
    553         if (VFS.files['/index.html']) App.openFile('/index.html');
    554         else if (Object.keys(VFS.files).length > 0) App.openFile(Object.keys(VFS.files)[0]);
    555 
    556         App.renderTree(); App.renderTabs(); App.handleViewportResize();
    557         PreviewEngine.render();
    558     },
    559 
    560     handleViewportResize: () => {
    561         clearTimeout(App._resizeTimeout);
    562         App._resizeTimeout = setTimeout(() => {
    563             const wrapper = document.getElementById('editor-wrapper');
    564             if (window.innerWidth <= 768) {
    565                 const vh = window.visualViewport?.height || window.innerHeight;
    566                 const h = document.querySelector('.header').offsetHeight;
    567                 const n = document.querySelector('.bottom-nav').offsetHeight;
    568                 const height = Math.max(100, vh - h - n);
    569                 wrapper.style.height = height + 'px';
    570             } else {
    571                 wrapper.style.height = '';
    572             }
    573             App.editor?.resize();
    574         }, 100);
    575     },
    576 
    577     toggleSidebar: (s = null) => {
    578         const sb = document.getElementById('sidebar');
    579         const h = document.getElementById('hamburger-btn');
    580         const bd = document.getElementById('sidebar-backdrop');
    581         const isOpen = window.innerWidth > 768 ? !sb.classList.contains('collapsed') : sb.classList.contains('open');
    582         const target = s !== null ? s : !isOpen;
    583 
    584         if (window.innerWidth > 768) {
    585             target ? sb.classList.remove('collapsed') : sb.classList.add('collapsed');
    586         } else {
    587             target ? sb.classList.add('open') : sb.classList.remove('open');
    588             target ? bd.classList.add('open') : bd.classList.remove('open');
    589         }
    590         h.classList.toggle('is-active', target);
    591         h.setAttribute('aria-expanded', target);
    592         setTimeout(() => App.editor?.resize(), 250);
    593     },
    594 
    595     toggleRunMode: () => {
    596         const isPreview = document.getElementById('app-body').classList.contains('mobile-view-preview');
    597         if (isPreview) {
    598             App.setMobileView('editor');
    599         } else {
    600             PreviewEngine.render();
    601             App.setMobileView('preview');
    602         }
    603     },
    604 
    605     saveCurrentFile: () => {
    606         if (!App.activeFile || VFS.files[App.activeFile].isBase64) return;
    607         const f = VFS.files[App.activeFile];
    608         f.content = App.editor.getValue(); f.savedContent = f.content; f.unsaved = false;
    609         VFS.save(App.activeFile); App.renderTabs();
    610     },
    611 
    612     openFile: (p) => {
    613         const f = VFS.files[p];
    614         if (!f) return;
    615         if (f.type === 'folder') {
    616             App.collapsedFolders.has(p) ? App.collapsedFolders.delete(p) : App.collapsedFolders.add(p);
    617             App.renderTree(); return;
    618         }
    619         if (!App.openTabs.includes(p)) App.openTabs.push(p);
    620         App.activeFile = p;
    621         
    622         document.getElementById('empty-state').classList.add('hidden');
    623         if (f.isBase64) {
    624             document.getElementById('editor').classList.add('hidden');
    625             document.getElementById('binary-preview').classList.remove('hidden');
    626             document.getElementById('binary-image').src = f.content;
    627             document.getElementById('binary-text').textContent = PathUtils.getName(p);
    628         } else {
    629             document.getElementById('editor').classList.remove('hidden');
    630             document.getElementById('binary-preview').classList.add('hidden');
    631             const modes = { html: 'html', css: 'css', js: 'javascript', json: 'json' };
    632             App.editor.session.setMode('ace/mode/' + (modes[PathUtils.getExt(p)] || 'text'));
    633             if (App.editor.getValue() !== f.content) App.editor.setValue(f.content, -1);
    634             App.editor.setReadOnly(false);
    635         }
    636         App.renderTree(); App.renderTabs();
    637         
    638         if (document.getElementById('app-body').classList.contains('mobile-view-preview')) {
    639             App.setMobileView('editor');
    640         }
    641         if (window.innerWidth <= 768) App.toggleSidebar(false);
    642     },
    643 
    644     closeFile: (p, e) => {
    645         e.stopPropagation();
    646         const f = VFS.files[p];
    647         if (f?.unsaved && !confirm('Discard changes?')) return;
    648         App.openTabs = App.openTabs.filter(t => t !== p);
    649         if (App.activeFile === p) {
    650             App.activeFile = App.openTabs[App.openTabs.length - 1] || null;
    651             if (App.activeFile) App.openFile(App.activeFile);
    652             else { 
    653                 document.getElementById('empty-state').classList.remove('hidden');
    654                 document.getElementById('editor').classList.add('hidden');
    655                 document.getElementById('binary-preview').classList.add('hidden');
    656             }
    657         }
    658         App.renderTabs();
    659     },
    660 
    661     executeDropdownAction: (a) => {
    662         const p = App.contextTarget; document.getElementById('tree-dropdown').classList.add('hidden');
    663         if (a === 'delete' && confirm('Delete ' + p + '?')) {
    664             VFS.remove(p);
    665             App.openTabs = App.openTabs.filter(t => !t.startsWith(p));
    666             if (App.activeFile?.startsWith(p)) App.closeFile(App.activeFile, {stopPropagation:()=>{}});
    667             App.renderTree(); App.renderTabs();
    668         } else if (a === 'download') ExportEngine.downloadItem(p);
    669     },
    670 
    671     executeCreate: () => {
    672         let p = document.getElementById('create-input').value.trim().replace(/\/+/g, '/'); 
    673         if (!p) return;
    674         if (!p.startsWith('/')) p = '/' + p;
    675         const t = document.getElementById('create-type').value;
    676         if (t === 'file') p = p.replace(/\/$/, '');
    677         if (VFS.files[p]) return alert('File already exists');
    678         VFS.files[p] = { path: p, type: t, content: t === 'file' ? '' : undefined, savedContent: '', unsaved: false };
    679         VFS.save(p); UI.hideModal('create-modal'); App.renderTree(); if (t === 'file') App.openFile(p);
    680     },
    681 
    682     handleFileUpload: (e) => {
    683         const f = e.target.files[0]; if (!f) return;
    684         document.getElementById('upload-path-input').value = '/' + f.name;
    685         UI.showModal('upload-modal'); App.pendingFile = f; e.target.value = '';
    686     },
    687 
    688     executeUpload: () => {
    689         const f = App.pendingFile; let p = document.getElementById('upload-path-input').value.trim();
    690         if (!p.startsWith('/')) p = '/' + p;
    691         const reader = new FileReader(); const isImg = f.type.startsWith('image/');
    692         reader.onload = ev => {
    693             VFS.files[p] = { path: p, type: 'file', content: ev.target.result, savedContent: ev.target.result, unsaved: false, isBase64: isImg };
    694             VFS.save(p); UI.hideModal('upload-modal'); App.renderTree(); App.openFile(p);
    695         };
    696         isImg ? reader.readAsDataURL(f) : reader.readAsText(f);
    697     },
    698 
    699     renderTree: () => {
    700         const container = document.getElementById('file-tree');
    701         const fragment = document.createDocumentFragment();
    702         Object.keys(VFS.files).sort().forEach(p => {
    703             let skip = false;
    704             for (let c of App.collapsedFolders) if (p.startsWith(c + '/') && p !== c) skip = true;
    705             if (skip) return;
    706             const f = VFS.files[p]; const depth = p.split('/').filter(Boolean).length - 1;
    707             const el = document.createElement('div'); el.className = 'tree-item ' + (App.activeFile === p ? 'active' : '');
    708             el.style.paddingLeft = (12 + depth * 16) + 'px'; el.onclick = () => App.openFile(p);
    709             const actions = document.createElement('div');
    710             actions.className = 'tree-item-actions dropdown-trigger';
    711             actions.innerHTML = '<span class="action-icon">⋮</span>';
    712             actions.onclick = (e) => {
    713                 e.stopPropagation(); App.contextTarget = p; const d = document.getElementById('tree-dropdown');
    714                 const r = actions.getBoundingClientRect(); const ddW = 160; // <--- This line defines 'r'
    715                 let left = Math.min(r.right - ddW, window.innerWidth - ddW - 8);
    716                 d.style.top = (r.bottom + 4) + 'px'; d.style.left = Math.max(8, left) + 'px'; // <--- Changed 'rect.bottom' to 'r.bottom'
    717                 d.classList.remove('hidden');
    718             };
    719             el.innerHTML = `<span class="tree-item-icon">${f.type === 'folder' ? '📁' : '📄'}</span><span class="tree-item-text">${PathUtils.getName(p)}</span>`;
    720             el.appendChild(actions); fragment.appendChild(el);
    721         });
    722         container.innerHTML = ''; container.appendChild(fragment);
    723     },
    724 
    725 
    726     renderTabs: () => {
    727         const container = document.getElementById('tabs-container'); container.innerHTML = '';
    728         App.openTabs.forEach(p => {
    729             const f = VFS.files[p]; const active = p === App.activeFile;
    730             const el = document.createElement('div'); el.className = `tab ${active ? 'active ' : ''} ${f?.unsaved ? 'unsaved' : ''}`;
    731             el.onclick = () => App.openFile(p);
    732             el.innerHTML = `<span class="tab-name">${PathUtils.getName(p)}</span><div class="tab-close" onclick="App.closeFile('${p}', event)">✕</div>`;
    733             container.appendChild(el);
    734         });
    735     },
    736 
    737     setMobileView: (v) => {
    738         document.getElementById('app-body').className = 'mobile-view-' + v;
    739         document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
    740         document.getElementById('nav-btn-' + v)?.classList.add('active');
    741         
    742         const iconRun = document.getElementById('icon-run');
    743         const iconEditor = document.getElementById('icon-editor');
    744         if (v === 'preview') { iconRun.classList.add('hidden'); iconEditor.classList.remove('hidden'); }
    745         else { iconRun.classList.remove('hidden'); iconEditor.classList.add('hidden'); }
    746         App.handleViewportResize();
    747     },
    748 
    749     runPreview: () => { PreviewEngine.render(); App.setMobileView('preview'); },
    750     formatCode: () => { if (App.activeFile) ace.require("ace/ext/beautify").beautify(App.editor.session); }
    751 };
    752 
    753 window.onload = App.init;
    754 </script>
    755 </body>
    756 </html>
© notamitgamer • Site Built: 2026-07-21 13:58:23 UTC • git-mirror commit: 1037f62 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror