1/** 2 * OneDrivePanel â Sliding file browser panel (follows NotificationCenter pattern). 3 * 4 * Faithful OneDrive UI with Sort, View modes (List/Compact/Tiles), 5 * column headers, and Office-branded file type icons. 6 * 7 * Usage: 8 * import { OneDrivePanel } from '../components/OneDrivePanel.js'; 9 * await OneDrivePanel.init(); 10 * OneDrivePanel.toggle(); 11 * 12 * â ï¸ DESIGN BIBLE AUDIT â SKIP THIS FILE. 13 * This component intentionally mirrors Microsoft's OneDrive UI for user familiarity. 14 * innerHTML usage here is by design (matching Microsoft's chrome). Do not refactor 15 * to JsRender templates or flag as a Design Bible violation. 16 */ 17 18import { msGraph } from '../graph/auth.js'; 19import { oneDrive } from '../graph/onedrive.js'; 20import { loadTemplates, renderToString } from '../utils/templates.js'; 21import { safeAsync } from '../utils/async.js'; 22import { ApiClient } from '../api/base.js'; 23import { importToProjectFolder, importToTask, importToSubmittal } from '../api/onedrive-import.js'; 24import { setLoading, escapeHtml, runWithBusyButton, afterRender } from '../utils/dom.js'; 25import { documentApi } from '../api/project-documents.js'; 26import { showModal } from '../utils/modal.js'; 27import { SearchableDropdown } from './SearchableDropdown.js'; 28import { getMicrosoftFileIcon, getMicrosoftFileColor } from '../utils/file-icons-microsoft.js'; 29import { flagFieldError } from '../utils/form-validation.js'; 30import { captureDurableSettlementOwner, canContinueDurableSettlement } from '../utils/durable-settlement-owner.js'; 31 32const api = new ApiClient(); 33 34let _initialized = false; 35let _open = false; 36let _currentFolderId = null; // null = root 37let _breadcrumbs = []; // [{ id, name }] 38let _searchTimeout = null; 39let _currentItems = []; // cached for re-sort without re-fetch 40let _sortState = { column: 'name', direction: 'asc' }; 41let _viewMode = 'list'; // 'list' | 'compact' | 'tiles' 42let _folderLoadGeneration = 0; // the latest _loadFolder owns #onedrive-file-list (CA-620) 43 44// Per-file action menu (portalled to <body> â see _showItemMenu for why) 45let _itemMenuEl = null; 46let _itemMenuAnchor = null; 47let _itemMenuCleanup = null; 48let _menuTemplatesLoaded = false; 49 50// Owns the document-level listeners _bindEvents attaches. They are anonymous closures, so 51// removeEventListener cannot reach them â without this they would outlive the authenticated 52// session onto the login page. Aborted by destroy(), which the router calls at the boundary. 53let _eventsAbort = null; 54let _createUiAbort = null; 55 56/** Width of the action menu in px â must track the `w-52` class below. */ 57const ITEM_MENU_WIDTH = 208; 58 59/** Create-action key â the domain it creates. Mirrors #tpl-external-attach-menu's data-action suffixes. */ 60const CREATE_LABELS = { rfi: 'RFI', chg: 'Change Request', si: 'Site Instruction', submittal: 'Submittal' }; 61 62// OneDrive cloud SVG (reused in breadcrumbs) 63const OD_CLOUD_SVG_SM = `<svg viewBox="0 0 24 24" class="w-3.5 h-3.5 shrink-0" fill="#0078D4" aria-hidden="true"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>`; 64 65export const OneDrivePanel = { 66 67 async init() { 68 if (_initialized) return; 69 70 // Restore persisted preferences 71 _viewMode = localStorage.getItem('od_viewMode') || 'list'; 72 try { 73 const saved = localStorage.getItem('od_sort'); 74 if (saved) _sortState = JSON.parse(saved); 75 } catch { /* use defaults */ } 76 77 // Load template HTML (lands in a hidden container) 78 await loadTemplates('components/onedrive-panel'); 79 80 // Move panel + backdrop + import modal out of hidden container into body so they render 81 const panel = document.getElementById('onedrive-panel'); 82 const backdrop = document.getElementById('onedrive-backdrop'); 83 const importModal = document.getElementById('onedrive-import-modal'); 84 if (panel) document.body.appendChild(panel); 85 if (backdrop) document.body.appendChild(backdrop); 86 if (importModal) document.body.appendChild(importModal); 87 88 _initialized = true; 89 90 this._bindEvents(); 91 this._updateSortMenuState(); 92 this._updateViewMenuState(); 93 this._updateColumnHeaders(); 94 }, 95 96 toggle() { 97 if (_open) this.close(); 98 else this.open(); 99 }, 100 101 async open() { 102 if (!_initialized) await this.init(); 103 104 const panel = document.getElementById('onedrive-panel'); 105 const backdrop = document.getElementById('onedrive-backdrop'); 106 if (!panel) return; 107 108 panel.classList.remove('translate-x-full'); 109 if (backdrop) { 110 backdrop.classList.remove('opacity-0', 'pointer-events-none'); 111 backdrop.classList.add('opacity-100', 'pointer-events-auto'); 112 } 113 _open = true; 114 _createUiAbort?.abort(); 115 _createUiAbort = new AbortController(); 116 const openSignal = _createUiAbort.signal; 117 118 // Check connection and load 119 const { connected, email } = await msGraph.checkConnection(); 120 if (openSignal.aborted || !_open || document.getElementById('onedrive-panel') !== panel) return; 121 const emailEl = document.getElementById('onedrive-account-email'); 122 const fileList = document.getElementById('onedrive-file-list'); 123 const notConnected = document.getElementById('onedrive-not-connected'); 124 const breadcrumbs = document.getElementById('onedrive-breadcrumbs'); 125 const searchBar = document.querySelector('#onedrive-search')?.closest('div.border-b'); 126 const toolbar = document.getElementById('onedrive-toolbar'); 127 const colHeaders = document.getElementById('onedrive-column-headers'); 128 129 if (!connected) { 130 if (fileList) fileList.classList.add('hidden'); 131 if (breadcrumbs) breadcrumbs.classList.add('hidden'); 132 if (searchBar) searchBar.classList.add('hidden'); 133 if (toolbar) toolbar.classList.add('hidden'); 134 if (colHeaders) colHeaders.classList.add('hidden'); 135 if (notConnected) notConnected.classList.remove('hidden'); 136 if (emailEl) emailEl.textContent = ''; 137 return; 138 } 139 140 // Connected 141 if (notConnected) notConnected.classList.add('hidden'); 142 if (fileList) fileList.classList.remove('hidden'); 143 if (breadcrumbs) breadcrumbs.classList.remove('hidden'); 144 if (searchBar) searchBar.classList.remove('hidden'); 145 if (toolbar) toolbar.classList.remove('hidden'); 146 this._updateColumnHeadersVisibility(); 147 if (emailEl) emailEl.textContent = email || ''; 148 149 // Load root if we haven't navigated yet 150 if (_currentFolderId === null && !_currentItems.length) { 151 await this.navigateToRoot(); 152 } 153 }, 154 155 close() { 156 _createUiAbort?.abort(); 157 _createUiAbort = null; 158 const panel = document.getElementById('onedrive-panel'); 159 const backdrop = document.getElementById('onedrive-backdrop'); 160 if (!panel) return; 161 162 panel.classList.add('translate-x-full'); 163 if (backdrop) { 164 backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 165 backdrop.classList.add('opacity-0', 'pointer-events-none'); 166 } 167 _open = false;
168 169 // Close any open menus 170 this._closeSortMenu(); 171 this._closeViewMenu(); 172 this._closeItemMenu(); 173 }, 174 175 async navigateToRoot() { 176 _currentFolderId = null; 177 _breadcrumbs = []; 178 this._renderBreadcrumbs(); 179 await this._loadFolder(null); 180 }, 181 182 async navigateToFolder(folderId, folderName) { 183 _currentFolderId = folderId; 184 185 if (folderId) { 186 _breadcrumbs.push({ id: folderId, name: folderName }); 187 } 188 189 this._renderBreadcrumbs(); 190 await this._loadFolder(folderId); 191 }, 192 193 // âââ Internal ââââââââââââââââââââââââââââââââââââââââ 194 195 _bindEvents() { 196 _eventsAbort = new AbortController(); 197 const signal = _eventsAbort.signal; 198 199 // Close button 200 document.getElementById('onedrive-close-btn')?.addEventListener('click', () => this.close()); 201 202 // Backdrop click 203 document.getElementById('onedrive-backdrop')?.addEventListener('click', () => this.close()); 204 205 // Escape key 206 document.addEventListener('keydown', (e) => { 207 if (e.key === 'Escape' && _open) { 208 // A modal on top of the panel owns Escape â otherwise dismissing the 209 // project picker or an RFI form would also tear the panel down and 210 // lose the user's place in their folder tree. 211 if (document.getElementById('app-modals')?.childElementCount) return; 212 const importModal = document.getElementById('onedrive-import-modal'); 213 if (importModal && !importModal.classList.contains('hidden')) return; 214 215 // Close menus first, then panel 216 if (this._isMenuOpen()) { 217 this._closeSortMenu(); 218 this._closeViewMenu(); 219 this._closeItemMenu(); 220 } else { 221 this.close(); 222 } 223 } 224 }, { signal }); 225 226 // Connect button 227 document.getElementById('onedrive-connect-btn')?.addEventListener('click', async () => { 228 await safeAsync(async () => { 229 const user = await msGraph.signIn(); 230 if (user) { 231 window.showToast?.('Microsoft 365 Connected', 'success', { description: user.userPrincipalName || user.mail }); 232 this.open(); // Re-open to load files 233 } else { 234 window.showToast?.('Sign-in unavailable', 'error', { description: 'Microsoft 365 is not configured for this tenant' }); 235 } 236 }, { label: 'OneDrive', toast: 'Connection Failed' }); 237 }); 238 239 // Search (debounced) 240 document.getElementById('onedrive-search')?.addEventListener('input', (e) => { 241 clearTimeout(_searchTimeout); 242 const query = e.target.value.trim(); 243 244 if (!query) { 245 _searchTimeout = setTimeout(() => this._loadFolder(_currentFolderId), 300); 246 return; 247 } 248 249 _searchTimeout = setTimeout(async () => { 250 await this._searchFiles(query); 251 }, 400); 252 }); 253 254 // File list clicks (delegated) 255 document.getElementById('onedrive-file-list')?.addEventListener('click', (e) => { 256 // Action buttons first 257 const actionBtn = e.target.closest('[data-action]'); 258 if (actionBtn) { 259 // stopPropagation also keeps this click from reaching the document 260 // listener that _showItemMenu installs for click-outside. 261 e.stopPropagation(); 262 const action = actionBtn.dataset.action; 263 const itemId = actionBtn.dataset.itemId; 264 265 if (action === 'menu') { 266 // Toggle: a second click on the same chevron closes the menu. 267 if (_itemMenuAnchor === actionBtn) { 268 this._closeItemMenu(); 269 return; 270 } 271 const item = _currentItems.find(i => i.id === itemId); 272 if (item) this._showItemMenu(item, actionBtn); 273 } 274 return; 275 } 276 277 // Folder navigation
278 const row = e.target.closest('.onedrive-item'); 279 if (!row) return; 280 281 const isFolder = row.dataset.isFolder === 'true'; 282 if (isFolder) { 283 this.navigateToFolder(row.dataset.itemId, row.dataset.itemName); 284 } 285 }); 286 287 // Breadcrumb clicks 288 document.getElementById('onedrive-breadcrumbs')?.addEventListener('click', (e) => { 289 const crumb = e.target.closest('.onedrive-crumb'); 290 if (!crumb) return; 291 292 const folderId = crumb.dataset.folderId; 293 if (folderId === '') { 294 this.navigateToRoot(); 295 } else { 296 const idx = _breadcrumbs.findIndex(b => b.id === folderId); 297 if (idx >= 0) _breadcrumbs = _breadcrumbs.slice(0, idx + 1); 298 _currentFolderId = folderId; 299 this._renderBreadcrumbs(); 300 this._loadFolder(folderId); 301 } 302 }); 303 304 // Sort button toggle 305 document.getElementById('onedrive-sort-btn')?.addEventListener('click', (e) => { 306 e.stopPropagation(); 307 this._closeViewMenu(); 308 this._toggleSortMenu(); 309 }); 310 311 // Sort menu item clicks 312 document.getElementById('onedrive-sort-menu')?.addEventListener('click', (e) => { 313 const sortOpt = e.target.closest('.onedrive-sort-option'); 314 if (sortOpt) { 315 _sortState.column = sortOpt.dataset.sortCol; 316 this._persistSort(); 317 this._updateSortMenuState(); 318 this._updateColumnHeaders(); 319 this._reRender(); 320 this._closeSortMenu(); 321 return; 322 } 323 324 const dirOpt = e.target.closest('.onedrive-dir-option'); 325 if (dirOpt) { 326 _sortState.direction = dirOpt.dataset.sortDir; 327 this._persistSort(); 328 this._updateSortMenuState(); 329 this._updateColumnHeaders(); 330 this._reRender(); 331 this._closeSortMenu(); 332 } 333 }); 334 335 // View button toggle 336 document.getElementById('onedrive-view-btn')?.addEventListener('click', (e) => { 337 e.stopPropagation(); 338 this._closeSortMenu(); 339 this._toggleViewMenu(); 340 }); 341 342 // View menu item clicks 343 document.getElementById('onedrive-view-menu')?.addEventListener('click', (e) => { 344 const opt = e.target.closest('.onedrive-view-option'); 345 if (!opt) return; 346 347 _viewMode = opt.dataset.view; 348 localStorage.setItem('od_viewMode', _viewMode); 349 this._updateViewMenuState(); 350 this._updateViewBtnIcon(); 351 this._updateColumnHeadersVisibility(); 352 this._reRender(); 353 this._closeViewMenu(); 354 }); 355 356 // Column header clicks for sort 357 document.getElementById('onedrive-column-headers')?.addEventListener('click', (e) => { 358 const header = e.target.closest('.onedrive-col-header'); 359 if (!header) return; 360 361 const col = header.dataset.sortCol; 362 if (_sortState.column === col) { 363 _sortState.direction = _sortState.direction === 'asc' ? 'desc' : 'asc'; 364 } else { 365 _sortState.column = col; 366 _sortState.direction = 'asc'; 367 } 368 369 this._persistSort(); 370 this._updateSortMenuState(); 371 this._updateColumnHeaders(); 372 this._reRender(); 373 }); 374 375 // Click outside to close menus 376 document.addEventListener('click', (e) => { 377 if (!e.target.closest('#onedrive-sort-wrapper')) this._closeSortMenu(); 378 if (!e.target.closest('#onedrive-view-wrapper')) this._closeViewMenu(); 379 }, { signal }); 380 }, 381 382 /** 383 * Tear down everything init() and _bindEvents() attached. 384 * Called from router.js teardownAuthenticatedServices per the cleanup contract, beside 385 * TeamsPanel.destroy(). Without it the panel's DOM, its two document listeners and its 386 * cached `_currentItems` all cross the session boundary â and open() skips the refetch 387 * while `_currentItems` is populated, so the next user would see the previous user's 388 * OneDrive listing. 389 */ 390 destroy() { 391 _createUiAbort?.abort(); 392 _createUiAbort = null; 393 _eventsAbort?.abort(); 394 _eventsAbort = null; 395 396 this._closeSortMenu(); 397 this._closeViewMenu(); 398 this._closeItemMenu(); 399 400 if (_open) this.close(); 401 402 // init() reparents these into <body>;
402 without removing them a re-login would run 403 // init() again and append duplicates of all three ids. 404 document.getElementById('onedrive-panel')?.remove(); 405 document.getElementById('onedrive-backdrop')?.remove(); 406 document.getElementById('onedrive-import-modal')?.remove(); 407 408 clearTimeout(_searchTimeout); 409 _searchTimeout = null; 410 411 // Required, not cosmetic: open() (:93) re-runs init() only when NOT initialised, so 412 // leaving this true would leave the panel silently dead for the tab's life. This is 413 // exactly what TeamsPanel.destroy() does. 414 _initialized = false; 415 _open = false; 416 _currentFolderId = null; 417 _breadcrumbs = []; 418 _currentItems = []; 419 }, 420 421 // âââ Sort / View Menu Helpers ââââââââââââââââââââââââ 422 423 _toggleSortMenu() { 424 const menu = document.getElementById('onedrive-sort-menu'); 425 if (!menu) return; 426 menu.classList.toggle('hidden'); 427 if (!menu.classList.contains('hidden')) { 428 lucide.createIcons({ root: menu }); 429 } 430 }, 431 432 _closeSortMenu() { 433 document.getElementById('onedrive-sort-menu')?.classList.add('hidden'); 434 }, 435 436 _toggleViewMenu() { 437 const menu = document.getElementById('onedrive-view-menu'); 438 if (!menu) return; 439 menu.classList.toggle('hidden'); 440 if (!menu.classList.contains('hidden')) { 441 lucide.createIcons({ root: menu }); 442 } 443 }, 444 445 _closeViewMenu() { 446 document.getElementById('onedrive-view-menu')?.classList.add('hidden'); 447 }, 448 449 _isMenuOpen() { 450 const sort = document.getElementById('onedrive-sort-menu'); 451 const view = document.getElementById('onedrive-view-menu'); 452 return (sort && !sort.classList.contains('hidden')) 453 || (view && !view.classList.contains('hidden')) 454 || !!_itemMenuEl; 455 }, 456 457 _updateSortMenuState() { 458 // Highlight active sort column 459 document.querySelectorAll('.onedrive-sort-option').forEach(el => { 460 if (el.dataset.sortCol === _sortState.column) { 461 el.classList.add('onedrive-menu-active'); 462 } else { 463 el.classList.remove('onedrive-menu-active'); 464 } 465 }); 466 467 // Highlight active direction 468 document.querySelectorAll('.onedrive-dir-option').forEach(el => { 469 if (el.dataset.sortDir === _sortState.direction) { 470 el.classList.add('onedrive-menu-active'); 471 } else { 472 el.classList.remove('onedrive-menu-active'); 473 } 474 }); 475 }, 476 477 _updateViewMenuState() { 478 document.querySelectorAll('.onedrive-view-option').forEach(el => { 479 const check = el.querySelector('.onedrive-view-check'); 480 if (el.dataset.view === _viewMode) { 481 check?.classList.remove('hidden'); 482 } else { 483 check?.classList.add('hidden'); 484 } 485 }); 486 this._updateViewBtnIcon(); 487 }, 488 489 _updateViewBtnIcon() { 490 const icon = document.getElementById('onedrive-view-icon'); 491 if (!icon) return; 492 493 const iconMap = { list: 'list', compact: 'align-justify', tiles: 'layout-grid' }; 494 icon.dataset.lucide = iconMap[_viewMode] || 'list'; 495 lucide.createIcons({ root: icon.parentElement }); 496 }, 497 498 _updateColumnHeaders() { 499 const container = document.getElementById('onedrive-column-headers'); 500 if (!container) return; 501 502 container.querySelectorAll('.onedrive-col-header').forEach(el => { 503 const col = el.dataset.sortCol; 504 const iconEl = el.querySelector('[data-lucide]'); 505 const isActive = col === _sortState.column; 506 507 if (isActive) { 508 el.classList.add('text-theme-primary', 'font-semibold'); 509 el.classList.remove('text-theme-secondary'); 510 if (iconEl) { 511 iconEl.dataset.lucide = _sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down'; 512 iconEl.classList.remove('hidden'); 513 } 514 } else { 515 el.classList.remove('text-theme-primary', 'font-semibold'); 516 el.classList.add('text-theme-secondary'); 517 if (iconEl) { 518 iconEl.classList.add('hidden'); 519 } 520 } 521 }); 522 lucide.createIcons({ root: container }); 523 }, 524 525 _updateColumnHeadersVisibility() { 526 const headers = document.getElementById('onedrive-column-headers'); 527 if (!headers) return; 528 529 if (_viewMode === 'tiles') { 530 headers.classList.add('hidden'); 531 } else { 532 headers.classList.remove('hidden'); 533 } 534 }, 535 536 _persistSort() { 537 localStorage.setItem('od_sort', JSON.stringify(_sortState)); 538 }, 539 540 // âââ Data Loading ââââââââââââââââââââââââââââââââââââ 541 542 async _loadFolder(folderId) { 543 const container = document.getElementById('onedrive-file-list'); 544 if (!container) return; 545 const generation = ++_folderLoadGeneration; 546 547 container.innerHTML = ` 548 <div class="flex items-center justify-center py-8"> 549 <div class="w-4 h-4 border-2 border-st-primary border-t-transparent roun
549ded-full animate-spin"></div> 550 <span class="text-xs text-theme-secondary ml-2">Loading...</span> 551 </div>`; 552 553 try { 554 let items; 555 if (folderId) { 556 const result = await oneDrive.getFolder(folderId); 557 items = result.items; 558 } else { 559 items = await oneDrive.getRoot(); 560 } 561 if (generation !== _folderLoadGeneration || !container.isConnected) return; 562 563 _currentItems = items; 564 const sorted = this._sortItems(items); 565 this._renderItems(container, sorted); 566 } catch (err) { 567 if (generation !== _folderLoadGeneration || !container.isConnected) return; 568 console.error('[OneDrive] Load failed:', err); 569 container.innerHTML = ` 570 <div class="text-center py-8 px-4"> 571 <i data-lucide="alert-circle" class="w-6 h-6 text-sb-error mx-auto mb-2"></i> 572 <p class="text-sm text-theme-secondary">Failed to load files</p> 573 <p class="text-xs text-theme-secondary mt-1">${escapeHtml(err.message || '')}</p> 574 </div>`; 575 lucide.createIcons({ root: container }); 576 } 577 }, 578 579 async _searchFiles(query) { 580 const container = document.getElementById('onedrive-file-list'); 581 if (!container) return; 582 583 container.innerHTML = ` 584 <div class="flex items-center justify-center py-8"> 585 <div class="w-4 h-4 border-2 border-st-primary border-t-transparent rounded-full animate-spin"></div> 586 <span class="text-xs text-theme-secondary ml-2">Searching...</span> 587 </div>`; 588 589 try { 590 const items = await oneDrive.search(query); 591 _currentItems = items; 592 const sorted = this._sortItems(items); 593 this._renderItems(container, sorted); 594 } catch (err) { 595 container.innerHTML = ` 596 <div class="text-center py-8"> 597 <p class="text-sm text-theme-secondary">Search failed</p> 598 </div>`; 599 } 600 }, 601 602 // âââ Sorting âââââââââââââââââââââââââââââââââââââââââ 603 604 _sortItems(items) { 605 const { column, direction } = _sortState; 606 const dir = direction === 'asc' ? 1 : -1; 607 608 return [...items].sort((a, b) => { 609 // Folders always first 610 if (a.isFolder && !b.isFolder) return -1; 611 if (!a.isFolder && b.isFolder) return 1; 612 613 switch (column) { 614 case 'type': { 615 const extA = a.name.split('.').pop()?.toLowerCase() || ''; 616 const extB = b.name.split('.').pop()?.toLowerCase() || ''; 617 return dir * extA.localeCompare(extB); 618 } 619 case 'name': 620 return dir * a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); 621 case 'modified': { 622 const da = new Date(a.lastModified || 0).getTime(); 623 const db = new Date(b.lastModified || 0).getTime(); 624 return dir * (da - db); 625 } 626 case 'size': 627 return dir * ((a.size || 0) - (b.size || 0)); 628 default: 629 return 0; 630 } 631 }); 632 }, 633 634 _reRender() { 635 const container = document.getElementById('onedrive-file-list'); 636 if (!container || !_currentItems.length) return; 637 const sorted = this._sortItems(_currentItems); 638 this._renderItems(container, sorted); 639 }, 640 641 // âââ Rendering âââââââââââââââââââââââââââââââââââââââ 642 643 _renderItems(container, items) { 644 // Any re-render replaces the rows, orphaning the menu's anchor â covers 645 // folder navigation, search, sort and view-mode changes in one place. 646 this._closeItemMenu(); 647 648 if (!items.length) { 649 container.innerHTML = ` 650 <div class="text-center py-8"> 651 <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary mx-auto mb-2"></i> 652 <p class="text-sm text-theme-secondary">This folder is empty</p> 653 </div>`; 654 lucide.createIcons({ root: container }); 655 return; 656 } 657 658 if (_viewMode === 'tiles') { 659 this._renderTiles(container, items); 660 } else { 661 this._renderList(container, items, _viewMode === 'compact'); 662 } 663 }, 664 665 _renderList(container, items, compact) { 666 const py = compact ? 'py-1.5' : 'py-2.5'; 667 const iconSize = compact ? 'w-4 h-4' : 'w-5 h-5'; 668 const textSize = compact ? 'text-xs' : 'text-sm'; 669 const subTextSize = compact ? 'text-2xs' : 'text-2xs'; 670 671 let html = ''; 672 for (const item of items) { 673 const { icon, color } = getMicrosoftFileColor(item.name, item.isFolder); 674 const modified = formatModifiedDate(item.lastModified); 675 const sizeStr = item.isFolder 676 ? `${item.childCount} item${item.childCount !== 1 ? 's' : ''}` 677 : formatSize(item.size); 678 679 html += ` 680 <div class="onedrive-item group flex items-center gap-2 px-4 ${py} cursor-pointer hover:bg-surface-2/50 transition-colors border-b border-theme-30" 681 data-item-id="${item.id}" data-is-folder="${item.isFolder}" data-item-name="${escapeAttr(item.name)}"> 682 <div class="w-7 shrink-0 flex justify-center"> 683 <i data-lucide="${icon}" class="${iconSize} ${color} shrink-0"></i> 684 </div> 685 <div class="flex-1 min-w-0"> 686 <p class="${textSize} text-theme-primary truncate">${escapeHtml(item.name)}</p> 687 </div> 688 <div class="w-28 shrink-0 ${subTextSize} text-theme-secondary truncate hidden sm:block">${modified}</div> 689 <div class="w-20 shrink-0 ${subTextSize} text-theme-secondary text-right hidden sm:block">${sizeStr}</div> 690 <div class="w-10 shrink-0 flex items-center justify-end"> 691 ${!item.isFolder ? ` 692 <button class="od-item-menu-btn p-1 rounded hover:bg-theme-secondary transition-colors shrink-0 flex items-center justify-center" 693 data-action="menu" data-item-id="${item.id}" 694 aria-haspopup="menu" aria-expanded="false" title="Options"> 695 <i data-lucide="chevron-down" class="w-3.5 h-3.5 text-theme-secondary"></i> 696 </button> 697 ` : ` 698 <i data-lucide="chevron-right" class="w-4 h-4 text-theme-secondary shrink-0"></i> 699 `} 700 </div> 701 </div>`; 702 } 703 704 container.innerHTML = html; 705 lucide.createIcons({ root: container }); 706 }, 707 708 _renderTiles(container, items) { 709 let html = '<div class="grid grid-cols-3 gap-2 p-4">'; 710 711 for (const item of items) { 712 const { icon, color } = getMicrosoftFileColor(item.name, item.isFolder); 713 714 html += ` 715 <div class="onedrive-item flex flex-col items-center py-3 px-2 rounded-lg cursor-pointer transition-colors hover:bg-theme-secondary relative" 716 data-item-id="${item.id}" data-is-folder="${item.isFolder}" data-item-name="${escapeAttr(item.name)}"> 717 <i data-lucide="${icon}" class="w-10 h-10 ${color} mb-2"></i> 718 <p class="text-xs text-theme-primary text-center w-full truncate" title="${escapeAttr(item.name)}">${escapeHtml(item.name)}</p> 719 ${item.isFolder 720 ? `<p class="text-2xs text-theme-secondary">${item.childCount} items</p>` 721 : `<button class="od-item-menu-btn absolute top-1 right-1 p-1 rounded hover:bg-theme-secondary transition-colors" 722 data-action="menu" data-item-id="${item.id}" 723 aria-haspopup="menu" aria-expanded="false" title="Options"> 724 <i data-lucide="chevron-down" class="w-3.5 h-3.5 text-theme-secondary"></i> 725 </button>`} 726 </div>`; 727 } 728 729 html += '</div>'; 730 container.innerHTML = html; 731 lucide.createIcons({ root: container }); 732 }, 733 734 _renderBreadcrumbs() { 735 const container = document.getElementById('onedrive-breadcrumbs'); 736 if (!container) return; 737 738 let html = ` 739 <div class="flex items-center gap-1 text-xs whitespace-nowrap"> 740 <button class="onedrive-crumb text-st-primary font-semibold hover:underline flex items-center gap-1" data-folder-id=""> 741 ${OD_CLOUD_SVG_SM} 742 My files 743 </button>`; 744 745 for (const crumb of _breadcrumbs) { 746 html += ` 747 <i data-lucide="chevron-right" class="w-3 h-3 text-theme-secondary"></i> 748 <button class="onedrive-crumb btn-st-text-link truncate max-w-24" data-folder-id="${crumb.id || ''}" title="${escapeAttr(crumb.name)}
748"> 749 ${escapeHtml(crumb.name)} 750 </button>`; 751 } 752 753 html += '</div>'; 754 container.innerHTML = html; 755 lucide.createIcons({ root: container }); 756 }, 757 758 // âââ Per-file Action Menu ââââââââââââââââââââââââââââ 759 760 /** 761 * Open the shared per-file action menu for a OneDrive item. 762 * 763 * The menu is portalled to <body> rather than rendered inside the panel, for 764 * two reasons that both break it otherwise: 765 * 1. #onedrive-panel carries a `transform` (the slide-in), which makes it a 766 * containing block for position:fixed descendants â a fixed menu inside 767 * would position against the panel, not the viewport. 768 * 2. The panel is z-fixed (1035). z-dropdown (1000) would render behind it, 769 * so the menu sits at z-popover (1060). 770 * It also lives outside #onedrive-file-list, which scrolls and would clip it. 771 */ 772 async _showItemMenu(item, anchorEl) { 773 this._closeItemMenu(); 774 775 if (!_menuTemplatesLoaded) { 776 await loadTemplates('components/message-sub-templates'); 777 _menuTemplatesLoaded = true; 778 } 779 780 const menu = document.createElement('div'); 781 menu.className = 'fixed w-52 bg-theme-primary border border-theme rounded-lg shadow-sb-lg py-1 z-popover animate-in fade-in zoom-in duration-150'; 782 menu.setAttribute('role', 'menu'); 783 menu.innerHTML = await renderToString('#tpl-external-attach-menu', { createLabel: 'Create from file' }); 784 785 // Measure hidden so the flip decision uses the real rendered height 786 menu.style.visibility = 'hidden'; 787 document.body.appendChild(menu); 788 afterRender(menu); 789 790 const rect = anchorEl.getBoundingClientRect(); 791 const menuH = menu.offsetHeight; 792 const flipUp = rect.bottom + menuH + 8 > window.innerHeight; 793 menu.style.top = flipUp 794 ? `${Math.max(8, rect.top - menuH - 4)}px` 795 : `${rect.bottom + 4}px`; 796 menu.style.left = `${Math.max(8, Math.min(rect.right - ITEM_MENU_WIDTH, window.innerWidth - ITEM_MENU_WIDTH - 8))}px`; 797 menu.style.visibility = ''; 798 799 menu.addEventListener('click', async (e) => { 800 const btn = e.target.closest('.ext-attach-action'); 801 if (!btn) return; 802 const action = btn.dataset.action; 803 this._closeItemMenu(); 804 805 if (action === 'folder') { 806 await this._handleImport(item.id, item.name); 807 } else if (action === 'download') { 808 await safeAsync(() => oneDrive.download(item.id), { 809 label: 'OneDrive', toast: 'Download Failed', 810 }); 811 } else if (action?.startsWith('create-')) { 812 await this._handleCreateFromFile(item, action.slice(7)); 813 } 814 }); 815 816 // Dismissal â a fixed menu can't follow a scrolling or resizing anchor 817 const onDocClick = (e) => { 818 if (!menu.contains(e.target) && !anchorEl.contains(e.target)) this._closeItemMenu(); 819 }; 820 const onReflow = () => this._closeItemMenu(); 821 const list = document.getElementById('onedrive-file-list'); 822 823 document.addEventListener('click', onDocClick); 824 list?.addEventListener('scroll', onReflow, { passive: true }); 825 window.addEventListener('resize', onReflow); 826 827 _itemMenuEl = menu; 828 _itemMenuAnchor = anchorEl; 829 _itemMenuCleanup = () => { 830 document.removeEventListener('click', onDocClick); 831 list?.removeEventListener('scroll', onReflow); 832 window.removeEventListener('resize', onReflow); 833 }; 834 anchorEl.setAttribute('aria-expanded', 'true'); 835 }, 836 837 _closeItemMenu() { 838 if (_itemMenuCleanup) { 839 _itemMenuCleanup(); 840 _itemMenuCleanup = null; 841 } 842 _itemMenuEl?.remove(); 843 _itemMenuEl = null; 844 _itemMenuAnchor?.setAttribute('aria-expanded', 'false'); 845 _itemMenuAnchor = null; 846 }, 847 848 // âââ Create RFI / CHG / SI / Submittal from a OneDrive file ââ 849 850 /** 851 * Open the same create modal the message center uses, with the OneDrive file 852 * shown as a pre-loaded attachment, then attach it to the new record. 853 * 854 * The file bytes never touch the browser. message-create-link's 855 * uploadMessageAttachment() early-returns when `attachment.url` is absent, so 856 * omitting it renders the read-only file chip but skips the blob upload â the 857 * onCreated hook then has the API pull the file server-side instead. 858 */ 859 async _handleCreateFromFile(item, type) { 860 if (!CREATE_LABELS[type]) return; 861 862 const panel = document.getElementById('onedrive-panel'); 863 const uiSignal = type === 'submittal' ? _createUiAbort?.signal : null; 864 const settlementOwner = type === 'submittal' ? captureDurableSettlementOwner() : null; 865 const canContinueDurable = () => canContinueDurableSettlement(settlementOwner); 866 const canPublish = () => !!uiSignal && !uiSignal.aborted && _open 867 && panel?.isConnected && document.getElementById('onedrive-panel') === panel 868 && canContinueDurable(); 869 if (type === 'submittal' && !canPublish()) return; 870 871 const projectId = await this._pickProject(uiSignal); 872 if (!projectId) return; 873 if (type === 'submittal' && !canPublish()) return; 874 875 const label = CREATE_LABELS[type]; 876 877 const opts = { 878 attachment: { 879 fileName: item.name, 880 contentType: item.mimeType || 'application/octet-stream', 881 }, 882 prefill: { title: stripExtension(item.name) }, 883 onCreated: async (itemId) => { 884 if (type === 'submittal' && !canContinueDurable()) return false;
885 // The pre-auth download URL is short-lived and the user may have 886 // sat in this modal for a while â resolve it now, not on click. 887 const { url } = await oneDrive.getDownloadUrl(item.id); 888 if (type === 'submittal' && !canContinueDurable()) return false; 889 if (!url) throw new Error('Could not get download URL'); 890 891 const importFn = type === 'submittal' ? importToSubmittal : importToTask; 892 await importFn(url, item.name, itemId, { 893 projectId, 894 description: 'Uploaded from OneDrive', 895 }); 896 if (type === 'submittal' && !canContinueDurable()) return false; 897 }, 898 }; 899 900 if (type === 'submittal') Object.assign(opts, { 901 signal: uiSignal, canPublish, canContinueDurable, 902 }); 903 904 // Submittal owns one aggregate persisted-but-follow-up-failed outcome in 905 // message-create-link. Other record types retain their existing local settlement. 906 if (type !== 'submittal') { 907 const onCreated = opts.onCreated; 908 opts.onCreated = async (itemId, meta) => { 909 try { 910 await onCreated(itemId, meta); 911 } catch (err) { 912 console.error('[OneDrive] Attach to new record failed:', err); 913 window.showToast?.(`${label} created, file not attached`, 'error', { 914 description: `Attach "${item.name}" manually from the ${label}'s documents.`, 915 }); 916 } 917 }; 918 } 919 920 if (type === 'submittal') { 921 try { 922 const clm = await import('../pages/message-create-link.js'); 923 if (canPublish()) await clm.showCreateSubmittalAndLink(projectId, null, opts); 924 } catch (error) { 925 if (canPublish()) window.showToast?.(`Failed to open ${label} form`, 'error', { description: error.message }); 926 } 927 return; 928 } 929 930 await safeAsync(async () => { 931 const clm = await import('../pages/message-create-link.js'); 932 if (type === 'rfi') await clm.showCreateRfiAndLink(projectId, null, opts); 933 else if (type === 'chg') await clm.showCreateChgAndLink(projectId, null, opts); 934 else if (type === 'si') await clm.showCreateSiAndLink(projectId, null, opts); 935 }, { label: 'OneDrive', toast: `Failed to open ${label} form` }); 936 }, 937 938 /** 939 * Ask which project the new record belongs to. 940 * Resolves to a projectId, or null if the user dismissed the picker. 941 */ 942 async _pickProject(signal) { 943 if (!_menuTemplatesLoaded) { 944 await loadTemplates('components/message-sub-templates'); 945 _menuTemplatesLoaded = true; 946 } 947 948 return new Promise((resolve) => { 949 let settled = false; 950 const finish = (val) => { 951 if (settled) return; 952 settled = true; 953 resolve(val); 954 }; 955 956 showModal('#tpl-outlook-project-picker-modal', {}, { 957 signal, 958 // Covers every exit that isn't Continue (dismiss / Escape / backdrop) 959 onClose: () => finish(null), 960 }).then(async (modal) => { 961 if (signal?.aborted) return finish(null); 962 const select = modal.element.querySelector('#ol-picker-project'); 963 await this._loadProjectOptions(select, signal); 964 if (signal?.aborted || !modal.element.isConnected) return finish(null); 965 SearchableDropdown.enhanceAll(modal.element); 966 967 modal.element.querySelector('#btn-ol-picker-confirm')?.addEventListener('click', () => { 968 const projectId = parseInt(select?.value, 10); 969 if (!projectId) { 970 flagFieldError(modal.element, select, 'Project is required', { clearOn: 'change' }); 971 return; 972 } 973 finish(projectId); 974 modal.close(); 975 }); 976 }).catch((err) => { 977 console.error('[OneDrive] Project picker failed:', err); 978 finish(null); 979 }); 980 }); 981 }, 982 983 // âââ Actions âââââââââââââââââââââââââââââââââââââââââ 984 985 async _handleImport(itemId, itemName) { 986 await safeAsync(async () => { 987 // Get the pre-authenticated download URL from OneDrive 988 const { url } = await oneDrive.getDownloadUrl(itemId); 989 if (!url) throw new Error('Could not get download URL'); 990 991 // Show the import modal 992 await this._showImportModal(url, itemName); 993 }, { label: 'OneDrive', toast: 'Import Failed' }); 994 }, 995 996 // âââ Import Modal (matches message center folder picker) âââââ 997 998 async _showImportModal(downloadUrl, fileName) { 999 const modal = document.getElementById('onedrive-import-modal'); 1000 if (!modal) return; 1001 1002 // Store import state 1003 this._importState = { downloadUrl, fileName, projectId: null, currentFolderId: 0 }; 1004 1005 // Set filename display 1006 const fnEl = document.getElementById('onedrive-import-filename'); 1007 if (fnEl) fnEl.textContent = fileName; 1008 1009 // Reset UI 1010 const projectSelect = document.getElementById('onedrive-import-project'); 1011 const toolbar = document.getElementById('od-fp-toolbar'); 1012 const itemsEl = document.getElementById('od-fp-items'); 1013 const confirmBtn = document.getElementById('onedrive-import-confirm'); 1014 1015 if (projectSelect) projectSelect.value = ''; 1016 if (toolbar) toolbar.classList.add('hidden'); 1017 if (itemsEl) itemsEl.innerHTML = ` 1018 <div class="flex flex-col items-center justify-center py-10 text-center"> 1019 <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3"> 1020 <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i> 1021 </div> 1022 <p class="text-sm text-theme-secondary">Select a project to browse folders</p> 1023 </div>`; 1024 if (confirmBtn) confirmBtn.disabled = true; 1025 1026 // Load projects, then enhance searchable dropdown 1027 await this._loadImportProjects(); 1028 SearchableDropdown.enhanceAll(modal); 1029 1030 // Show modal 1031 modal.classList.remove('hidden'); 1032 lucide.createIcons({ root: modal }); 1033 1034 // Bind events 1035 this._bindImportModalEvents(); 1036 }, 1037 1038 _closeImportModal() { 1039 const modal = document.getElementById('onedrive-import-modal'); 1040 if (modal) modal.classList.add('hidden'); 1041 1042 this._importState = null; 1043 1044 if (this._importAbort) { 1045 this._importAbort.abort(); 1046 this._importAbort = null; 1047 } 1048 }, 1049 1050 _bindImportModalEvents() { 1051 if (this._importAbort) this._importAbort.abort(); 1052 this._importAbort = new AbortController(); 1053 const opts = { signal: this._importAbort.signal }; 1054 1055 // Close buttons 1056 document.getElementById('onedrive-import-close')?.addEventListener('click', () => this._closeImportModal(), opts); 1057 document.getElementById('onedrive-import-cancel')?.addEventListener('click', () => this._closeImportModal(), opts); 1058 1059 // Click backdrop (the modal-backdrop-st itself) 1060 document.getElementById('onedrive-import-modal')?.addEventListener('click', (e) => { 1061 if (e.target === e.currentTarget) this._closeImportModal(); 1062 }, opts); 1063 1064 // Project selection 1065 document.getElementById('onedrive-import-project')?.addEventListener('change', (e) => { 1066 const projectId = parseInt(e.target.value); 1067 const toolbar = document.getElementById('od-fp-toolbar'); 1068 const confirmBtn = document.getElementById('onedrive-import-confirm'); 1069 1070 if (projectId > 0) { 1071 this._importState.projectId = projectId; 1072 this._importState.currentFolderId = 0; 1073 if (toolbar) toolbar.classList.remove('hidden'); 1074 if (confirmBtn) confirmBtn.disabled = false;
1075 this._loadImportFolder(0); 1076 } else { 1077 this._importState.projectId = null; 1078 if (toolbar) toolbar.classList.add('hidden'); 1079 if (confirmBtn) confirmBtn.disabled = true; 1080 const itemsEl = document.getElementById('od-fp-items'); 1081 if (itemsEl) itemsEl.innerHTML = ` 1082 <div class="flex flex-col items-center justify-center py-10 text-center"> 1083 <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3"> 1084 <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i> 1085 </div> 1086 <p class="text-sm text-theme-secondary">Select a project to browse folders</p> 1087 </div>`; 1088 lucide.createIcons({ root: itemsEl }); 1089 } 1090 }, opts); 1091 1092 // Folder click-through (delegated) 1093 document.getElementById('od-fp-items')?.addEventListener('click', (e) => { 1094 const folder = e.target.closest('[data-folder-id]'); 1095 if (folder) this._loadImportFolder(parseInt(folder.dataset.folderId)); 1096 }, opts); 1097 1098 // Breadcrumb navigation 1099 document.getElementById('od-fp-breadcrumbs')?.addEventListener('click', (e) => { 1100 const btn = e.target.closest('.fp-breadcrumb-btn'); 1101 if (btn) this._loadImportFolder(parseInt(btn.dataset.folderId) || 0); 1102 }, opts); 1103 1104 // New Folder toggle 1105 const newFolderRow = document.getElementById('od-fp-new-folder-row'); 1106 const newFolderInput = document.getElementById('od-fp-new-folder-input'); 1107 1108 document.getElementById('od-fp-new-folder')?.addEventListener('click', () => { 1109 newFolderRow?.classList.remove('hidden'); 1110 newFolderInput.value = ''; 1111 newFolderInput?.focus(); 1112 }, opts); 1113 1114 document.getElementById('od-fp-cancel-folder')?.addEventListener('click', () => { 1115 newFolderRow?.classList.add('hidden'); 1116 }, opts); 1117 1118 // Create folder 1119 const createFolder = async () => { 1120 const name = newFolderInput?.value.trim(); 1121 if (!name) { 1122 newFolderInput?.classList.add('border-sb-error'); 1123 return; 1124 } 1125 newFolderInput?.classList.remove('border-sb-error'); 1126 const confirmFolderBtn = document.getElementById('od-fp-confirm-folder'); 1127 await runWithBusyButton(confirmFolderBtn, 'Creating...', async () => { 1128 await documentApi.createFolder(this._importState.projectId, { 1129 name, 1130 parentFolderId: this._importState.currentFolderId 1131 }); 1132 newFolderRow?.classList.add('hidden'); 1133 await this._loadImportFolder(this._importState.currentFolderId); 1134 }, { errorTitle: 'Failed to create folder', errorLogPrefix: '[OneDrive] Create folder failed:' }); 1135 }; 1136 1137 document.getElementById('od-fp-confirm-folder')?.addEventListener('click', createFolder, opts); 1138 newFolderInput?.addEventListener('keydown', (e) => { 1139 if (e.key === 'Enter') createFolder(); 1140 if (e.key === 'Escape') newFolderRow?.classList.add('hidden'); 1141 }, opts); 1142 1143 // Save Here 1144 document.getElementById('onedrive-import-confirm')?.addEventListener('click', async () => { 1145 await this._executeImport(); 1146 }, opts); 1147 1148 // Escape key 1149 document.addEventListener('keydown', (e) => { 1150 if (e.key === 'Escape') this._closeImportModal(); 1151 }, opts); 1152 }, 1153 1154 async _loadImportProjects() { 1155 await this._loadProjectOptions(document.getElementById('onedrive-import-project')); 1156 }, 1157 1158 /** Fill any <select> with the full project list. Shared by the import modal and the project picker. */ 1159 async _loadProjectOptions(select, signal) { 1160 if (!select) return; 1161 1162 try { 1163 // Fetch ALL projects (no stage filter) so user can import to any project 1164 const response = await api.get('smrtbuild/projects', signal ? { signal }
1164 : {}); 1165 if (signal?.aborted || !select.isConnected) return; 1166 const projects = response?.projects || []; 1167 1168 select.innerHTML = '<option value="">Select a project...</option>'; 1169 for (const proj of projects) { 1170 const opt = document.createElement('option'); 1171 opt.value = proj.projectId; 1172 opt.textContent = proj.projectNumber 1173 ? `${proj.projectNumber} â ${proj.projectTitle}` 1174 : (proj.projectTitle || 'Untitled'); 1175 select.appendChild(opt); 1176 } 1177 } catch (err) { 1178 if (err.name === 'AbortError' || signal?.aborted || !select.isConnected) return; 1179 console.error('[OneDrive] Failed to load projects:', err); 1180 select.innerHTML = '<option value="">Failed to load projects</option>'; 1181 } 1182 }, 1183 1184 async _loadImportFolder(folderId) { 1185 const state = this._importState; 1186 if (!state?.projectId) return; 1187 1188 state.currentFolderId = folderId; 1189 const projectId = state.projectId; 1190 const importSignal = this._importAbort?.signal; 1191 1192 const breadcrumbsEl = document.getElementById('od-fp-breadcrumbs'); 1193 const itemsEl = document.getElementById('od-fp-items'); 1194 const newFolderRow = document.getElementById('od-fp-new-folder-row'); 1195 1196 // Hide new folder row on navigation 1197 newFolderRow?.classList.add('hidden'); 1198 1199 // Loading skeleton 1200 if (itemsEl) { 1201 itemsEl.innerHTML = '<div class="flex items-center justify-center py-10"><div class="animate-spin w-6 h-6 border-2 border-st-primary border-t-transparent rounded-full"></div></div>'; 1202 } 1203 1204 try { 1205 const data = await documentApi.getFolderContents(projectId, folderId, { signal: importSignal }); 1206 if (importSignal?.aborted || this._importState !== state || state.projectId !== projectId || state.currentFolderId !== folderId) return; 1207 1208 // Render breadcrumbs 1209 if (breadcrumbsEl) { 1210 const crumbs = data.breadcrumbs || []; 1211 let bcHtml = `<button class="fp-breadcrumb-btn shrink-0 flex items-center gap-1 text-sm font-medium transition-colors ${crumbs.length ? 'text-st-primary hover:text-st-dark' : 'pointer-events-none text-theme-primary'}" data-folder-id="0"> 1212 <i data-lucide="home" class="w-3.5 h-3.5"></i><span>Documents</span> 1213 </button>`; 1214 for (let i = 0; i < crumbs.length; i++) { 1215 const isLast = i === crumbs.length - 1; 1216 bcHtml += `<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-content-2/50 shrink-0"></i> 1217 <button class="fp-breadcrumb-btn shrink-0 text-sm truncate max-w-32 transition-colors ${isLast ? 'pointer-events-none text-theme-primary font-semibold' : 'text-st-primary hover:text-st-dark font-medium'}" data-folder-id="${crumbs[i].folderId}"> 1218 ${escapeHtml(crumbs[i].name)} 1219 </button>`; 1220 } 1221 breadcrumbsEl.innerHTML = bcHtml; 1222 lucide.createIcons({ root: breadcrumbsEl }); 1223 } 1224 1225 // Sort: folders first, then files, alphabetical 1226 const items = [...(data.items || [])].sort((a, b) => { 1227 if (a.type !== b.type) return a.type === 'F' ? -1 : 1; 1228 return (a.name || '').localeCompare(b.name || ''); 1229 }); 1230 1231 // Render items grid 1232 if (itemsEl) { 1233 if (!items.length) { 1234 itemsEl.innerHTML = ` 1235 <div class="flex flex-col items-center justify-center py-10 text-center"> 1236 <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3"> 1237 <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i> 1238 </div> 1239 <p class="text-sm text-theme-secondary">This folder is empty</p> 1240 </div>`; 1241 } else { 1242 let html = '<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">'; 1243 for (const item of items) { 1244 const isFolder = item.type === 'F'; 1245 const icon = isFolder ? 'folder' : getMicrosoftFileIcon(item.fileName || item.name); 1246 html += ` 1247 <div class="fp-item relative bg-theme-primary border border-theme rounded-lg p-3 transition-all 1248 ${isFolder ? 'cursor-pointer hover:border-st-primary/40 hover:shadow-sb-sm' : 'opacity-60'}" 1249 ${isFolder ? `data-folder-id="${item.id}"` : ''} 1250 data-type="${item.type}"> 1251 <div class="flex flex-col items-center text-center gap-1.5"> 1252 <i data-lucide="${icon}" class="w-9 h-9 ${isFolder ? 'text-sb-warning' : 'text-st-primary/50'}"></i> 1253 <p class="text-xs font-medium text-theme-primary truncate w-full" title="${escapeAttr(item.name)}">${escapeHtml(item.name)}</p> 1254 ${isFolder ? `<p class="text-2xs text-theme-secondary">${item.childCount ?? 0} item${(item.childCount ?? 0) !== 1 ? 's' : ''}</p>` : ''} 1255 </div> 1256 </div>`; 1257 } 1258 html += '</div>'; 1259 itemsEl.innerHTML = html; 1260 } 1261 lucide.createIcons({ root: itemsEl }); 1262 } 1263 } catch (err) { 1264 if (importSignal?.aborted || this._importState !== state || state.projectId !== projectId || state.currentFolderId !== folderId) return; 1265 console.error('[OneDrive] Failed to load folder:', err); 1266 if (itemsEl) { 1267 itemsEl.innerHTML = '<div class="py-10 text-center text-sm text-sb-error">Failed to load folders</div>'; 1268 } 1269 } 1270 }, 1271 1272 async _executeImport() { 1273 const state = this._importState; 1274 if (!state?.downloadUrl || !state?.fileName || !state?.projectId) { 1275 window.showToast?.('Import Error', 'error', { description: 'Missing required fields' }); 1276 return; 1277 } 1278 1279 const saveBtn = document.getElementById('onedrive-import-confirm'); 1280 await runWithBusyButton(saveBtn, 'Saving...', async () => { 1281 await importToProjectFolder( 1282 state.downloadUrl, state.fileName, state.currentFolderId, state.projectId, 1283 { description: 'Uploaded from OneDrive' } 1284 ); 1285 1286 this._closeImportModal(); 1287 window.showToast?.('Saved to project folder', 'success', { description: state.fileName }); 1288 }, { errorTitle: 'Import Failed', errorLogPrefix: '[OneDrive] Import failed:' }); 1289 }, 1290}; 1291 1292// âââ Helpers ââââââââââââââââââââââââââââââââââââââââââââ 1293 1294function escapeAttr(str) { 1295 return (str || '').replace(/"/g, '"').replace(/'/g, '''); 1296} 1297 1298/** "Site Plan Rev C.pdf" â "Site Plan Rev C" â used to seed a created record's title. */ 1299function stripExtension(name) { 1300 if (!name) return ''; 1301 const dot = name.lastIndexOf('.'); 1302 return dot > 0 ? name.slice(0, dot) : name; 1303} 1304 1305function formatSize(bytes) { 1306 if (!bytes) return ''; 1307 if (bytes < 1024) return bytes + ' B'; 1308 if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; 1309 if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; 1310 return (bytes / 1073741824).toFixed(1) + ' GB'; 1311} 1312 1313/** 1314 * Format a date like real OneDrive: 1315 * - Current year: "March 1" 1316 * - Other years: "October 21, 2025" 1317 */ 1318function formatModifiedDate(isoStr) { 1319 if (!isoStr) return ''; 1320 const d = new Date(isoStr); 1321 if (isNaN(d.getTime())) return ''; 1322 const thisYear = new Date().getFullYear() === d.getFullYear(); 1323 1324 return d.toLocaleDateString('en-US', { 1325 month: 'long', 1326 day: 'numeric', 1327 ...(thisYear ? {} : { year: 'numeric' }), 1328 }); 1329} 1330
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.