299 lines
9.4 KiB
JavaScript
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()
|
|
}
|
|
}
|