mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-14 18:01:47 +01:00
First run of the web UI
This commit is contained in:
+166
-18
@@ -1,14 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
// Shared helpers of the review-dashboard pages (US-30 to US-36).
|
||||
// The pages carry no data; everything goes through authenticated API
|
||||
// calls, the admin session cookie riding along.
|
||||
// The pages carry no data; everything goes through authenticated API calls.
|
||||
|
||||
function redirectLogin() {
|
||||
window.location.href = "/_/";
|
||||
}
|
||||
var memoryToken = "";
|
||||
var loginPromise = null;
|
||||
|
||||
function storedToken() {
|
||||
if (memoryToken) return memoryToken;
|
||||
try {
|
||||
return localStorage.getItem("archivist.token") || "";
|
||||
} catch (e) {
|
||||
@@ -16,17 +15,110 @@ function storedToken() {
|
||||
}
|
||||
}
|
||||
|
||||
// api performs one authenticated call. A stale stored token is dropped
|
||||
// and the call retried once; an unauthenticated visitor is redirected to
|
||||
// the admin UI (US-30).
|
||||
function saveToken(token) {
|
||||
memoryToken = token;
|
||||
try {
|
||||
localStorage.setItem("archivist.token", token);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
memoryToken = "";
|
||||
try {
|
||||
localStorage.removeItem("archivist.token");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function requestLogin() {
|
||||
if (loginPromise) return loginPromise;
|
||||
|
||||
loginPromise = new Promise(function (resolve) {
|
||||
var dialog = document.createElement("dialog");
|
||||
dialog.className = "auth-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "auth-title");
|
||||
dialog.innerHTML = '<div class="auth-card">' +
|
||||
'<h2 id="auth-title">Connexion requise</h2>' +
|
||||
'<p>Connectez-vous avec un compte superutilisateur.</p>' +
|
||||
'<form class="auth-form">' +
|
||||
'<label>Adresse e-mail<input name="identity" type="email" autocomplete="username" required autofocus></label>' +
|
||||
'<label>Mot de passe<input name="password" type="password" autocomplete="current-password" required></label>' +
|
||||
'<p class="auth-error" role="alert" aria-live="polite"></p>' +
|
||||
'<div class="auth-actions"><a href="/_/">Administration PocketBase</a>' +
|
||||
'<button class="btn primary" type="submit">Se connecter</button></div>' +
|
||||
'</form></div>';
|
||||
|
||||
var form = dialog.querySelector("form");
|
||||
var error = dialog.querySelector(".auth-error");
|
||||
var submit = form.querySelector("button[type=submit]");
|
||||
form.addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
error.textContent = "";
|
||||
submit.disabled = true;
|
||||
try {
|
||||
var result = await rawFetch("POST", "/api/collections/_superusers/auth-with-password", {
|
||||
identity: form.elements.identity.value,
|
||||
password: form.elements.password.value
|
||||
}, "");
|
||||
var token = result.data && result.data.token;
|
||||
if (result.status !== 200 || !token) {
|
||||
error.textContent = result.data && result.data.message || "Identifiants invalides.";
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
saveToken(token);
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
resolve(token);
|
||||
} catch (err) {
|
||||
error.textContent = "Connexion impossible. Vérifiez le réseau puis réessayez.";
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
dialog.addEventListener("cancel", function (event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
});
|
||||
|
||||
var pending = loginPromise;
|
||||
return pending.finally(function () {
|
||||
loginPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
// api retries the blocked request after sign-in. A stale token is cleared
|
||||
// first; the server still enforces superuser access on every protected route.
|
||||
async function api(method, path, body) {
|
||||
var res = await rawFetch(method, path, body, storedToken());
|
||||
if (res.status === 401 && storedToken()) {
|
||||
try { localStorage.removeItem("archivist.token"); } catch (e) {}
|
||||
res = await rawFetch(method, path, body, "");
|
||||
var token = storedToken();
|
||||
var res = await rawFetch(method, path, body, token);
|
||||
if (res.status === 401 && token) {
|
||||
clearToken();
|
||||
token = "";
|
||||
res = await rawFetch(method, path, body, token);
|
||||
}
|
||||
if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) {
|
||||
redirectLogin();
|
||||
token = await requestLogin();
|
||||
res = await rawFetch(method, path, body, token);
|
||||
if (res.status === 401) clearToken();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// An iframe cannot attach the dashboard's Authorization header, so protected
|
||||
// binary resources use fetch and are assigned to the frame as object URLs.
|
||||
async function apiBlob(path) {
|
||||
var token = storedToken();
|
||||
var res = await rawBlob(path, token);
|
||||
if (res.status === 401 && token) {
|
||||
clearToken();
|
||||
token = "";
|
||||
res = await rawBlob(path, token);
|
||||
}
|
||||
if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) {
|
||||
token = await requestLogin();
|
||||
res = await rawBlob(path, token);
|
||||
if (res.status === 401) clearToken();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -46,6 +138,12 @@ async function rawFetch(method, path, body, token) {
|
||||
return { status: res.status, data: data };
|
||||
}
|
||||
|
||||
async function rawBlob(path, token) {
|
||||
var opts = { method: "GET", headers: {} };
|
||||
if (token) opts.headers["Authorization"] = token;
|
||||
return fetch(path, opts);
|
||||
}
|
||||
|
||||
function errMsg(res) {
|
||||
if (res && res.data && res.data.message) return res.data.message;
|
||||
if (res && res.data) {
|
||||
@@ -94,6 +192,60 @@ function addOption(sel, value, label) {
|
||||
sel.appendChild(o);
|
||||
}
|
||||
|
||||
function buildEnumControl(meta, vocab, value) {
|
||||
var current = value === null || value === undefined ? "" : String(value);
|
||||
var wrapper = document.createElement("div");
|
||||
wrapper.className = "enum-control";
|
||||
wrapper.dataset.field = meta.name;
|
||||
|
||||
var select = document.createElement("select");
|
||||
addOption(select, "", "—");
|
||||
(vocab || []).forEach(function (item) { addOption(select, item, item); });
|
||||
if (current && !(vocab || []).includes(current)) addOption(select, current, current);
|
||||
addOption(select, "__archivist_custom_value__", "Ajouter une valeur…");
|
||||
|
||||
var custom = document.createElement("input");
|
||||
custom.type = "text";
|
||||
custom.placeholder = "Saisir une valeur";
|
||||
custom.setAttribute("aria-label", "Nouvelle valeur pour " + meta.name);
|
||||
custom.hidden = true;
|
||||
|
||||
select.value = current;
|
||||
select.addEventListener("change", function () {
|
||||
var adding = select.value === "__archivist_custom_value__";
|
||||
custom.hidden = !adding;
|
||||
if (adding) {
|
||||
custom.value = "";
|
||||
custom.focus();
|
||||
} else {
|
||||
custom.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(wrapper, "value", {
|
||||
get: function () { return custom.hidden ? select.value : custom.value; },
|
||||
set: function (next) {
|
||||
var nextValue = next === null || next === undefined ? "" : String(next);
|
||||
var optionExists = Array.from(select.options).some(function (option) {
|
||||
return option.value === nextValue;
|
||||
});
|
||||
if (optionExists) {
|
||||
select.value = nextValue;
|
||||
custom.value = "";
|
||||
custom.hidden = true;
|
||||
} else {
|
||||
select.value = "__archivist_custom_value__";
|
||||
custom.value = nextValue;
|
||||
custom.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.appendChild(select);
|
||||
wrapper.appendChild(custom);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// buildValueControl builds the typed control of one field (US-33 bulk
|
||||
// edit, US-34 form, US-32 filters).
|
||||
function buildValueControl(meta, vocab, value) {
|
||||
@@ -119,11 +271,7 @@ function buildValueControl(meta, vocab, value) {
|
||||
el.value = value === true ? "true" : (value === false ? "false" : "");
|
||||
break;
|
||||
case "enum":
|
||||
el = document.createElement("select");
|
||||
addOption(el, "", "—");
|
||||
(vocab || []).forEach(function (v) { addOption(el, v, v); });
|
||||
el.value = s;
|
||||
break;
|
||||
return buildEnumControl(meta, vocab, value);
|
||||
default:
|
||||
el = document.createElement("input");
|
||||
el.type = "text";
|
||||
|
||||
@@ -27,6 +27,7 @@ async function init() {
|
||||
|
||||
initTheme("themeBtn");
|
||||
renderHead();
|
||||
loadPdf();
|
||||
var evs = await loadEvents();
|
||||
renderImportErrors(evs);
|
||||
renderHistory(evs);
|
||||
@@ -38,7 +39,7 @@ async function init() {
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
var q = "/api/collections/document_events/records?perPage=200&sort=timestamp&filter=" +
|
||||
var q = "/api/collections/document_events/records?perPage=200&sort=-timestamp&filter=" +
|
||||
encodeURIComponent("(document='" + docId + "')");
|
||||
var r = await api("GET", q);
|
||||
return (r.status === 200 && r.data.items) ? r.data.items : [];
|
||||
@@ -50,7 +51,31 @@ function renderHead() {
|
||||
var st = $("docstatus");
|
||||
st.textContent = record.status || "";
|
||||
st.className = "badge st-" + record.status;
|
||||
$("pdf").src = "/api/archivist/documents/" + docId + "/pdf";
|
||||
}
|
||||
|
||||
async function loadPdf() {
|
||||
var iframe = $("pdf");
|
||||
var error = $("pdferror");
|
||||
try {
|
||||
var response = await apiBlob("/api/archivist/documents/" + docId + "/pdf");
|
||||
if (!response.ok) {
|
||||
var data = null;
|
||||
try { data = await response.json(); } catch (e) {}
|
||||
error.textContent = "Aperçu indisponible : " + (data && data.message || "HTTP " + response.status);
|
||||
error.hidden = false;
|
||||
iframe.hidden = true;
|
||||
return;
|
||||
}
|
||||
var objectUrl = URL.createObjectURL(await response.blob());
|
||||
iframe.src = objectUrl;
|
||||
window.addEventListener("pagehide", function () { URL.revokeObjectURL(objectUrl); }, { once: true });
|
||||
} catch (e) {
|
||||
var reason = e && e.message ? e.message : String(e);
|
||||
console.error("PDF preview failed:", e);
|
||||
error.textContent = "Impossible de charger l'aperçu PDF : " + reason;
|
||||
error.hidden = false;
|
||||
iframe.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function payloadOf(ev) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="detailgrid">
|
||||
<section class="card pdfpane">
|
||||
<iframe id="pdf" title="Aperçu du document"></iframe>
|
||||
<div id="pdferror" class="errbox" hidden></div>
|
||||
</section>
|
||||
<section>
|
||||
<div id="importerrors" class="errbox" hidden></div>
|
||||
|
||||
@@ -161,12 +161,15 @@ tbody tr:hover { background: var(--accent); }
|
||||
|
||||
.pdfpane { padding: 0; overflow: hidden; height: 75vh; }
|
||||
.pdfpane iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--muted); }
|
||||
#pdf[hidden] { display: none; }
|
||||
#pdferror { margin: 1rem; }
|
||||
|
||||
.formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1rem; }
|
||||
@media (max-width: 700px) { .formgrid { grid-template-columns: 1fr; } }
|
||||
.fieldrow { display: flex; flex-direction: column; gap: .25rem; }
|
||||
.fieldrow > label { font-size: 12px; color: var(--muted-foreground); }
|
||||
.fieldrow input, .fieldrow select { width: 100%; }
|
||||
.enum-control { display: grid; gap: .35rem; }
|
||||
.mandatory { color: var(--destructive); }
|
||||
.proposal { font-size: 12px; color: var(--muted-foreground); display: flex; gap: .5rem; align-items: center; }
|
||||
.proposal[hidden] { display: none; }
|
||||
@@ -182,6 +185,24 @@ tbody tr:hover { background: var(--accent); }
|
||||
.errbox[hidden] { display: none; }
|
||||
.errbox ul { margin: .25rem 0 0 1rem; padding: 0; }
|
||||
|
||||
.auth-dialog {
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0;
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
.auth-dialog::backdrop { background: hsl(220 8% 10% / .52); }
|
||||
.auth-card { padding: 1.25rem; }
|
||||
.auth-card h2 { margin: 0; font-size: 1.1rem; }
|
||||
.auth-card > p { margin: .35rem 0 1rem; color: var(--muted-foreground); }
|
||||
.auth-form { display: grid; gap: .75rem; }
|
||||
.auth-form label { display: grid; gap: .25rem; color: var(--muted-foreground); }
|
||||
.auth-form input { width: 100%; }
|
||||
.auth-error { min-height: 1.3em; margin: 0; color: var(--destructive); }
|
||||
.auth-actions { display: flex; justify-content: space-between; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
|
||||
.targetname { font-size: 13px; color: var(--muted-foreground); margin-bottom: .75rem; word-break: break-all; }
|
||||
.actions { display: flex; gap: .75rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user