299 lines
9.4 KiB
JavaScript

/**
* 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()
}
}