v_1.5_patch_0.2
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
'use strict';
|
||||
|
||||
const FOOTER_TEXT = '© 2026 Martinhal IT - Joao Vaz - Version 1.5 Patch 0.2';
|
||||
|
||||
// Dates are stored as ISO (YYYY-MM-DD) so they sort correctly, but are always
|
||||
// shown to people as DD-MM-YYYY.
|
||||
function fmtDate(value) {
|
||||
if (!value) return '';
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return m ? `${m[3]}-${m[2]}-${m[1]}` : String(value);
|
||||
}
|
||||
|
||||
function fmtDateTime(value) {
|
||||
if (!value) return '';
|
||||
const str = String(value).replace('T', ' ').replace('Z', '');
|
||||
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ ](\d{2}:\d{2})(:\d{2})?)?/);
|
||||
if (!m) return str;
|
||||
const date = `${m[3]}-${m[2]}-${m[1]}`;
|
||||
return m[4] ? `${date} ${m[4]}${m[5] || ''}` : date;
|
||||
}
|
||||
|
||||
// Accepts DD-MM-YYYY (what people type) or YYYY-MM-DD (native date inputs)
|
||||
// and returns ISO for the API, or '' when the value is not a real date.
|
||||
function toIsoDate(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
let iso = '';
|
||||
const dmy = v.match(/^(\d{2})-(\d{2})-(\d{4})$/);
|
||||
if (dmy) iso = `${dmy[3]}-${dmy[2]}-${dmy[1]}`;
|
||||
else if (/^\d{4}-\d{2}-\d{2}$/.test(v)) iso = v;
|
||||
else return '';
|
||||
const [y, mo, da] = iso.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, mo - 1, da));
|
||||
return (dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === da) ? iso : '';
|
||||
}
|
||||
|
||||
const ACCESS_DURATIONS = [
|
||||
{ key: '24h', label: 'Valid for 24 hours' },
|
||||
{ key: '15d', label: 'Valid for 15 days' },
|
||||
{ key: '30d', label: 'Valid for 30 days' },
|
||||
{ key: 'forever', label: 'Valid forever' },
|
||||
];
|
||||
|
||||
const ICON = {
|
||||
storage: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/></svg>',
|
||||
caret: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>',
|
||||
calendar: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>',
|
||||
legislation: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 4v16M5 8h14M7 8l-3 6a3 3 0 0 0 6 0L7 8zM17 8l-3 6a3 3 0 0 0 6 0l-3-6zM9 20h6"/></svg>',
|
||||
version: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.5 6H13a2.5 2.5 0 0 1 2.5 2.5v1M8.5 18H13a2.5 2.5 0 0 0 2.5-2.5v-1"/></svg>',
|
||||
lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>',
|
||||
view: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
manage: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h5l2 2h11v9a2 2 0 0 1-2 2H3Z"/><path d="M3 7V5a2 2 0 0 1 2-2h4l2 2"/></svg>',
|
||||
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4Z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>',
|
||||
account: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
|
||||
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h6l2 2h10v9a2 2 0 0 1-2 2H3Z"/></svg>',
|
||||
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 2h8l4 4v16H6Z"/><path d="M14 2v4h4"/></svg>',
|
||||
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l7 3v6c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
|
||||
download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0 4-4m-4 4-4-4"/><path d="M4 21h16"/></svg>',
|
||||
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>',
|
||||
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
|
||||
edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h4L18 10l-4-4L4 16Z"/><path d="m14 6 4 4"/></svg>',
|
||||
move: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 9 2 12l3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/></svg>',
|
||||
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15V3m0 0 4 4m-4-4L8 7"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
|
||||
};
|
||||
|
||||
async function api(url, opts = {}) {
|
||||
const o = Object.assign({ headers: {} }, opts);
|
||||
if (o.body && !(o.body instanceof FormData)) {
|
||||
o.headers['Content-Type'] = 'application/json';
|
||||
o.body = JSON.stringify(o.body);
|
||||
}
|
||||
const res = await fetch(url, o);
|
||||
if (res.status === 401) { location.href = '/login.html'; throw new Error('Not authenticated'); }
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const data = ct.includes('application/json') ? await res.json() : await res.text();
|
||||
if (!res.ok) throw new Error((data && data.error) || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
// Upload with progress. fetch() cannot report upload progress, so this uses
|
||||
// XMLHttpRequest and calls onProgress(percentOrNull) as bytes go out.
|
||||
function apiUpload(url, formData, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (onProgress) onProgress(e.lengthComputable ? Math.round((e.loaded / e.total) * 100) : null);
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 401) { location.href = '/login.html'; return reject(new Error('Not authenticated')); }
|
||||
let data = null;
|
||||
try { data = JSON.parse(xhr.responseText); } catch (_) { /* non-JSON */ }
|
||||
if (xhr.status >= 200 && xhr.status < 300) return resolve(data || {});
|
||||
reject(new Error((data && data.error) || `Upload failed (${xhr.status})`));
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload.')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, kind = '') {
|
||||
let box = document.querySelector('.toasts');
|
||||
if (!box) { box = document.createElement('div'); box.className = 'toasts'; document.body.appendChild(box); }
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast ' + kind;
|
||||
t.textContent = msg;
|
||||
box.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3600);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; n = Number(n);
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function modal(title, bodyHtml, opts = {}) {
|
||||
const back = document.createElement('div');
|
||||
back.className = 'modal-back';
|
||||
back.innerHTML = `<div class="modal ${opts.lg ? 'lg' : ''}">
|
||||
<div class="modal-head">${esc(title)}</div>
|
||||
<div class="modal-body">${bodyHtml}</div>
|
||||
<div class="modal-foot"></div></div>`;
|
||||
const foot = back.querySelector('.modal-foot');
|
||||
(opts.buttons || [{ label: 'Close' }]).forEach((b) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn ' + (b.className || '');
|
||||
btn.textContent = b.label;
|
||||
btn.onclick = () => { if (!b.onClick || b.onClick(back) !== false) close(); };
|
||||
foot.appendChild(btn);
|
||||
});
|
||||
function close() { back.remove(); }
|
||||
back.addEventListener('mousedown', (e) => { if (e.target === back && !opts.sticky) close(); });
|
||||
document.body.appendChild(back);
|
||||
return { el: back, close };
|
||||
}
|
||||
|
||||
function prompt2(title, fields, onSubmit, submitLabel = 'Save') {
|
||||
const body = fields.map((f) => `
|
||||
<div class="field">
|
||||
<label>${esc(f.label)}</label>
|
||||
${f.type === 'select'
|
||||
? `<select data-k="${f.key}">${f.options.map((o) => `<option value="${esc(o.value)}" ${o.value == f.value ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`
|
||||
: `<input type="text" data-k="${f.key}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}">`}
|
||||
</div>`).join('');
|
||||
const m = modal(title, body, {
|
||||
buttons: [
|
||||
{ label: 'Cancel' },
|
||||
{ label: submitLabel, className: 'btn-primary', onClick: (back) => {
|
||||
const vals = {};
|
||||
back.querySelectorAll('[data-k]').forEach((el) => vals[el.dataset.k] = el.value.trim());
|
||||
Promise.resolve(onSubmit(vals)).then((r) => { if (r === false) {} });
|
||||
return false; // keep open; caller closes
|
||||
} },
|
||||
],
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
async function buildShell(active) {
|
||||
let me;
|
||||
try { me = (await api('/api/auth/me')).user; }
|
||||
catch { location.href = '/login.html'; return null; }
|
||||
|
||||
const isAdmin = me.role === 'admin';
|
||||
const link = (href, icon, label, key) =>
|
||||
`<a href="${href}" class="${active === key ? 'active' : ''}">${ICON[icon]}<span>${label}</span></a>`;
|
||||
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'app';
|
||||
shell.innerHTML = `
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<a class="brand-logo" href="/view-data.html" aria-label="ISDSS home">
|
||||
<img src="/assets/isdss-logo.png" alt="ISDSS — Internal Surveillance Data Storage System">
|
||||
</a>
|
||||
<div class="mark fallback-mark" hidden><span class="logo">M</span> ISDSS</div>
|
||||
<div class="tag">Martinhal IT · secure portal</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
${link('/view-data.html', 'view', 'View Data', 'view')}
|
||||
${link('/version-control.html', 'version', 'Version Control', 'version')}
|
||||
${link('/legislation.html', 'legislation', 'Legislation', 'legislation')}
|
||||
${link('/account.html', 'account', 'My Account', 'account')}
|
||||
${isAdmin ? `<div class="section-label">Administration</div>
|
||||
${link('/data-management.html', 'manage', 'Data Management', 'manage')}
|
||||
${link('/storage.html', 'storage', 'Storage', 'storage')}
|
||||
${link('/logs.html', 'logs', 'Logs', 'logs')}` : ''}
|
||||
</nav>
|
||||
<div class="side-user">
|
||||
<div class="u-identity">
|
||||
<span class="u-avatar">${me.avatar
|
||||
? `<img src="${esc(me.avatar)}" alt="">`
|
||||
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`}</span>
|
||||
<span class="u-text">
|
||||
<span class="u-name">${esc(me.username)}</span>
|
||||
<span class="u-role">${esc(me.role)}${me.mfa_enabled ? ' · MFA on' : ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button id="logoutBtn">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="content" id="content"></div>
|
||||
<div class="footer">${FOOTER_TEXT}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(shell);
|
||||
|
||||
// If the logo file is ever missing, fall back to the original wordmark
|
||||
// rather than showing a broken image.
|
||||
const logoImg = shell.querySelector('.brand-logo img');
|
||||
if (logoImg) {
|
||||
logoImg.addEventListener('error', () => {
|
||||
const link = shell.querySelector('.brand-logo');
|
||||
const fallback = shell.querySelector('.fallback-mark');
|
||||
if (link) link.hidden = true;
|
||||
if (fallback) fallback.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
shell.querySelector('#logoutBtn').onclick = async () => {
|
||||
await api('/api/auth/logout', { method: 'POST' });
|
||||
location.href = '/login.html';
|
||||
};
|
||||
return { me, isAdmin, content: shell.querySelector('#content') };
|
||||
}
|
||||
Reference in New Issue
Block a user