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
+13 -10
View File
@@ -1,26 +1,29 @@
import { getToken, clearToken } from './auth.mjs';
const BASE = '/api';
let _token = '';
export function setToken(t) { _token = t; }
export function getToken() { return _token; }
async function request(method, path, body, isForm = false) {
const headers = { Authorization: `Bearer ${_token}` };
if (!isForm && body) headers['Content-Type'] = 'application/json';
async function request(method, path, body, isFormData = false) {
const token = getToken();
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData && body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(BASE + path, {
method,
headers,
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
body: body !== undefined
? (isFormData ? body : JSON.stringify(body))
: undefined,
});
if (res.status === 401) {
clearToken();
window.dispatchEvent(new CustomEvent('auth:expired'));
return null;
throw new Error('Session expired');
}
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
if (!res.ok) throw new Error(json.error || `Request failed (${res.status})`);
return json.data;
}
+49
View File
@@ -0,0 +1,49 @@
const TOKEN_KEY = 'wo_token';
const USER_KEY = 'wo_user';
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || '';
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token);
try {
const payload = JSON.parse(atob(token.split('.')[1]));
localStorage.setItem(USER_KEY, JSON.stringify({
id: payload.uid,
username: payload.username,
email: payload.email,
displayName: payload.name,
role: payload.role,
}));
} catch { /* ignore decode errors */ }
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getUser() {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp && payload.exp * 1000 < Date.now()) {
clearToken();
return null;
}
const stored = localStorage.getItem(USER_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
const ROLE_LEVEL = { admin: 4, dispatcher: 3, field_tech: 2, viewer: 1 };
export function hasRole(minRole) {
const user = getUser();
if (!user) return false;
return (ROLE_LEVEL[user.role] || 0) >= (ROLE_LEVEL[minRole] || 0);
}
+40
View File
@@ -0,0 +1,40 @@
export function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
});
}
export function formatDateTime(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
export function formatRelative(iso) {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return formatDate(iso);
}
export function formatPhone(phone) {
if (!phone) return '—';
const d = phone.replace(/\D/g, '');
return d.length === 10
? `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`
: phone;
}
export function toLocalDatetime(iso) {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 16);
}
+33
View File
@@ -0,0 +1,33 @@
class Router {
#routes = [];
on(pattern, handler) {
const regex = new RegExp(
'^' + pattern.replace(/:([^/]+)/g, '(?<$1>[^/]+)') + '$'
);
this.#routes.push({ regex, handler });
return this;
}
navigate(path) {
location.hash = '#' + path;
}
start() {
const dispatch = () => {
const path = decodeURIComponent(location.hash.slice(1)) || '/';
for (const { regex, handler } of this.#routes) {
const m = path.match(regex);
if (m) { handler(m.groups || {}); return; }
}
};
window.addEventListener('hashchange', dispatch);
dispatch();
}
get current() {
return decodeURIComponent(location.hash.slice(1)) || '/';
}
}
export const router = new Router();
+10
View File
@@ -0,0 +1,10 @@
export function createStore(initial) {
let state = { ...initial };
const subscribers = new Set();
return {
get() { return { ...state }; },
set(updates) { state = { ...state, ...updates }; subscribers.forEach(fn => fn(state)); },
subscribe(fn) { subscribers.add(fn); return () => subscribers.delete(fn); },
};
}