Add user database migration, core reusable components, and layout structure

This commit is contained in:
2026-05-16 18:54:23 -04:00
parent c7df396a83
commit e132c7a580
33 changed files with 2348 additions and 398 deletions
+104
View File
@@ -0,0 +1,104 @@
import { getUser, clearToken } from '../../lib/auth.mjs';
const BREADCRUMBS = {
'/': ['Dashboard'],
'/work-orders': ['Work Orders'],
'/work-orders/new': ['Work Orders', 'New'],
'/registry/people': ['Resources', 'People'],
'/registry/vehicles': ['Resources', 'Vehicles'],
'/registry/equipment': ['Resources', 'Equipment'],
'/registry/materials': ['Resources', 'Materials'],
'/reports': ['Reports'],
'/users': ['Admin', 'Users'],
'/settings': ['Admin', 'Settings'],
};
class AppTopbar extends HTMLElement {
#menuOpen = false;
connectedCallback() {
this.#render();
window.addEventListener('hashchange', () => this.#render());
document.addEventListener('click', e => {
if (!this.contains(e.target) && this.#menuOpen) {
this.#menuOpen = false;
this.#render();
}
});
}
#render() {
const user = getUser();
const path = decodeURIComponent(location.hash.slice(1)) || '/';
const crumb = BREADCRUMBS[path] || [path.split('/').filter(Boolean).map(s => s.replace(/-/g, ' ')).join(' ')];
const initials = (user?.displayName || user?.username || 'U')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
this.innerHTML = `
<style>
app-topbar { position: relative; }
.breadcrumb { flex: 1; display: flex; align-items: center; gap: .4rem; font-size: .875rem; }
.crumb { color: var(--text-muted); }
.crumb:last-child { color: var(--text); font-weight: 600; }
.sep { color: var(--border); }
.right { display: flex; align-items: center; gap: .5rem; margin-left: auto; }
.avatar-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; gap: .5rem; padding: .3rem .5rem; border-radius: var(--radius); transition: background .15s; }
.avatar-btn:hover { background: var(--surface-2); }
.avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--teal); color: #fff; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 700; flex-shrink: 0; }
.user-name { font-size: .813rem; font-weight: 500; color: var(--text); }
.dropdown { position: absolute; top: calc(100% + .25rem); right: 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); min-width: 180px; z-index: 100; overflow: hidden; }
.dd-item { display: flex; align-items: center; gap: .6rem; padding: .65rem 1rem; font-size: .875rem; color: var(--text); cursor: pointer; border: none; background: none; width: 100%; text-align: left; transition: background .15s; }
.dd-item:hover { background: var(--surface-2); }
.dd-item.danger { color: var(--danger); }
.dd-divider { height: 1px; background: var(--border); margin: .25rem 0; }
.mobile-menu-btn { display: none; background: none; border: none; cursor: pointer; color: var(--text); padding: .4rem; }
@media (max-width: 768px) { .mobile-menu-btn { display: flex; } .breadcrumb { font-size: .813rem; } }
</style>
<button class="mobile-menu-btn" id="mobile-menu">
<i data-lucide="menu" style="width:22px;height:22px"></i>
</button>
<nav class="breadcrumb">
${crumb.map((c, i) => `
<span class="crumb">${c}</span>
${i < crumb.length - 1 ? '<span class="sep">/</span>' : ''}
`).join('')}
</nav>
<div class="right">
<button class="avatar-btn" id="user-menu-btn">
<div class="avatar">${initials}</div>
<span class="user-name">${user?.displayName || user?.username || ''}</span>
<i data-lucide="chevron-down" style="width:14px;height:14px;color:var(--text-muted)"></i>
</button>
</div>
${this.#menuOpen ? `
<div class="dropdown" id="dropdown">
<button class="dd-item">
<i data-lucide="user" style="width:15px;height:15px"></i> My Profile
</button>
<div class="dd-divider"></div>
<button class="dd-item danger" id="dd-logout">
<i data-lucide="log-out" style="width:15px;height:15px"></i> Sign Out
</button>
</div>` : ''}`;
if (window.lucide) lucide.createIcons({ nodes: [this] });
this.querySelector('#user-menu-btn')?.addEventListener('click', e => {
e.stopPropagation();
this.#menuOpen = !this.#menuOpen;
this.#render();
});
this.querySelector('#dd-logout')?.addEventListener('click', () => {
clearToken();
window.dispatchEvent(new CustomEvent('auth:logout'));
});
this.querySelector('#mobile-menu')?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('sidebar:toggle'));
});
}
}
customElements.define('app-topbar', AppTopbar);