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
+41
View File
@@ -0,0 +1,41 @@
const TABS = [
{ label: 'Dashboard', href: '/', icon: 'layout-dashboard' },
{ label: 'Work Orders', href: '/work-orders', icon: 'clipboard-list' },
{ label: 'People', href: '/registry/people', icon: 'users' },
{ label: 'Reports', href: '/reports', icon: 'bar-chart-2' },
];
class AppMobileNav extends HTMLElement {
connectedCallback() {
this.#render();
window.addEventListener('hashchange', () => this.#render());
}
#render() {
const path = decodeURIComponent(location.hash.slice(1)) || '/';
this.innerHTML = `
<style>
app-mobile-nav a {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: .2rem; text-decoration: none;
color: var(--text-muted); font-size: .65rem; font-weight: 500;
padding: .4rem; position: relative; transition: color .15s;
}
app-mobile-nav a.active { color: var(--teal); }
app-mobile-nav a.active::after {
content: ''; position: absolute; bottom: 0; left: 25%; right: 25%;
height: 2px; background: var(--teal); border-radius: 1px;
}
app-mobile-nav a i { width: 20px; height: 20px; }
</style>
${TABS.map(t => {
const active = t.href === '/' ? path === '/' : path.startsWith(t.href);
return `<a href="#${t.href}" class="${active ? 'active' : ''}">
<i data-lucide="${t.icon}"></i>
<span>${t.label}</span>
</a>`;
}).join('')}`;
if (window.lucide) lucide.createIcons({ nodes: [this] });
}
}
customElements.define('app-mobile-nav', AppMobileNav);
+26
View File
@@ -0,0 +1,26 @@
class AppRoot extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<app-sidebar></app-sidebar>
<div class="app-shell">
<app-topbar></app-topbar>
<main id="main-content"></main>
</div>
<app-mobile-nav></app-mobile-nav>`;
// Apply saved collapse state
if (localStorage.getItem('sidebar-collapsed') === 'true') {
this.classList.add('collapsed');
}
}
setPage(html) {
const main = document.getElementById('main-content');
if (main) {
main.innerHTML = html;
main.scrollTop = 0;
if (window.lucide) lucide.createIcons({ nodes: [main] });
}
}
}
customElements.define('app-root', AppRoot);
+141
View File
@@ -0,0 +1,141 @@
import { getUser, clearToken } from '../../lib/auth.mjs';
import { router } from '../../lib/router.mjs';
const NAV = [
{ label: 'Dashboard', href: '/', icon: 'layout-dashboard', section: 'main' },
{ label: 'Work Orders', href: '/work-orders', icon: 'clipboard-list', section: 'main' },
{ label: 'People', href: '/registry/people', icon: 'users', section: 'resources' },
{ label: 'Vehicles', href: '/registry/vehicles', icon: 'truck', section: 'resources' },
{ label: 'Equipment', href: '/registry/equipment', icon: 'wrench', section: 'resources' },
{ label: 'Materials', href: '/registry/materials', icon: 'package', section: 'resources' },
{ label: 'Reports', href: '/reports', icon: 'bar-chart-2', section: 'operations' },
];
const ADMIN_NAV = [
{ label: 'Users', href: '/users', icon: 'user-cog' },
{ label: 'Settings', href: '/settings', icon: 'settings' },
];
class AppSidebar extends HTMLElement {
#collapsed = localStorage.getItem('sidebar-collapsed') === 'true';
connectedCallback() { this.#render(); this.#listenRoute(); }
#listenRoute() {
window.addEventListener('hashchange', () => this.#updateActive());
}
#toggle() {
this.#collapsed = !this.#collapsed;
localStorage.setItem('sidebar-collapsed', this.#collapsed);
const appRoot = document.querySelector('app-root');
if (appRoot) appRoot.classList.toggle('collapsed', this.#collapsed);
this.#render();
}
#updateActive() {
const path = decodeURIComponent(location.hash.slice(1)) || '/';
this.querySelectorAll('.nav-item').forEach(el => {
const href = el.dataset.href;
const active = href === '/' ? path === '/' : path.startsWith(href);
el.classList.toggle('active', active);
});
}
#navItemHTML(item, currentPath) {
const active = item.href === '/' ? currentPath === '/' : currentPath.startsWith(item.href);
return `
<a class="nav-item${active ? ' active' : ''}" data-href="${item.href}" href="#${item.href}" title="${item.label}">
<i data-lucide="${item.icon}"></i>
<span class="label">${item.label}</span>
</a>`;
}
#render() {
const user = getUser();
const path = decodeURIComponent(location.hash.slice(1)) || '/';
const c = this.#collapsed;
const appRoot = document.querySelector('app-root');
if (appRoot) appRoot.classList.toggle('collapsed', c);
const initials = (user?.displayName || user?.username || 'U')
.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
this.innerHTML = `
<style>
app-sidebar { overflow: hidden; }
.top { display: flex; align-items: center; justify-content: space-between; padding: 1rem; min-height: 56px; border-bottom: 1px solid rgba(255,255,255,.06); }
.brand { display: flex; align-items: center; gap: .6rem; text-decoration: none; }
.brand-icon { width: 28px; height: 28px; background: var(--teal); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.brand-icon svg { color: #fff; }
.brand-name { font-size: .9rem; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; transition: opacity .2s; }
.collapsed .brand-name { opacity: 0; width: 0; }
.toggle-btn { background: none; border: none; cursor: pointer; color: rgba(255,255,255,.5); padding: .3rem; border-radius: 4px; display: flex; flex-shrink: 0; transition: color .15s; }
.toggle-btn:hover { color: #fff; }
.nav { flex: 1; overflow-y: auto; padding: .5rem 0; }
.section-label { padding: .5rem 1rem .25rem; font-size: .65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,.3); white-space: nowrap; overflow: hidden; transition: opacity .2s; }
.collapsed .section-label { opacity: 0; }
.nav-item { display: flex; align-items: center; gap: .75rem; padding: .55rem 1rem; color: rgba(255,255,255,.7); text-decoration: none; font-size: .875rem; font-weight: 500; transition: background .15s, color .15s; cursor: pointer; border-left: 3px solid transparent; }
.nav-item:hover { background: var(--sidebar-hover); color: #fff; text-decoration: none; }
.nav-item.active { background: rgba(10,126,164,.2); color: #fff; border-left-color: var(--teal); }
.nav-item i { width: 18px; height: 18px; flex-shrink: 0; }
.nav-item .label { white-space: nowrap; overflow: hidden; transition: opacity .2s; }
.collapsed .nav-item .label { opacity: 0; width: 0; }
.collapsed .nav-item { justify-content: center; padding: .55rem; }
.divider { height: 1px; background: rgba(255,255,255,.06); margin: .5rem 0; }
.bottom { border-top: 1px solid rgba(255,255,255,.06); padding: .75rem 1rem; display: flex; align-items: center; gap: .6rem; }
.avatar { width: 32px; height: 32px; 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-info { flex: 1; overflow: hidden; transition: opacity .2s; }
.collapsed .user-info { opacity: 0; width: 0; }
.user-name { font-size: .813rem; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-role { font-size: .7rem; color: rgba(255,255,255,.45); white-space: nowrap; }
.logout-btn { background: none; border: none; cursor: pointer; color: rgba(255,255,255,.4); padding: .3rem; border-radius: 4px; display: flex; flex-shrink: 0; transition: color .15s; }
.logout-btn:hover { color: #fff; }
.collapsed .logout-btn { display: none; }
</style>
<div class="top ${c ? 'collapsed' : ''}">
<a class="brand" href="#/">
<div class="brand-icon"><i data-lucide="clipboard-list" style="width:16px;height:16px"></i></div>
<span class="brand-name">Work Orders</span>
</a>
<button class="toggle-btn" id="toggle-btn" title="Toggle sidebar">
<i data-lucide="${c ? 'chevrons-right' : 'chevrons-left'}" style="width:18px;height:18px"></i>
</button>
</div>
<nav class="nav ${c ? 'collapsed' : ''}">
<div class="section-label">Main</div>
${NAV.filter(n => n.section === 'main').map(n => this.#navItemHTML(n, path)).join('')}
<div class="divider"></div>
<div class="section-label">Resources</div>
${NAV.filter(n => n.section === 'resources').map(n => this.#navItemHTML(n, path)).join('')}
<div class="divider"></div>
<div class="section-label">Operations</div>
${NAV.filter(n => n.section === 'operations').map(n => this.#navItemHTML(n, path)).join('')}
${user?.role === 'admin' ? `
<div class="divider"></div>
<div class="section-label">Admin</div>
${ADMIN_NAV.map(n => this.#navItemHTML(n, path)).join('')}` : ''}
</nav>
<div class="bottom ${c ? 'collapsed' : ''}">
<div class="avatar">${initials}</div>
<div class="user-info">
<div class="user-name">${user?.displayName || user?.username || 'User'}</div>
<div class="user-role">${user?.role || ''}</div>
</div>
<button class="logout-btn" id="logout-btn" title="Sign out">
<i data-lucide="log-out" style="width:16px;height:16px"></i>
</button>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [this] });
this.querySelector('#toggle-btn')?.addEventListener('click', () => this.#toggle());
this.querySelector('#logout-btn')?.addEventListener('click', () => {
clearToken();
window.dispatchEvent(new CustomEvent('auth:logout'));
});
}
}
customElements.define('app-sidebar', AppSidebar);
+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);