/** * MainLayout — shell: sidebar + content area with view routing. */ import { Component } from './component.js' import { app } from '../lib/stores/app.js' import { search } from '../lib/stores/search.js' import { TRASH_GROUP_NAME } from '../lib/models/schema.js' import { emptyTrash } from '../lib/storage/db.js' import { Sidebar } from './Sidebar.js' import { EntryList } from './EntryList.js' import { EntryDetail } from './EntryDetail.js' import { EntryForm } from './EntryForm.js' import { ImportExport } from './ImportExport.js' import { SettingsDialog } from './SettingsDialog.js' export class MainLayout extends Component { sidebarOpen = false viewMode = 'list' // 'list' | 'detail' | 'form' | 'settings' selectedEntryId = null showEmptyTrashConfirm = false emptyingTrash = false // Child component instances _sidebar = null _importExport = null _contentComponent = null get isTrashView() { return search.activeGroupId === 'trash' } mount() { super.mount() // Subscribe to app lock — unmount everything and emit event this.subscribe(app, 'isUnlocked', (unlocked) => { if (!unlocked) this.emitLock() }) return this } render() { this.el = this.ce('div', { className: 'app-shell' }) // Mobile header this.el.appendChild(this.ce('div', { className: 'mobile-header' }, this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'menu-btn', textContent: '☰ Menu' }), this.ce('span', { className: 'mobile-title', textContent: 'Password Vault' }), this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'mobile-lock-btn', title: 'Lock', textContent: '🔒' }), )) // Sidebar const aside = this.ce('aside', { className: 'sidebar', id: 'sidebar' }) this.el.appendChild(aside) // Main content const main = this.ce('main', { className: 'main-content' }, this.ce('div', { className: 'top-bar', id: 'top-bar' }), this.ce('div', { className: 'content-area', id: 'content-area' }), ) this.el.appendChild(main) // Wire mobile header buttons this.on(this.q('#menu-btn'), 'click', () => { this.sidebarOpen = !this.sidebarOpen; this.#updateSidebar() }) this.on(this.q('#mobile-lock-btn'), 'click', () => app.lockVault()) // Mount sidebar this._sidebar = new Sidebar(this.q('#sidebar')) this._sidebar.mount() // Initial render this.#renderTopBar() this.#navigate() return this.el } #updateSidebar() { const sidebar = this.q('#sidebar') if (sidebar) { sidebar.classList.toggle('open', this.sidebarOpen) } // Overlay let overlay = this.q('.sidebar-overlay') if (this.sidebarOpen && !overlay) { overlay = this.ce('button', { className: 'sidebar-overlay', 'aria-label': 'Close menu' }) this.on(overlay, 'click', () => { this.sidebarOpen = false; this.#updateSidebar() }) this.el.appendChild(overlay) } else if (!this.sidebarOpen && overlay) { overlay.remove() } } #renderTopBar() { const topBar = this.q('#top-bar') if (!topBar) return topBar.innerHTML = '' // Back button if (this.viewMode !== 'list') { const backBtn = this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'back-btn', textContent: '← Back' }) this.on(backBtn, 'click', () => this.#handleBack()) topBar.appendChild(backBtn) } // Title const titleDiv = this.ce('div', { className: 'top-bar-title' }) let titleText switch (this.viewMode) { case 'list': titleText = this.isTrashView ? TRASH_GROUP_NAME : 'All Entries'; break case 'detail': titleText = 'Entry Details'; break case 'form': titleText = this.selectedEntryId ? 'Edit Entry' : 'New Entry'; break case 'settings': titleText = 'Settings'; break default: titleText = 'Password Vault' } titleDiv.appendChild(this.ce('h1', { textContent: titleText })) topBar.appendChild(titleDiv) // Actions const actions = this.ce('div', { className: 'top-bar-actions' }) if (this.viewMode === 'list' && this.isTrashView) { actions.appendChild(this.ce('button', { className: 'btn btn-danger btn-sm', id: 'empty-trash-btn', disabled: this.emptyingTrash, textContent: this.emptyingTrash ? 'Emptying...' : '🗑 Empty Trash', })) } if (this.viewMode === 'list' && !this.isTrashView) { actions.appendChild(this.ce('button', { className: 'btn btn-primary btn-sm', id: 'new-entry-btn', textContent: '+ New Entry', })) } // ImportExport buttons if (!this._importExport) { this._importExport = new ImportExport(actions) this._importExport.mount() } actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'settings-btn', title: 'Settings', textContent: '⚙️' })) actions.appendChild(this.ce('button', { className: 'btn btn-ghost btn-sm', id: 'lock-btn', title: 'Lock vault', textContent: '🔒' })) topBar.appendChild(actions) // Wire action buttons const emptyTrashBtn = this.q('#empty-trash-btn') if (emptyTrashBtn) this.on(emptyTrashBtn, 'click', () => { this.showEmptyTrashConfirm = true; this.#renderEmptyTrashModal() }) const newEntryBtn = this.q('#new-entry-btn') if (newEntryBtn) this.on(newEntryBtn, 'click', () => this.#goForm(null)) const settingsBtn = this.q('#settings-btn') if (settingsBtn) this.on(settingsBtn, 'click', () => this.#goSettings()) const lockBtn = this.q('#lock-btn') if (lockBtn) this.on(lockBtn, 'click', () => app.lockVault()) } #navigate() { // Destroy previous content component if (this._contentComponent) { this._contentComponent.destroy() this._contentComponent = null } const contentArea = this.q('#content-area') if (!contentArea) return switch (this.viewMode) { case 'list': this._contentComponent = new EntryList(contentArea, { onSelect: (id) => this.#goDetail(id), onAdd: () => this.#goForm(null), }) break case 'detail': if (this.selectedEntryId) { this._contentComponent = new EntryDetail(contentArea, { entryId: this.selectedEntryId, onEdit: (id) => this.#goForm(id), onBack: () => this.#goList(), }) } break case 'form': this._contentComponent = new EntryForm(contentArea, { entryId: this.selectedEntryId, onSave: () => this.#goList(), onCancel: () => this.#handleBack(), }) break case 'settings': this._contentComponent = new SettingsDialog(contentArea, { onBack: () => this.#goList(), }) break } if (this._contentComponent) this._contentComponent.mount() this.#renderTopBar() } #goList() { this.viewMode = 'list' this.selectedEntryId = null this.sidebarOpen = false this.#updateSidebar() this.#navigate() } #goDetail(entryId) { this.selectedEntryId = entryId this.viewMode = 'detail' this.sidebarOpen = false this.#updateSidebar() this.#navigate() } #goForm(entryId) { this.selectedEntryId = entryId this.viewMode = 'form' this.sidebarOpen = false this.#updateSidebar() this.#navigate() } #goSettings() { this.viewMode = 'settings' this.sidebarOpen = false this.#updateSidebar() this.#navigate() } #handleBack() { this.#goList() } #renderEmptyTrashModal() { const existing = this.q('.ml-modal-overlay') if (existing) existing.remove() const overlay = this.ce('div', { className: 'ml-modal-overlay', role: 'presentation' }) const modal = this.ce('div', { className: 'ml-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Empty trash confirmation', tabindex: '-1' }) modal.appendChild(this.ce('h3', { textContent: 'Empty Trash' })) modal.appendChild(this.ce('p', { textContent: 'Permanently delete all entries from the trash? This cannot be undone.' })) const actions = this.ce('div', { className: 'ml-modal-actions' }, this.ce('button', { className: 'btn btn-danger', id: 'confirm-empty-trash', disabled: this.emptyingTrash, textContent: this.emptyingTrash ? 'Emptying...' : 'Yes, empty trash' }), this.ce('button', { className: 'btn btn-ghost', id: 'cancel-empty-trash', textContent: 'Cancel' }), ) modal.appendChild(actions) overlay.appendChild(modal) this.el.appendChild(overlay) this.on(overlay, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() }) this.on(modal, 'click', (e) => e.stopPropagation()) const confirmBtn = overlay.querySelector('#confirm-empty-trash') if (confirmBtn) this.on(confirmBtn, 'click', () => this.#handleEmptyTrash()) const cancelBtn = overlay.querySelector('#cancel-empty-trash') if (cancelBtn) this.on(cancelBtn, 'click', () => { this.showEmptyTrashConfirm = false; overlay.remove() }) } async #handleEmptyTrash() { this.emptyingTrash = true try { await emptyTrash() search.activeGroupId = 'all' this.showEmptyTrashConfirm = false this.#goList() } catch (e) { console.error('Failed to empty trash:', e) } this.emptyingTrash = false } emitLock() { this.destroy() } destroy() { if (this._sidebar) this._sidebar.destroy() if (this._importExport) this._importExport.destroy() if (this._contentComponent) this._contentComponent.destroy() super.destroy() } }