Seed from NAS prod snapshot 20260130-190531
This commit is contained in:
8
src/components/BuildStamp.astro
Normal file
8
src/components/BuildStamp.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import { SITE_RELEASE, BUILD_TIME_ISO } from "../lib/buildInfo";
|
||||
---
|
||||
<footer class="build-stamp" aria-label="Informations d’édition">
|
||||
<small>
|
||||
Édition web — release <strong>{SITE_RELEASE}</strong> · build <time datetime={BUILD_TIME_ISO}>{BUILD_TIME_ISO}</time>
|
||||
</small>
|
||||
</footer>
|
||||
52
src/components/Callout.astro
Normal file
52
src/components/Callout.astro
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
const {
|
||||
kind = "note", // note | definition | these | objection | limite | scene | procedure
|
||||
title,
|
||||
} = Astro.props;
|
||||
|
||||
const labels = {
|
||||
note: "Note",
|
||||
definition: "Définition",
|
||||
these: "Thèse",
|
||||
objection: "Objection",
|
||||
limite: "Limite",
|
||||
scene: "Scène",
|
||||
procedure: "Procédure",
|
||||
};
|
||||
|
||||
const label = labels[kind] ?? "Note";
|
||||
---
|
||||
<section class={`callout callout-${kind}`}>
|
||||
<div class="callout-head">
|
||||
<span class="callout-badge">{label}</span>
|
||||
{title ? <strong class="callout-title">{title}</strong> : null}
|
||||
</div>
|
||||
<div class="callout-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.callout {
|
||||
border: 1px solid rgba(127,127,127,0.35);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
margin: 14px 0;
|
||||
background: rgba(127,127,127,0.06);
|
||||
}
|
||||
.callout-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.callout-badge {
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(127,127,127,0.45);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.callout-title { font-weight: 700; }
|
||||
.callout-body :global(p:last-child) { margin-bottom: 0; }
|
||||
</style>
|
||||
165
src/components/EditionToc.astro
Normal file
165
src/components/EditionToc.astro
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const { currentSlug } = Astro.props;
|
||||
|
||||
const entries = (await getCollection("archicratie"))
|
||||
.filter((e) => e.slug.startsWith("archicrat-ia/"))
|
||||
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
|
||||
|
||||
const href = (slug) => `/archicratie/${slug}/`;
|
||||
---
|
||||
|
||||
<nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
|
||||
<div class="toc-global__head">
|
||||
<div class="toc-global__title">Table des matières</div>
|
||||
</div>
|
||||
|
||||
<ol class="toc-global__list">
|
||||
{entries.map((e) => {
|
||||
const active = e.slug === currentSlug;
|
||||
return (
|
||||
<li class={`toc-item ${active ? "is-active" : ""}`}>
|
||||
<a class="toc-link" href={href(e.slug)} aria-current={active ? "page" : undefined}>
|
||||
<span class="toc-link__row">
|
||||
{active ? (
|
||||
<span class="toc-active-indicator" aria-hidden="true">👉</span>
|
||||
) : (
|
||||
<span class="toc-active-spacer" aria-hidden="true"></span>
|
||||
)}
|
||||
|
||||
<span class="toc-link__title">{e.data.title}</span>
|
||||
|
||||
{active && (
|
||||
<span class="toc-badge" aria-label="Chapitre en cours">
|
||||
En cours
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{active && <span class="toc-underline" aria-hidden="true"></span>}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.toc-global{
|
||||
border: 1px solid rgba(127,127,127,0.22);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(127,127,127,0.06);
|
||||
}
|
||||
|
||||
.toc-global__head{
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed rgba(127,127,127,0.25);
|
||||
}
|
||||
|
||||
.toc-global__title{
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .2px;
|
||||
opacity: .88;
|
||||
}
|
||||
|
||||
/* On garde <ol> mais on neutralise tout marker/numéro */
|
||||
.toc-global__list{
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toc-global__list li::marker{ content: ""; }
|
||||
|
||||
.toc-item{ margin: 6px 0; }
|
||||
|
||||
.toc-link{
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
border-radius: 14px;
|
||||
padding: 8px 10px;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.toc-link:hover{
|
||||
background: rgba(127,127,127,0.10);
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toc-link__row{
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toc-active-indicator{
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toc-active-spacer{
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.toc-link__title{
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toc-badge{
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .2px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127,127,127,0.30);
|
||||
background: rgba(127,127,127,0.10);
|
||||
opacity: .92;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-item.is-active .toc-link{
|
||||
background: rgba(127,127,127,0.12);
|
||||
}
|
||||
|
||||
.toc-item.is-active .toc-link__title{
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.toc-underline{
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: rgba(127,127,127,0.35);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toc-global__list{
|
||||
max-height: 44vh;
|
||||
overflow: auto;
|
||||
padding-right: 8px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-color-scheme: dark){
|
||||
.toc-global{ background: rgba(255,255,255,0.04); }
|
||||
.toc-link:hover{ background: rgba(255,255,255,0.06); }
|
||||
.toc-item.is-active .toc-link{ background: rgba(255,255,255,0.06); }
|
||||
.toc-badge{ background: rgba(255,255,255,0.06); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const active = document.querySelector(".toc-global .toc-item.is-active");
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
})();
|
||||
</script>
|
||||
35
src/components/LevelToggle.astro
Normal file
35
src/components/LevelToggle.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
const { initialLevel = 1 } = Astro.props;
|
||||
---
|
||||
<div class="level-toggle" role="group" aria-label="Niveau de lecture">
|
||||
<button type="button" class="lvl-btn" data-level="1" aria-pressed="true">Niveau 1</button>
|
||||
<button type="button" class="lvl-btn" data-level="2" aria-pressed="false">Niveau 2</button>
|
||||
<button type="button" class="lvl-btn" data-level="3" aria-pressed="false">Niveau 3</button>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const KEY = "archicratie.readingLevel";
|
||||
const buttons = Array.from(document.querySelectorAll(".lvl-btn"));
|
||||
|
||||
function apply(level) {
|
||||
document.body.setAttribute("data-reading-level", String(level));
|
||||
buttons.forEach((b) => b.setAttribute("aria-pressed", b.dataset.level === String(level) ? "true" : "false"));
|
||||
}
|
||||
|
||||
// Valeur par défaut : si rien n'est stocké, on met 1 (citoyen).
|
||||
// Si JS est absent/casse, le site reste lisible (tout s'affiche).
|
||||
const stored = Number(localStorage.getItem(KEY));
|
||||
const level = (stored === 1 || stored === 2 || stored === 3) ? stored : 1;
|
||||
|
||||
apply(level);
|
||||
|
||||
buttons.forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
const lvl = Number(b.dataset.level);
|
||||
localStorage.setItem(KEY, String(lvl));
|
||||
apply(lvl);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
182
src/components/LocalToc.astro
Normal file
182
src/components/LocalToc.astro
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
const { headings } = Astro.props;
|
||||
|
||||
// H2/H3 seulement
|
||||
const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
||||
---
|
||||
|
||||
{items.length > 0 && (
|
||||
<nav class="toc-local" aria-label="Dans ce chapitre">
|
||||
<div class="toc-local__title">Dans ce chapitre</div>
|
||||
|
||||
<ol class="toc-local__list">
|
||||
{items.map((h) => (
|
||||
<li
|
||||
class={`toc-local__item d${h.depth}`}
|
||||
data-toc-item
|
||||
data-depth={h.depth}
|
||||
data-id={h.slug}
|
||||
>
|
||||
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
|
||||
{h.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
function init() {
|
||||
const toc = document.querySelector(".toc-local");
|
||||
if (!toc) return;
|
||||
|
||||
const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]"));
|
||||
if (!itemEls.length) return;
|
||||
|
||||
const ordered = itemEls
|
||||
.map((li) => {
|
||||
const a = li.querySelector("a[data-toc-link]");
|
||||
const id = li.getAttribute("data-id") || a?.dataset.slug || "";
|
||||
const depth = Number(li.getAttribute("data-depth") || "0");
|
||||
const el = id ? document.getElementById(id) : null; // span.details-anchor OU h3[id]
|
||||
return (a && id && el) ? { id, depth, li, a, el } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (!ordered.length) return;
|
||||
|
||||
const clear = () => {
|
||||
for (const t of ordered) {
|
||||
t.a.removeAttribute("aria-current");
|
||||
t.li.classList.remove("is-current");
|
||||
}
|
||||
};
|
||||
|
||||
const openDetailsIfNeeded = (el) => {
|
||||
const d = el?.closest?.("details");
|
||||
if (d && !d.open) d.open = true;
|
||||
};
|
||||
|
||||
let current = "";
|
||||
|
||||
const setCurrent = (id) => {
|
||||
if (!id || id === current) return;
|
||||
const t = ordered.find((x) => x.id === id);
|
||||
if (!t) return;
|
||||
|
||||
current = id;
|
||||
|
||||
clear();
|
||||
openDetailsIfNeeded(t.el);
|
||||
|
||||
t.a.setAttribute("aria-current", "true");
|
||||
t.li.classList.add("is-current");
|
||||
|
||||
// ✅ IMPORTANT: plus de scrollIntoView ici
|
||||
// sinon ça scroll l'aside pendant le scroll du reading => TOC global “disparaît”.
|
||||
};
|
||||
|
||||
const computeActive = () => {
|
||||
const visible = ordered.filter((t) => {
|
||||
const d = t.el.closest?.("details");
|
||||
if (d && !d.open) {
|
||||
// Si l'élément est dans <summary>, il reste visible même details fermé
|
||||
const inSummary = !!t.el.closest?.("summary");
|
||||
if (!inSummary) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!visible.length) return;
|
||||
|
||||
const y = 120;
|
||||
let best = null;
|
||||
|
||||
for (const t of visible) {
|
||||
const r = t.el.getBoundingClientRect();
|
||||
if (!r || (r.width === 0 && r.height === 0)) continue;
|
||||
if (r.top <= y) best = t;
|
||||
else break;
|
||||
}
|
||||
|
||||
if (!best) best = visible[0];
|
||||
setCurrent(best.id);
|
||||
};
|
||||
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
ticking = false;
|
||||
computeActive();
|
||||
});
|
||||
};
|
||||
|
||||
const syncFromHash = () => {
|
||||
const id = (location.hash || "").slice(1);
|
||||
if (!id) { computeActive(); return; }
|
||||
|
||||
const el = document.getElementById(id);
|
||||
if (el) openDetailsIfNeeded(el);
|
||||
setCurrent(id);
|
||||
};
|
||||
|
||||
toc.addEventListener("click", (ev) => {
|
||||
const a = ev.target?.closest?.("a[data-toc-link]");
|
||||
if (!a) return;
|
||||
const id = a.dataset.slug || "";
|
||||
if (!id) return;
|
||||
|
||||
const el = document.getElementById(id);
|
||||
if (el) openDetailsIfNeeded(el);
|
||||
|
||||
setCurrent(id);
|
||||
});
|
||||
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onScroll);
|
||||
window.addEventListener("hashchange", syncFromHash);
|
||||
|
||||
syncFromHash();
|
||||
onScroll();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
window.addEventListener("DOMContentLoaded", init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toc-local{margin-top:12px;border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:12px}
|
||||
.toc-local__title{font-size:13px;opacity:.85;margin-bottom:8px}
|
||||
|
||||
.toc-local__list{list-style:none;margin:0;padding:0}
|
||||
.toc-local__item::marker{content:""}
|
||||
.toc-local__item{margin:6px 0}
|
||||
.toc-local__item.d3{margin-left:12px;opacity:.9}
|
||||
|
||||
.toc-local__item.is-current > a{
|
||||
font-weight: 750;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toc-local a{
|
||||
display:inline-block;
|
||||
max-width:100%;
|
||||
text-decoration:none;
|
||||
}
|
||||
.toc-local a:hover{ text-decoration: underline; }
|
||||
.toc-local__list{
|
||||
max-height: 44vh;
|
||||
overflow: auto;
|
||||
padding-right: 8px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
</style>
|
||||
273
src/components/ProposeModal.astro
Normal file
273
src/components/ProposeModal.astro
Normal file
@@ -0,0 +1,273 @@
|
||||
<dialog id="propose-modal" aria-label="Qualifier la proposition" data-step="1">
|
||||
<form method="dialog" class="box">
|
||||
<header class="hd">
|
||||
<h2>Qualifier la proposition</h2>
|
||||
<button value="cancel" class="x" aria-label="Fermer">✕</button>
|
||||
</header>
|
||||
|
||||
<!-- STEP 1 -->
|
||||
<section class="step step-1" aria-label="Choisir le type">
|
||||
<p class="sub">
|
||||
D’abord : quel type de ticket veux-tu ouvrir ?
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<button type="button" data-kind="correction">✍️ Correction (rédaction / clarté)</button>
|
||||
<button type="button" data-kind="fact">🔎 Vérification factuelle / sources</button>
|
||||
</div>
|
||||
|
||||
<footer class="ft">
|
||||
<small>Étape 1/2 — <kbd>Esc</kbd> pour fermer.</small>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- STEP 2 -->
|
||||
<section class="step step-2" aria-label="Choisir la catégorie">
|
||||
<p class="sub">
|
||||
Optionnel : choisis une intention (pour tri & traitement éditorial). Sinon, continue sans préciser.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<button type="button" data-category="cat/style">Style / lisibilité</button>
|
||||
<button type="button" data-category="cat/lexique">Lexique / terminologie</button>
|
||||
<button type="button" data-category="cat/argument">Argument / structure</button>
|
||||
<button type="button" data-category="cat/redondance">Redondance</button>
|
||||
<button type="button" data-category="cat/source">Source / vérification</button>
|
||||
<button type="button" data-category="">Continuer sans préciser</button>
|
||||
</div>
|
||||
|
||||
<footer class="ft row">
|
||||
<button type="button" class="back" data-back="1">← Retour</button>
|
||||
<small>Étape 2/2 — <kbd>Esc</kbd> pour fermer.</small>
|
||||
</footer>
|
||||
</section>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog { border: none; padding: 0; border-radius: 18px; width: min(760px, calc(100vw - 2rem)); }
|
||||
dialog::backdrop { background: rgba(0,0,0,.55); }
|
||||
|
||||
.box { padding: 1rem 1rem .85rem; }
|
||||
.hd { display:flex; align-items:center; justify-content:space-between; gap:.75rem; }
|
||||
.hd h2 { margin:0; font-size:1.1rem; }
|
||||
.x { border:0; background:transparent; font-size:1.1rem; cursor:pointer; }
|
||||
|
||||
.sub { margin:.55rem 0 1rem; opacity:.92; line-height: 1.35; }
|
||||
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:.55rem; }
|
||||
.grid button {
|
||||
padding: .8rem .9rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,.18);
|
||||
background: rgba(255,255,255,.96);
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.grid button:hover { background: #fff; }
|
||||
|
||||
.ft { margin-top:.85rem; opacity:.88; }
|
||||
.ft.row { display:flex; align-items:center; justify-content:space-between; gap:1rem; }
|
||||
.back {
|
||||
border: 1px solid rgba(0,0,0,.18);
|
||||
background: rgba(255,255,255,.96);
|
||||
color:#111;
|
||||
border-radius: 12px;
|
||||
padding: .55rem .7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
kbd { padding:0 .35rem; border:1px solid rgba(0,0,0,.2); border-radius:6px; }
|
||||
|
||||
.step { display:none; }
|
||||
dialog[data-step="1"] .step-1 { display:block; }
|
||||
dialog[data-step="2"] .step-2 { display:block; }
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const dlg = document.getElementById("propose-modal");
|
||||
if (!dlg) return;
|
||||
|
||||
/** @type {URL|null} */
|
||||
let pending = null;
|
||||
/** @type {"correction"|"fact"|""} */
|
||||
let kind = "";
|
||||
|
||||
// Doit rester cohérent avec EditionLayout
|
||||
const URL_HARD_LIMIT = 6500;
|
||||
|
||||
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const upsertLine = (text, key, value) => {
|
||||
const re = new RegExp(`^\\s*${esc(key)}\\s*:\\s*.*$`, "mi");
|
||||
|
||||
// value vide => supprimer la ligne si elle existe
|
||||
if (!value) {
|
||||
if (!re.test(text)) return text;
|
||||
return (
|
||||
text
|
||||
.replace(re, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trimEnd() + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
// remplace si existe
|
||||
if (re.test(text)) return text.replace(re, `${key}: ${value}`);
|
||||
|
||||
// sinon append
|
||||
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
||||
return text + sep + `${key}: ${value}\n`;
|
||||
};
|
||||
|
||||
const setTitlePrefix = (u, prefix) => {
|
||||
const t = u.searchParams.get("title") || "";
|
||||
const cleaned = t.replace(/^\[[^\]]+\]\s*/,"");
|
||||
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
|
||||
};
|
||||
|
||||
const tryUpgradeBodyWithFull = (u, full) => {
|
||||
if (!full) return;
|
||||
|
||||
let body = u.searchParams.get("body") || "";
|
||||
if (!body) return;
|
||||
|
||||
// Si déjà en "copie exacte", rien à faire
|
||||
if (body.includes("Texte actuel (copie exacte du paragraphe)")) return;
|
||||
|
||||
// On ne tente que si le body est en mode extrait
|
||||
if (!body.includes("Texte actuel (extrait):")) return;
|
||||
|
||||
const quoted = full.split(/\r?\n/).map(l => `> ${l}`.trimEnd()).join("\n");
|
||||
|
||||
// Remplace le bloc "Texte actuel (extrait)" jusqu'à "Proposition (remplacer par):"
|
||||
const re = /Texte actuel \(extrait\):[\s\S]*?\n\nProposition \(remplacer par\):/m;
|
||||
const next = body.replace(
|
||||
re,
|
||||
`Texte actuel (copie exacte du paragraphe):\n${quoted}\n\nProposition (remplacer par):`
|
||||
);
|
||||
|
||||
if (next === body) return;
|
||||
|
||||
u.searchParams.set("body", next);
|
||||
|
||||
// garde-fou URL
|
||||
if (u.toString().length > URL_HARD_LIMIT) {
|
||||
// revert
|
||||
u.searchParams.set("body", body);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Ouvre EN NOUVEL ONGLET sans jamais remplacer l’onglet courant
|
||||
const openInNewTab = (url) => {
|
||||
// 1) tente window.open
|
||||
try {
|
||||
const w = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (w) return true;
|
||||
} catch {}
|
||||
|
||||
// 2) fallback "anchor click" (souvent mieux toléré)
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
return true;
|
||||
} catch {}
|
||||
|
||||
// 3) dernier recours: on ne quitte PAS la page
|
||||
window.prompt("Popup bloquée. Copiez ce lien pour ouvrir le ticket :", url);
|
||||
return false;
|
||||
};
|
||||
|
||||
const openWith = (url) => {
|
||||
pending = url;
|
||||
kind = "";
|
||||
dlg.dataset.step = "1";
|
||||
if (typeof dlg.showModal === "function") dlg.showModal();
|
||||
else openInNewTab(url.toString());
|
||||
};
|
||||
|
||||
// Intercepte UNIQUEMENT les liens marqués data-propose
|
||||
document.addEventListener("click", async (e) => {
|
||||
const a = e.target?.closest?.("a[data-propose]");
|
||||
if (!a) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
|
||||
if (!rawUrl || rawUrl === "#") return;
|
||||
|
||||
const full = (a.dataset.full || "").trim();
|
||||
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
|
||||
// Option B.1 : copie presse-papier du texte complet (si dispo)
|
||||
if (full) {
|
||||
try { await navigator.clipboard.writeText(full); } catch {}
|
||||
// Option B.2 : upgrade du body (si l'URL reste raisonnable)
|
||||
tryUpgradeBodyWithFull(u, full);
|
||||
}
|
||||
|
||||
openWith(u);
|
||||
} catch {
|
||||
openInNewTab(rawUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: type
|
||||
dlg.addEventListener("click", (e) => {
|
||||
const btn = e.target?.closest?.("button[data-kind]");
|
||||
if (!btn || !pending) return;
|
||||
|
||||
kind = btn.getAttribute("data-kind") || "";
|
||||
let body = pending.searchParams.get("body") || "";
|
||||
|
||||
if (kind === "fact") {
|
||||
body = upsertLine(body, "Type", "type/fact-check");
|
||||
body = upsertLine(body, "State", "state/a-sourcer");
|
||||
setTitlePrefix(pending, "Fact-check");
|
||||
} else {
|
||||
body = upsertLine(body, "Type", "type/correction");
|
||||
body = upsertLine(body, "State", "state/recevable");
|
||||
setTitlePrefix(pending, "Correction");
|
||||
}
|
||||
|
||||
pending.searchParams.set("body", body);
|
||||
dlg.dataset.step = "2";
|
||||
});
|
||||
|
||||
// Back
|
||||
dlg.addEventListener("click", (e) => {
|
||||
const back = e.target?.closest?.("button[data-back]");
|
||||
if (!back) return;
|
||||
dlg.dataset.step = "1";
|
||||
});
|
||||
|
||||
// Step 2: category + open
|
||||
dlg.addEventListener("click", (e) => {
|
||||
const btn = e.target?.closest?.("button[data-category]");
|
||||
if (!btn || !pending) return;
|
||||
|
||||
const cat = btn.getAttribute("data-category") || "";
|
||||
let body = pending.searchParams.get("body") || "";
|
||||
body = upsertLine(body, "Category", cat);
|
||||
pending.searchParams.set("body", body);
|
||||
|
||||
const u = pending.toString();
|
||||
dlg.close();
|
||||
|
||||
// ✅ ouvre en nouvel onglet, sans jamais remplacer l’onglet courant
|
||||
openInNewTab(u);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
11
src/components/SiteNav.astro
Normal file
11
src/components/SiteNav.astro
Normal file
@@ -0,0 +1,11 @@
|
||||
<nav class="site-nav" aria-label="Navigation principale">
|
||||
<a href="/">Accueil</a><span aria-hidden="true"> · </span>
|
||||
<a href="/editions/">Carte des œuvres</a><span aria-hidden="true"> · </span>
|
||||
<a href="/methode/">Méthode</a><span aria-hidden="true"> · </span>
|
||||
<a href="/recherche/">Recherche</a><span aria-hidden="true"> · </span>
|
||||
<a href="/archicratie/">Essai-thèse</a><span aria-hidden="true"> · </span>
|
||||
<a href="/traite/">Traité</a><span aria-hidden="true"> · </span>
|
||||
<a href="/ia/">Cas IA</a><span aria-hidden="true"> · </span>
|
||||
<a href="/glossaire/">Glossaire</a><span aria-hidden="true"> · </span>
|
||||
<a href="/atlas/">Atlas</a>
|
||||
</nav>
|
||||
131
src/components/Term.astro
Normal file
131
src/components/Term.astro
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
const { term, slug } = Astro.props;
|
||||
const href = slug ? `/glossaire/${slug}/` : "/glossaire/";
|
||||
---
|
||||
<a class="term" href={href} data-term={term} data-term-slug={slug}>
|
||||
<slot>{term}</slot>
|
||||
</a>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
// Popover léger, zéro dépendance, index statique.
|
||||
const API = "/api/glossaire.json";
|
||||
const CACHE_KEY = "__archicratieGlossaryIndex";
|
||||
const CACHE_TIME_KEY = "__archicratieGlossaryIndexTime";
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
async function getIndex() {
|
||||
try {
|
||||
const t = Number(sessionStorage.getItem(CACHE_TIME_KEY));
|
||||
const cached = sessionStorage.getItem(CACHE_KEY);
|
||||
if (cached && t && (now() - t) < TTL_MS) return JSON.parse(cached);
|
||||
|
||||
const res = await fetch(API);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
||||
sessionStorage.setItem(CACHE_TIME_KEY, String(now()));
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findEntry(index, slug, term) {
|
||||
if (!index) return null;
|
||||
if (slug) {
|
||||
const bySlug = index.find((e) => e.slug === slug);
|
||||
if (bySlug) return bySlug;
|
||||
}
|
||||
const lc = (term || "").toLowerCase();
|
||||
return index.find((e) =>
|
||||
e.term.toLowerCase() === lc ||
|
||||
(e.aliases || []).some((a) => String(a).toLowerCase() === lc)
|
||||
) || null;
|
||||
}
|
||||
|
||||
let pop;
|
||||
function ensurePopover() {
|
||||
if (pop) return pop;
|
||||
pop = document.createElement("div");
|
||||
pop.className = "term-popover";
|
||||
pop.style.position = "absolute";
|
||||
pop.style.zIndex = "9999";
|
||||
pop.style.maxWidth = "320px";
|
||||
pop.style.padding = "10px 12px";
|
||||
pop.style.border = "1px solid rgba(127,127,127,0.45)";
|
||||
pop.style.borderRadius = "12px";
|
||||
pop.style.background = "Canvas";
|
||||
pop.style.boxShadow = "0 10px 30px rgba(0,0,0,0.15)";
|
||||
pop.style.display = "none";
|
||||
document.body.appendChild(pop);
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!pop) return;
|
||||
const target = e.target;
|
||||
if (target && target.closest && target.closest(".term")) return;
|
||||
pop.style.display = "none";
|
||||
});
|
||||
|
||||
return pop;
|
||||
}
|
||||
|
||||
function placePopover(anchor, node) {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
const top = window.scrollY + r.bottom + 8;
|
||||
const left = window.scrollX + Math.min(r.left, window.innerWidth - 340);
|
||||
node.style.top = `${top}px`;
|
||||
node.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
async function show(anchor) {
|
||||
const slug = anchor.getAttribute("data-term-slug");
|
||||
const term = anchor.getAttribute("data-term");
|
||||
const index = await getIndex();
|
||||
const entry = findEntry(index, slug, term);
|
||||
|
||||
const node = ensurePopover();
|
||||
if (!entry) {
|
||||
node.innerHTML = `<strong>${term || "Terme"}</strong><div style="margin-top:6px;opacity:0.85;">Voir la page du glossaire.</div>`;
|
||||
} else {
|
||||
node.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;align-items:baseline;">
|
||||
<strong>${entry.term}</strong>
|
||||
<a href="${entry.href}" style="font-size:12px;opacity:0.85;">ouvrir</a>
|
||||
</div>
|
||||
<div style="margin-top:6px;opacity:0.95;">${entry.definitionShort}</div>
|
||||
`;
|
||||
}
|
||||
placePopover(anchor, node);
|
||||
node.style.display = "block";
|
||||
}
|
||||
|
||||
// Délégation d'événements : un seul handler pour tout le document
|
||||
if (!window.__archicratieTermPopoverBound) {
|
||||
window.__archicratieTermPopoverBound = true;
|
||||
|
||||
document.addEventListener("mouseover", (e) => {
|
||||
const a = e.target && e.target.closest ? e.target.closest(".term") : null;
|
||||
if (!a) return;
|
||||
// éviter popover si pas de slug (cas rares)
|
||||
show(a);
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const a = e.target && e.target.closest ? e.target.closest(".term") : null;
|
||||
if (!a) return;
|
||||
show(a);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.term {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user