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>