mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-14 09:51:47 +01:00
First run of the web UI
This commit is contained in:
+1
-2
@@ -275,8 +275,7 @@ lidateValue`, `Render`, `ResolveConflict`, `DiffSnapshots`, `AppendEvent`), no p
|
|||||||
- Validation: performed from the same interface by changing the status to `validated`; the hook appends the `validated` event, and any other status change is refused. A `pending` file can only leave the review queue through a full correction from the dashboard (US-36).
|
- Validation: performed from the same interface by changing the status to `validated`; the hook appends the `validated` event, and any other status change is refused. A `pending` file can only leave the review queue through a full correction from the dashboard (US-36).
|
||||||
- Deletion: deleting a `documents` record from the same interface first deletes the S3 object at `file_key` (US-24); on S3 failure the deletion is refused and the record stays. The cascade delete of the `document_events` relation removes the history with the record; with versioning the object stays recoverable via its delete marker.
|
- Deletion: deleting a `documents` record from the same interface first deletes the S3 object at `file_key` (US-24); on S3 failure the deletion is refused and the record stays. The cascade delete of the `document_events` relation removes the history with the record; with versioning the object stays recoverable via its delete marker.
|
||||||
|
|
||||||
The static pages themselves carry no data and are public by nature; every fetch is authenticated against the PocketBase API and an unauthenticated visitor is redirected to the admin UI (`/_/`). Authentication: single ac
|
The static pages themselves carry no data and are public by nature; every fetch is authenticated against the PocketBase API. An unauthenticated visitor is prompted to sign in with the superuser account; the dashboard exchanges those credentials through PocketBase's standard auth endpoint, stores the returned token locally, and retries the blocked request. The API continues to enforce superuser access. The configuration is reloaded only on restart; there is no runtime reload.
|
||||||
count via PocketBase's standard auth. The configuration is reloaded only on restart; there is no runtime reload.
|
|
||||||
### 7.5 Design (Beszel reference)
|
### 7.5 Design (Beszel reference)
|
||||||
|
|
||||||
The dashboard's visual design takes Beszel (https://github.com/henrygd/beszel) as its reference. Beszel is built on shadcn/ui + Tailwind with semantic HSL CSS variables and a light/dark pair; archivist reproduces its palette, token names and layout feel in a single hand-written stylesheet (`pb_public/styles.css`) — no Tailwind, no CSS framework, consistent with the no-build-toolchain constraint of section 7.
|
The dashboard's visual design takes Beszel (https://github.com/henrygd/beszel) as its reference. Beszel is built on shadcn/ui + Tailwind with semantic HSL CSS variables and a light/dark pair; archivist reproduces its palette, token names and layout feel in a single hand-written stylesheet (`pb_public/styles.css`) — no Tailwind, no CSS framework, consistent with the no-build-toolchain constraint of section 7.
|
||||||
|
|||||||
+11
-24
@@ -213,8 +213,7 @@ func startApp(image string) error {
|
|||||||
if _, err := run("docker", args...); err != nil {
|
if _, err := run("docker", args...); err != nil {
|
||||||
return fmt.Errorf("app container: %w", err)
|
return fmt.Errorf("app container: %w", err)
|
||||||
}
|
}
|
||||||
if err := retry(60*time.Second, checkHealth);
|
if err := retry(60*time.Second, checkHealth); err != nil {
|
||||||
err != nil {
|
|
||||||
if out, _ := run("docker", "logs", appC); out != "" {
|
if out, _ := run("docker", "logs", appC); out != "" {
|
||||||
return fmt.Errorf("app never became healthy; logs:\n%s", out)
|
return fmt.Errorf("app never became healthy; logs:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -300,8 +299,7 @@ func firstHexToken(s string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func lineValue(s, prefix string) strin
|
func lineValue(s, prefix string) string {
|
||||||
g {
|
|
||||||
for _, line := range strings.Split(s, "\n") {
|
for _, line := range strings.Split(s, "\n") {
|
||||||
t := strings.TrimSpace(line)
|
t := strings.TrimSpace(line)
|
||||||
if strings.HasPrefix(t, prefix) {
|
if strings.HasPrefix(t, prefix) {
|
||||||
@@ -378,8 +376,7 @@ func listRecords(t *testing.T, collection, filter, sort string) []map[string]any
|
|||||||
q.Set("perPage", "200")
|
q.Set("perPage", "200")
|
||||||
status, out := call(t, http.MethodGet, "/api/collections/"+collection+"/records?"+q.Encode(), nil)
|
status, out := call(t, http.MethodGet, "/api/collections/"+collection+"/records?"+q.Encode(), nil)
|
||||||
if status != 200 {
|
if status != 200 {
|
||||||
t.Fatalf("list %s: status %d: %v
|
t.Fatalf("list %s: status %d: %v", collection, status, out)
|
||||||
", collection, status, out)
|
|
||||||
}
|
}
|
||||||
items, _ := out["items"].([]any)
|
items, _ := out["items"].([]any)
|
||||||
recs := make([]map[string]any, 0, len(items))
|
recs := make([]map[string]any, 0, len(items))
|
||||||
@@ -456,8 +453,7 @@ func s3Exists(t *testing.T, key string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- in-memory test PDFs (no fixtures, no ge
|
// --- in-memory test PDFs (no fixtures, no generator dependency) ----------
|
||||||
nerator dependency) ----------
|
|
||||||
|
|
||||||
// makePDF builds a minimal valid single-page PDF whose page text is the
|
// makePDF builds a minimal valid single-page PDF whose page text is the
|
||||||
// given lines. The classification outcome is the proof that the
|
// given lines. The classification outcome is the proof that the
|
||||||
@@ -507,8 +503,7 @@ func putPDF(t *testing.T, key string, lines ...string) string {
|
|||||||
t.Fatalf("PutObject %s: %v", key, err)
|
t.Fatalf("PutObject %s: %v", key, err)
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
return hex.EncodeToString(su
|
return hex.EncodeToString(sum[:])
|
||||||
m[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the config.jsonc mounted into the image under test -----------------
|
// --- the config.jsonc mounted into the image under test -----------------
|
||||||
@@ -572,8 +567,7 @@ func TestE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// PocketBase writes log batches with a small delay.
|
// PocketBase writes log batches with a small delay.
|
||||||
var fou
|
var found bool
|
||||||
nd bool
|
|
||||||
deadline := time.Now().Add(15 * time.Second)
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
for !found && time.Now().Before(deadline) {
|
for !found && time.Now().Before(deadline) {
|
||||||
for _, l := range pbLogs(t) {
|
for _, l := range pbLogs(t) {
|
||||||
@@ -631,8 +625,7 @@ nd bool
|
|||||||
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
|
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
|
||||||
}
|
}
|
||||||
if !s3Exists(t, want) {
|
if !s3Exists(t, want) {
|
||||||
t.Fatalf("object %s missing from S3", wan
|
t.Fatalf("object %s missing from S3", want)
|
||||||
t)
|
|
||||||
}
|
}
|
||||||
if s3Exists(t, "inbox/statement-1.pdf") {
|
if s3Exists(t, "inbox/statement-1.pdf") {
|
||||||
t.Fatal("source object still in inbox/")
|
t.Fatal("source object still in inbox/")
|
||||||
@@ -687,8 +680,7 @@ t)
|
|||||||
p := eventPayloadOfType(t, eventsOf(t, doc["id"].(string)), "imported")
|
p := eventPayloadOfType(t, eventsOf(t, doc["id"].(string)), "imported")
|
||||||
errs, ok := p["errors"].([]any)
|
errs, ok := p["errors"].([]any)
|
||||||
if !ok || len(errs) == 0 {
|
if !ok || len(errs) == 0 {
|
||||||
t.Fatalf("
|
t.Fatalf("want errors in the imported event, got %v", p)
|
||||||
want errors in the imported event, got %v", p)
|
|
||||||
}
|
}
|
||||||
after, _ := p["after"].(map[string]any)
|
after, _ := p["after"].(map[string]any)
|
||||||
if after["documentType"] != "Releve de compte" {
|
if after["documentType"] != "Releve de compte" {
|
||||||
@@ -737,8 +729,7 @@ want errors in the imported event, got %v", p)
|
|||||||
t.Fatalf("%s must not appear in a field-edit diff: %v", s, before)
|
t.Fatalf("%s must not appear in a field-edit diff: %v", s, before)
|
||||||
}
|
}
|
||||||
if _, has := after[s]; has {
|
if _, has := after[s]; has {
|
||||||
t.Fatalf("%s must not appear in a field-edi
|
t.Fatalf("%s must not appear in a field-edit diff: %v", s, after)
|
||||||
t diff: %v", s, after)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -788,8 +779,7 @@ t diff: %v", s, after)
|
|||||||
map[string]any{"event_type": "corrected"}); status < 400 {
|
map[string]any{"event_type": "corrected"}); status < 400 {
|
||||||
t.Fatalf("PATCH on document_events must be refused, got %d", status)
|
t.Fatalf("PATCH on document_events must be refused, got %d", status)
|
||||||
}
|
}
|
||||||
if status, _ := call(t, http.MethodDelete, "/api/collections/docum
|
if status, _ := call(t, http.MethodDelete, "/api/collections/document_events/records/"+id, nil); status < 400 {
|
||||||
ent_events/records/"+id, nil); status < 400 {
|
|
||||||
t.Fatalf("DELETE on document_events must be refused, got %d", status)
|
t.Fatalf("DELETE on document_events must be refused, got %d", status)
|
||||||
}
|
}
|
||||||
if n := len(listRecords(t, "document_events", "", "")); n != len(evs) {
|
if n := len(listRecords(t, "document_events", "", "")); n != len(evs) {
|
||||||
@@ -820,7 +810,6 @@ ent_events/records/"+id, nil); status < 400 {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// 9. Recovery (US-11): a stuck record - file_key still under inbox/,
|
// 9. Recovery (US-11): a stuck record - file_key still under inbox/,
|
||||||
// created directly through the API - is completed by the next pass.
|
// created directly through the API - is completed by the next pass.
|
||||||
t.Run("recovery: a stuck filing is completed by the next pass", func(t *testing.T) {
|
t.Run("recovery: a stuck filing is completed by the next pass", func(t *testing.T) {
|
||||||
@@ -840,8 +829,7 @@ ent_events/records/"+id, nil); status < 400 {
|
|||||||
t.Fatalf("stuck record creation: %d %v", status, out)
|
t.Fatalf("stuck record creation: %d %v", status, out)
|
||||||
}
|
}
|
||||||
poll(t) // the recovery runs at the start of the pass
|
poll(t) // the recovery runs at the start of the pass
|
||||||
want := "files/BRED/202
|
want := "files/BRED/2026-02_Releve de compte_Thomas.pdf"
|
||||||
6-02_Releve de compte_Thomas.pdf"
|
|
||||||
doc := oneRecord(t, "documents", "(sha256='"+hash+"')")
|
doc := oneRecord(t, "documents", "(sha256='"+hash+"')")
|
||||||
if doc["file_key"] != want {
|
if doc["file_key"] != want {
|
||||||
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
|
t.Fatalf("file_key: %v, want %v", doc["file_key"], want)
|
||||||
@@ -1150,4 +1138,3 @@ ent_events/records/"+id, nil); status < 400 {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ganigeorgiev.com/pocketbase/pocketbase/apis"
|
||||||
"ganigeorgiev.com/pocketbase/pocketbase/core"
|
"ganigeorgiev.com/pocketbase/pocketbase/core"
|
||||||
"github.com/napnap75/archivist/internal/config"
|
"github.com/napnap75/archivist/internal/config"
|
||||||
"github.com/napnap75/archivist/internal/storage"
|
"github.com/napnap75/archivist/internal/storage"
|
||||||
@@ -23,6 +26,9 @@ import (
|
|||||||
// PocketBase auth, but these routes bypass the record API and its hooks.
|
// PocketBase auth, but these routes bypass the record API and its hooks.
|
||||||
func BindRoutes(app core.App, cfg *config.Config, s3 *storage.Client) {
|
func BindRoutes(app core.App, cfg *config.Config, s3 *storage.Client) {
|
||||||
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||||
|
if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
|
||||||
|
e.Router.GET("/{path...}", apis.Static(os.DirFS("pb_public"), true))
|
||||||
|
}
|
||||||
e.Router.GET("/api/archivist/config", func(re *core.RequestEvent) error {
|
e.Router.GET("/api/archivist/config", func(re *core.RequestEvent) error {
|
||||||
if !re.HasSuperuserAuth() {
|
if !re.HasSuperuserAuth() {
|
||||||
return re.ForbiddenError("superusers only", nil)
|
return re.ForbiddenError("superusers only", nil)
|
||||||
@@ -139,10 +145,10 @@ func fileName(key string) string {
|
|||||||
// dryRunResponse is the single response shape of the two exclusive
|
// dryRunResponse is the single response shape of the two exclusive
|
||||||
// dry-run modes (US-35).
|
// dry-run modes (US-35).
|
||||||
type dryRunResponse struct {
|
type dryRunResponse struct {
|
||||||
Fields map[string]any "json:"fields""
|
Fields map[string]any `json:"fields"`
|
||||||
Template string "json:"template""
|
Template string `json:"template"`
|
||||||
Rendered string "json:"rendered""
|
Rendered string `json:"rendered"`
|
||||||
Errors []string "json:"errors""
|
Errors []string `json:"errors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDryRun(re *core.RequestEvent, cfg *config.Config) error {
|
func handleDryRun(re *core.RequestEvent, cfg *config.Config) error {
|
||||||
@@ -270,7 +276,7 @@ func handleCorrect(re *core.RequestEvent, cfg *config.Config, s3 *storage.Client
|
|||||||
if err := tx.Save(record); err != nil {
|
if err := tx.Save(record); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return AppendEvent(tx, record.Id, "corrected", before, after)
|
return AppendEvent(tx, record.Id, "corrected", before, after, nil)
|
||||||
})
|
})
|
||||||
if txErr != nil {
|
if txErr != nil {
|
||||||
if refiled {
|
if refiled {
|
||||||
|
|||||||
+2
-4
@@ -65,8 +65,7 @@ func LoadEnvConfig() (*EnvConfig, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configPathFromArgs(args []strin
|
func configPathFromArgs(args []string, defaultPath string) (string, error) {
|
||||||
g, defaultPath string) (string, error) {
|
|
||||||
var configPath string
|
var configPath string
|
||||||
flags := pflag.NewFlagSet("archivist", pflag.ContinueOnError)
|
flags := pflag.NewFlagSet("archivist", pflag.ContinueOnError)
|
||||||
flags.SetInterspersed(true)
|
flags.SetInterspersed(true)
|
||||||
@@ -117,8 +116,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// US-01: versioning recommendation, via the PocketBase logger
|
// US-01: versioning recommendation, via the PocketBase logger
|
||||||
// (visible in the admin dashboard, Das
|
// (visible in the admin dashboard, Dashboard > Logs).
|
||||||
hboard > Logs).
|
|
||||||
if s3.StartupWarning != "" {
|
if s3.StartupWarning != "" {
|
||||||
app.Logger().Warn(s3.StartupWarning, "bucket", envCfg.S3Bucket)
|
app.Logger().Warn(s3.StartupWarning, "bucket", envCfg.S3Bucket)
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-18
@@ -1,14 +1,13 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Shared helpers of the review-dashboard pages (US-30 to US-36).
|
// Shared helpers of the review-dashboard pages (US-30 to US-36).
|
||||||
// The pages carry no data; everything goes through authenticated API
|
// The pages carry no data; everything goes through authenticated API calls.
|
||||||
// calls, the admin session cookie riding along.
|
|
||||||
|
|
||||||
function redirectLogin() {
|
var memoryToken = "";
|
||||||
window.location.href = "/_/";
|
var loginPromise = null;
|
||||||
}
|
|
||||||
|
|
||||||
function storedToken() {
|
function storedToken() {
|
||||||
|
if (memoryToken) return memoryToken;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("archivist.token") || "";
|
return localStorage.getItem("archivist.token") || "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -16,17 +15,110 @@ function storedToken() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// api performs one authenticated call. A stale stored token is dropped
|
function saveToken(token) {
|
||||||
// and the call retried once; an unauthenticated visitor is redirected to
|
memoryToken = token;
|
||||||
// the admin UI (US-30).
|
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) {
|
async function api(method, path, body) {
|
||||||
var res = await rawFetch(method, path, body, storedToken());
|
var token = storedToken();
|
||||||
if (res.status === 401 && storedToken()) {
|
var res = await rawFetch(method, path, body, token);
|
||||||
try { localStorage.removeItem("archivist.token"); } catch (e) {}
|
if (res.status === 401 && token) {
|
||||||
res = await rawFetch(method, path, body, "");
|
clearToken();
|
||||||
|
token = "";
|
||||||
|
res = await rawFetch(method, path, body, token);
|
||||||
}
|
}
|
||||||
if (res.status === 401 || (res.status === 403 && path.indexOf("/api/archivist/") === 0)) {
|
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;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -46,6 +138,12 @@ async function rawFetch(method, path, body, token) {
|
|||||||
return { status: res.status, data: data };
|
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) {
|
function errMsg(res) {
|
||||||
if (res && res.data && res.data.message) return res.data.message;
|
if (res && res.data && res.data.message) return res.data.message;
|
||||||
if (res && res.data) {
|
if (res && res.data) {
|
||||||
@@ -94,6 +192,60 @@ function addOption(sel, value, label) {
|
|||||||
sel.appendChild(o);
|
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
|
// buildValueControl builds the typed control of one field (US-33 bulk
|
||||||
// edit, US-34 form, US-32 filters).
|
// edit, US-34 form, US-32 filters).
|
||||||
function buildValueControl(meta, vocab, value) {
|
function buildValueControl(meta, vocab, value) {
|
||||||
@@ -119,11 +271,7 @@ function buildValueControl(meta, vocab, value) {
|
|||||||
el.value = value === true ? "true" : (value === false ? "false" : "");
|
el.value = value === true ? "true" : (value === false ? "false" : "");
|
||||||
break;
|
break;
|
||||||
case "enum":
|
case "enum":
|
||||||
el = document.createElement("select");
|
return buildEnumControl(meta, vocab, value);
|
||||||
addOption(el, "", "—");
|
|
||||||
(vocab || []).forEach(function (v) { addOption(el, v, v); });
|
|
||||||
el.value = s;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
el = document.createElement("input");
|
el = document.createElement("input");
|
||||||
el.type = "text";
|
el.type = "text";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function init() {
|
|||||||
|
|
||||||
initTheme("themeBtn");
|
initTheme("themeBtn");
|
||||||
renderHead();
|
renderHead();
|
||||||
|
loadPdf();
|
||||||
var evs = await loadEvents();
|
var evs = await loadEvents();
|
||||||
renderImportErrors(evs);
|
renderImportErrors(evs);
|
||||||
renderHistory(evs);
|
renderHistory(evs);
|
||||||
@@ -38,7 +39,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
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 + "')");
|
encodeURIComponent("(document='" + docId + "')");
|
||||||
var r = await api("GET", q);
|
var r = await api("GET", q);
|
||||||
return (r.status === 200 && r.data.items) ? r.data.items : [];
|
return (r.status === 200 && r.data.items) ? r.data.items : [];
|
||||||
@@ -50,7 +51,31 @@ function renderHead() {
|
|||||||
var st = $("docstatus");
|
var st = $("docstatus");
|
||||||
st.textContent = record.status || "";
|
st.textContent = record.status || "";
|
||||||
st.className = "badge st-" + 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) {
|
function payloadOf(ev) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="detailgrid">
|
<div class="detailgrid">
|
||||||
<section class="card pdfpane">
|
<section class="card pdfpane">
|
||||||
<iframe id="pdf" title="Aperçu du document"></iframe>
|
<iframe id="pdf" title="Aperçu du document"></iframe>
|
||||||
|
<div id="pdferror" class="errbox" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div id="importerrors" class="errbox" hidden></div>
|
<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 { padding: 0; overflow: hidden; height: 75vh; }
|
||||||
.pdfpane iframe { width: 100%; height: 100%; border: 0; display: block; background: var(--muted); }
|
.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; }
|
.formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1rem; }
|
||||||
@media (max-width: 700px) { .formgrid { grid-template-columns: 1fr; } }
|
@media (max-width: 700px) { .formgrid { grid-template-columns: 1fr; } }
|
||||||
.fieldrow { display: flex; flex-direction: column; gap: .25rem; }
|
.fieldrow { display: flex; flex-direction: column; gap: .25rem; }
|
||||||
.fieldrow > label { font-size: 12px; color: var(--muted-foreground); }
|
.fieldrow > label { font-size: 12px; color: var(--muted-foreground); }
|
||||||
.fieldrow input, .fieldrow select { width: 100%; }
|
.fieldrow input, .fieldrow select { width: 100%; }
|
||||||
|
.enum-control { display: grid; gap: .35rem; }
|
||||||
.mandatory { color: var(--destructive); }
|
.mandatory { color: var(--destructive); }
|
||||||
.proposal { font-size: 12px; color: var(--muted-foreground); display: flex; gap: .5rem; align-items: center; }
|
.proposal { font-size: 12px; color: var(--muted-foreground); display: flex; gap: .5rem; align-items: center; }
|
||||||
.proposal[hidden] { display: none; }
|
.proposal[hidden] { display: none; }
|
||||||
@@ -182,6 +185,24 @@ tbody tr:hover { background: var(--accent); }
|
|||||||
.errbox[hidden] { display: none; }
|
.errbox[hidden] { display: none; }
|
||||||
.errbox ul { margin: .25rem 0 0 1rem; padding: 0; }
|
.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; }
|
.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; }
|
.actions { display: flex; gap: .75rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
- Given the image is built and running, when I open `http://<server>:8090/`, then the dashboard pages are served from `pb_public/` — plain HTML/CSS/JS, no build toolchain, dependencies vendored locally, nothing loaded from a CDN.
|
- Given the image is built and running, when I open `http://<server>:8090/`, then the dashboard pages are served from `pb_public/` — plain HTML/CSS/JS, no build toolchain, dependencies vendored locally, nothing loaded from a CDN.
|
||||||
- Given a page is opened, when it renders, then it carries no document data by itself; everything is loaded through authenticated API calls.
|
- Given a page is opened, when it renders, then it carries no document data by itself; everything is loaded through authenticated API calls.
|
||||||
- Given an unauthenticated visitor, when a page requests data, then the API refuses it and the page redirects to the admin UI (`/_/`).
|
- Given an unauthenticated visitor, when a page requests data, then the page prompts for superuser credentials, authenticates through PocketBase, and retries the request; the API refuses access until authentication succeeds.
|
||||||
- Given the pages, when they are styled, then they follow the Beszel design reference (section 7.5): same semantic HSL tokens, light/dark pair switched by a class on `<html>`, cards, hairline borders, sticky table headers — reproduced in one hand-written stylesheet, no Tailwind or CSS framework.
|
- Given the pages, when they are styled, then they follow the Beszel design reference (section 7.5): same semantic HSL tokens, light/dark pair switched by a class on `<html>`, cards, hairline borders, sticky table headers — reproduced in one hand-written stylesheet, no Tailwind or CSS framework.
|
||||||
|
|
||||||
### US-31 — Expose the configuration metadata
|
### US-31 — Expose the configuration metadata
|
||||||
|
|||||||
Reference in New Issue
Block a user