Add user database migration, core reusable components, and layout structure
This commit is contained in:
+13
-10
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user