Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ce431d055 | ||
|
|
8d05873082 | ||
|
|
358c2954c3 | ||
|
|
79424d29cf | ||
|
|
7762b31f1d | ||
|
|
3f5ebe1c36 | ||
|
|
a2b89489a6 | ||
|
|
74337c0bcb | ||
|
|
6ac5a2bdf7 |
@@ -82,3 +82,29 @@ Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png
|
||||
|
||||
1. **Texte sombre sur l'accent safran, jamais blanc.** `--accent`/`--on-accent` : le blanc sur `#f5b342` échoue le contrôle AA (décision DS du 20/07/2026). `--on-accent` est toujours une couleur sombre (`#1a2238` clair, `#111520` sombre). Vérifié au pixel sur la variante `posture` de `Chip.vue`.
|
||||
2. **`--nav-text-on-primary` reste une valeur littérale**, pas un alias — voir l'explication détaillée dans la section tokens ci-dessus. Si un nouveau composant a besoin de texte de contraste sur un fond bleu nuit, vérifie d'abord si ce fond est `--nav-primary` (translucide) ou `--nav-primary-solid` (opaque) avant de choisir la variable de texte : elles n'ont pas la même réponse en mode sombre.
|
||||
|
||||
## Navigation par échelles S / M / XL (B10, 02/10/2026)
|
||||
|
||||
**Source unique : `utils/echelles.ts`.** Chaque échelle porte ses `onglets` (`libelle`, `to`, `actif(route)`) ; `ORDRE_ECHELLES` fixe l'ordre S → M → XL ; `echelleDeRoute(route)` rend le groupe et l'onglet courants, ou `null` hors groupe (Manifeste, À propos, Proposer, Signaler, fiche, `/rag`). Aucun libellé d'échelle ni d'onglet ne se recopie dans un composant : ajouter un onglet = une ligne dans ce fichier, et `scripts/test-echelles-nav.mjs` (une ligne de cas).
|
||||
|
||||
| Échelle | Accroche | Onglets |
|
||||
|---|---|---|
|
||||
| S | Partir de soi | Jobs (`/trouver-du-taf`) · Outils (`/outils`) |
|
||||
| M | La communauté | Écosystème d'entraide (`/`) · Réseaux AEP (`/agences`) · Codev (`/codev*`) |
|
||||
| XL | Contribuer | Recherche média (`/media`) · Publications (`/media?tab=projets`) |
|
||||
|
||||
**Seuils** (CSS des composants, pas de breakpoint Tailwind : 1280 et 1440 n'en sont pas) :
|
||||
|
||||
| Largeur | En-tête | Sous l'en-tête |
|
||||
|---|---|---|
|
||||
| ≥ 1440 | logo complet · trois groupes · Manifeste · À propos · Soutenir (contour safran) · + Proposer (plein safran) · sombre | — |
|
||||
| 1280-1439 | idem, logo réduit au bloc « AEP » | — |
|
||||
| 1024-1279 | logo complet · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
|
||||
| 768-1023 | logo réduit · groupe courant seul · Manifeste · + Proposer · sombre · « Menu » | — |
|
||||
| < 768 | logo complet · sombre · + (icône) · ☰ | `FilEchelle` (28 px), sauf hors groupe |
|
||||
|
||||
**Composants** : `NavEchelles.vue` (barre ; chaque groupe est un `<ul>` étiqueté par son accroche via `aria-labelledby`, onglet courant en `aria-current="page"`), `NavTiroir.vue` (menu < 1280 : `role="dialog"`, voile, Échap, focus piégé et rendu à l'élément d'ouverture, fermeture au changement de route et au passage à ≥ 1280), `FilEchelle.vue` (< 768, monté dans `app.vue` entre `<header>` et le conteneur de page, **jamais dedans** : `/` y est en `overflow-hidden`). Le lien de don vit dans `utils/liens.ts` (`SOUTENIR_URL`).
|
||||
|
||||
**Texte atténué** : `color-mix(in srgb, var(--text) 78 %, transparent)`, pas `--text-muted` (3,7:1 sur blanc, sous AA à 0,8 rem) ni `opacity` (qui éteindrait aussi l'anneau de focus).
|
||||
|
||||
**Signaler** n'est plus dans la barre d'ordinateur : il reste dans le tiroir, dans chaque fiche, et en pied d'À propos.
|
||||
|
||||
@@ -204,6 +204,8 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
||||
|
||||
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||
|
||||
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
|
||||
|
||||
---
|
||||
|
||||
## 7. Infrastructure
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
|
||||
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL (B10 : échelles S / M / XL) -->
|
||||
<header
|
||||
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
|
||||
class="app-header flex items-center px-4 shrink-0 relative z-[9999] shadow-sm gap-3"
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<!-- Logo : texte complet sauf 768-1023 et 1280-1439 (place des onglets) -->
|
||||
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
||||
<div
|
||||
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
@@ -20,112 +20,38 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||
<NuxtLink
|
||||
to="/outils"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
||||
>
|
||||
Outils
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||
>
|
||||
Écosystème Entraide
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/agences"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||
>
|
||||
Réseaux AEP
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/trouver-du-taf"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
||||
>
|
||||
Jobs
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/codev"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
||||
>
|
||||
Codev
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||
>
|
||||
recherche-média
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
|
||||
<div class="hidden sm:flex lg:hidden flex-1 mx-2">
|
||||
<label class="flex items-center gap-2 w-full px-3 py-1.5 rounded-xl border" style="background: var(--nav-bg); border-color: var(--nav-bg-alt);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="headerSearch"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
class="flex-1 bg-transparent border-0 outline-none text-sm"
|
||||
style="color: var(--nav-text); font-family: var(--nav-font);"
|
||||
autocomplete="off"
|
||||
@input="onHeaderSearch"
|
||||
@keydown.enter="onHeaderSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="headerSearch"
|
||||
type="button"
|
||||
@click.stop="clearHeaderSearch"
|
||||
class="flex-shrink-0"
|
||||
style="color: var(--nav-text-muted);"
|
||||
aria-label="Effacer la recherche"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Échelles : trois groupes ≥ 1280, groupe courant 768-1279, rien < 768 (fil d'échelle) -->
|
||||
<NavEchelles />
|
||||
<div class="flex-1 md:hidden" aria-hidden="true" />
|
||||
|
||||
<!-- Actions droite -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<NuxtLink
|
||||
to="/manifeste"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
|
||||
class="header-lien hidden md:inline-flex"
|
||||
:aria-current="route.path === '/manifeste' ? 'page' : undefined"
|
||||
>
|
||||
Manifeste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/a-propos"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
style="color: var(--nav-text-muted);"
|
||||
class="header-lien header-xl"
|
||||
:aria-current="route.path === '/a-propos' ? 'page' : undefined"
|
||||
>
|
||||
À propos
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/signaler"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden lg:inline-flex items-center gap-1"
|
||||
style="color: var(--nav-text-muted);"
|
||||
<a
|
||||
:href="SOUTENIR_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="header-soutenir header-xl"
|
||||
>
|
||||
Signaler
|
||||
</NuxtLink>
|
||||
<!-- Proposer une ressource (5 onglets) -->
|
||||
Soutenir
|
||||
</a>
|
||||
<!-- Proposer une ressource -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
class="header-proposer hidden md:inline-flex"
|
||||
>
|
||||
+ Proposer
|
||||
</NuxtLink>
|
||||
@@ -153,68 +79,51 @@
|
||||
<!-- Mobile : proposer icône -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
class="sm:hidden p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
class="md:hidden p-2 rounded-lg"
|
||||
style="background: var(--accent); color: var(--on-accent);"
|
||||
title="Proposer une ressource"
|
||||
aria-label="Proposer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="hamburgerOpen = !hamburgerOpen"
|
||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
:aria-label="hamburgerOpen ? 'Fermer le menu' : 'Menu'"
|
||||
:aria-expanded="hamburgerOpen"
|
||||
>
|
||||
<svg v-if="!hamburgerOpen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="hamburgerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[210px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
|
||||
<NuxtLink to="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Menu (< 1280) : ouvre le tiroir ; le mot est écrit à partir de 768 -->
|
||||
<button
|
||||
type="button"
|
||||
class="header-menu"
|
||||
aria-label="Ouvrir le menu"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="menuOpen"
|
||||
@click="menuOpen = true"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<span class="header-menu__mot" aria-hidden="true">Menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fil d'échelle (< 768) : dans le flux de la colonne, hors du conteneur de page -->
|
||||
<FilEchelle />
|
||||
|
||||
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<div class="flex-1 min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
<NavTiroir :open="menuOpen" @close="menuOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter()
|
||||
import { SOUTENIR_URL } from '~/utils/liens'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const hamburgerOpen = ref(false)
|
||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||
const menuOpen = ref(false)
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||
const isDark = ref(false)
|
||||
@@ -237,40 +146,14 @@ function toggleDark() {
|
||||
localStorage.setItem('aep_theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Barre de recherche header mobile ─────────────────────────────────────
|
||||
const headerSearch = ref((route.query.q as string) ?? '')
|
||||
|
||||
// Sync depuis URL quand la route change
|
||||
watch(() => route.query.q, (v) => {
|
||||
headerSearch.value = (v as string) ?? ''
|
||||
})
|
||||
|
||||
function onHeaderSearch() {
|
||||
const q = headerSearch.value.trim()
|
||||
if (route.path === '/') {
|
||||
router.replace({ query: q ? { ...route.query, q } : { ...route.query, q: undefined } })
|
||||
} else if (q) {
|
||||
router.push({ path: '/', query: { q } })
|
||||
}
|
||||
}
|
||||
|
||||
function clearHeaderSearch() {
|
||||
headerSearch.value = ''
|
||||
if (route.path === '/') {
|
||||
const q = { ...route.query }
|
||||
delete q.q
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fiche aléatoire ───────────────────────────────────────────────────────
|
||||
function goRandom() {
|
||||
router.push({ path: '/', query: { random: '1' } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── En-tête ──────────────────────────────────────────────────────────── */
|
||||
.app-header {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
||||
.logo-text {
|
||||
line-height: 1.05;
|
||||
@@ -285,39 +168,96 @@ function goRandom() {
|
||||
@media (min-width: 1024px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
||||
}
|
||||
/* Le groupe courant (768-1023) et les trois groupes (1280-1439) prennent la place du texte */
|
||||
@media (min-width: 768px) and (max-width: 1023px), (min-width: 1280px) and (max-width: 1439px) {
|
||||
.logo-text { display: none; }
|
||||
}
|
||||
|
||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||
.nav-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
/* ── Liens et boutons de droite ───────────────────────────────────────── */
|
||||
.header-lien {
|
||||
align-items: center;
|
||||
padding: 6px 16px 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
border-bottom: 2px solid transparent;
|
||||
gap: 2px;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
.header-lien:hover,
|
||||
.header-lien[aria-current="page"] {
|
||||
color: var(--text);
|
||||
}
|
||||
.header-lien[aria-current="page"] {
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: var(--nav-text);
|
||||
.header-soutenir {
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border: 1.5px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab--active {
|
||||
color: var(--nav-text);
|
||||
border-bottom-color: var(--nav-primary-solid);
|
||||
font-weight: 600;
|
||||
.header-proposer {
|
||||
align-items: center;
|
||||
padding: 6px 11px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-tab-badge {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--nav-text-muted);
|
||||
opacity: 0.65;
|
||||
.header-soutenir:hover,
|
||||
.header-proposer:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header-lien:focus-visible,
|
||||
.header-soutenir:focus-visible,
|
||||
.header-proposer:focus-visible,
|
||||
.header-menu:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* À propos et Soutenir : barre complète seulement (≥ 1280), sinon dans le tiroir */
|
||||
.header-xl { display: none; }
|
||||
@media (min-width: 1280px) {
|
||||
.header-xl { display: inline-flex; }
|
||||
}
|
||||
|
||||
.header-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.header-menu__mot { display: none; }
|
||||
@media (min-width: 768px) {
|
||||
.header-menu {
|
||||
padding: 5px 10px;
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
.header-menu__mot { display: inline; }
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.header-menu { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
-24
@@ -77,8 +77,12 @@ const emit = defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
/* Survol : uniquement sur pointeur capable de survol. Sur tactile, :hover
|
||||
reste collé après un tap (chip « allumé » sans être actif). */
|
||||
@media (hover: hover) {
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
@@ -92,19 +96,21 @@ const emit = defineEmits<{
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--echelle:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
@media (hover: hover) {
|
||||
.chip--echelle:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
/* Actif = fond PLEIN. --primary est translucide dans le thème AEP
|
||||
(rgba(26,34,56,.6) : 4,16:1 sur le fond de page, sous AA) ; le couple
|
||||
--nav-primary-solid / --nav-text-on-primary (≈14,6:1) reste lisible aussi
|
||||
en sombre. Le survol d'un chip actif ne change pas de fond. */
|
||||
.chip--echelle.chip--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--echelle.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--nav-primary-solid);
|
||||
border-color: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
||||
@@ -113,16 +119,15 @@ const emit = defineEmits<{
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
.chip--fonction:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
@media (hover: hover) {
|
||||
.chip--fonction:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
.chip--fonction.chip--active {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--fonction.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
||||
@@ -131,9 +136,11 @@ const emit = defineEmits<{
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--posture:hover {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
@media (hover: hover) {
|
||||
.chip--posture:hover {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
}
|
||||
.chip--posture.chip--active {
|
||||
background: var(--accent);
|
||||
|
||||
@@ -150,19 +150,19 @@ function toggle(tag: string) {
|
||||
|
||||
.fiches-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
FilEchelle — B10-M4
|
||||
< 768 px : une ligne de 28 px sous la barre, « S · Partir de soi · Outils · Jobs » :
|
||||
l'échelle courante, l'onglet courant, puis ses voisins du groupe (liens).
|
||||
Absente sur les pages hors groupe (Manifeste, À propos, Proposer, Signaler, fiche).
|
||||
Montée dans le flux de la colonne d'app.vue, AVANT le conteneur de page : la carte
|
||||
de / perd ces 28 px à vue, jamais en dessous de la ligne de flottaison.
|
||||
-->
|
||||
<template>
|
||||
<nav v-if="courant" class="fil-echelle" :class="{ 'fil-echelle--long': voisins.length > 1 }" aria-label="Échelle courante">
|
||||
<span class="fil-echelle__pastille">{{ courant.echelle }}</span>
|
||||
<span class="fil-echelle__titre">{{ ECHELLES[courant.echelle].titre }}</span>
|
||||
<span class="fil-echelle__sep" aria-hidden="true">·</span>
|
||||
<span class="fil-echelle__courant" aria-current="page">{{ courant.onglet.libelle }}</span>
|
||||
<template v-for="o in voisins" :key="o.to">
|
||||
<span class="fil-echelle__sep" aria-hidden="true">·</span>
|
||||
<NuxtLink :to="o.to" class="fil-echelle__lien">{{ o.libelle }}</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, echelleDeRoute } from '~/utils/echelles'
|
||||
|
||||
const route = useRoute()
|
||||
const courant = computed(() => echelleDeRoute(route))
|
||||
const voisins = computed(() =>
|
||||
courant.value ? ECHELLES[courant.value.echelle].onglets.filter((o) => o !== courant.value!.onglet) : [],
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fil-echelle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-sm);
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--bg-alt);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.fil-echelle::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fil-echelle__pastille {
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.fil-echelle__courant {
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fil-echelle__lien {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.fil-echelle__lien:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Groupe à trois onglets (M) sous 400 px : l'accroche se replie sur sa pastille
|
||||
(lue quand même par les lecteurs d'écran), sinon « Codev » sort de l'écran. */
|
||||
@media (max-width: 399px) {
|
||||
.fil-echelle--long .fil-echelle__titre {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
.fil-echelle--long .fil-echelle__titre + .fil-echelle__sep {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fil-echelle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -283,19 +283,11 @@ async function submit() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── B5-M2c : distinction principal / second choix + contraste chip actif ──
|
||||
Mesuré avant modif (script de capture, af4-avant-*) : le chip actif de
|
||||
Chip.vue (variant fonction, fond rgba(26,34,56,.6) sur fond de page
|
||||
#f8f6f1) rend ≈ 4.16:1 au repos — sous le seuil AA 4.5:1. C'est un défaut
|
||||
général de Chip.vue (touche aussi les filtres de / et /agences), signalé
|
||||
hors mandat — voir rapport. On neutralise ce point pour CE formulaire
|
||||
seulement, sans toucher Chip.vue : fond plein (--nav-primary-solid) au
|
||||
lieu de la version translucide, pairing déjà utilisé ailleurs sur le site
|
||||
(pages/trouver-du-taf.vue .taff-tab--active). Mesuré après : ≈14.6:1. */
|
||||
.field-group :deep(.chip--fonction.chip--active) {
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
/* ── B5-M2c : distinction principal / second choix ──
|
||||
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
|
||||
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
|
||||
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
|
||||
ont été retirées. */
|
||||
|
||||
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
|
||||
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
|
||||
@@ -306,32 +298,13 @@ async function submit() {
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
}
|
||||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||||
background: rgba(var(--nav-primary-raw), 0.08) !important;
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
}
|
||||
|
||||
/* Survol collant sur tactile (le :hover de Chip.vue n'est pas gardé par
|
||||
@media (hover: hover)) : neutralisé ici pour les deux chips actifs de ce
|
||||
formulaire, sans toucher Chip.vue. Mesuré : le survol n'aggravait pas le
|
||||
contraste du principal (6.68:1 contre 4.16:1 au repos, avant modif) mais
|
||||
figeait un fond et un transform incohérents après un tap tant que rien ne
|
||||
les levait. */
|
||||
@media (hover: none) {
|
||||
.field-group :deep(.chip--fonction.chip--active) {
|
||||
transform: none !important;
|
||||
}
|
||||
.field-group :deep(.chip--fonction.chip--active:hover) {
|
||||
background: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text-on-primary) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
|
||||
(sur tactile, :hover reste collé après un tap). */
|
||||
@media (hover: hover) {
|
||||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||||
background: transparent !important;
|
||||
background: rgba(var(--nav-primary-raw), 0.08) !important;
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
|
||||
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">Publications</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
|
||||
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
|
||||
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
NavEchelles — B10-M2
|
||||
Barre des trois échelles S / M / XL, lue depuis utils/echelles.ts (source unique).
|
||||
≥ 1280 px : les trois groupes ; 768-1279 px : le seul groupe de la page courante ;
|
||||
< 768 px : rien (FilEchelle + NavTiroir prennent le relais).
|
||||
Chaque groupe = <ul> étiqueté par son accroche (pastille + titre de l'échelle).
|
||||
-->
|
||||
<template>
|
||||
<nav class="nav-echelles" aria-label="Échelles">
|
||||
<div
|
||||
v-for="id in ORDRE_ECHELLES"
|
||||
:key="id"
|
||||
class="nav-echelles__grp"
|
||||
:class="{ 'nav-echelles__grp--courant': courant?.echelle === id }"
|
||||
>
|
||||
<span :id="`nav-echelle-${id}`" class="nav-echelles__kick">
|
||||
<span class="nav-echelles__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
|
||||
</span>
|
||||
<ul class="nav-echelles__tabs" :aria-labelledby="`nav-echelle-${id}`">
|
||||
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
|
||||
<NuxtLink
|
||||
:to="o.to"
|
||||
class="nav-echelles__tab"
|
||||
:class="{ 'nav-echelles__tab--actif': o.actif(route) }"
|
||||
:aria-current="o.actif(route) ? 'page' : undefined"
|
||||
>{{ o.libelle }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, ORDRE_ECHELLES, echelleDeRoute } from '~/utils/echelles'
|
||||
|
||||
const route = useRoute()
|
||||
const courant = computed(() => echelleDeRoute(route))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-echelles {
|
||||
display: none;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-echelles__grp {
|
||||
position: relative;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-echelles__kick {
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: 0.66rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.nav-echelles__grp--courant .nav-echelles__kick {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-echelles__pastille {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.nav-echelles__tabs {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-echelles__tab {
|
||||
display: inline-block;
|
||||
padding: 4px 9px 9px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--fw-medium);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.nav-echelles__tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-echelles__tab:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.nav-echelles__tab--actif {
|
||||
color: var(--text);
|
||||
font-weight: var(--fw-semibold);
|
||||
border-bottom-color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
/* 768-1279 : le seul groupe courant, aligné à gauche après le logo */
|
||||
@media (min-width: 768px) {
|
||||
.nav-echelles {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-echelles__grp--courant {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* ≥ 1280 : les trois groupes, centrés, séparés par un filet */
|
||||
@media (min-width: 1280px) {
|
||||
.nav-echelles {
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-echelles__grp {
|
||||
display: flex;
|
||||
}
|
||||
.nav-echelles__grp + .nav-echelles__grp::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(var(--space-lg) / -2);
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-echelles__tab { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<!--
|
||||
NavTiroir — B10-M3
|
||||
Menu < 1280 px : tiroir pleine hauteur à droite, voile, Échap, focus piégé,
|
||||
focus rendu à l'élément qui l'a ouvert. Les trois échelles dans l'ordre de la
|
||||
barre (utils/echelles.ts), puis Manifeste, À propos, Signaler, et Soutenir.
|
||||
Se ferme seul au changement de route et si la fenêtre passe à ≥ 1280 px.
|
||||
-->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="nav-tiroir">
|
||||
<div v-if="open" class="nav-tiroir" @keydown="onKeydown">
|
||||
<div class="nav-tiroir__voile" aria-hidden="true" @click="fermer" />
|
||||
<div
|
||||
ref="panneau"
|
||||
class="nav-tiroir__panneau"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nav-tiroir-titre"
|
||||
>
|
||||
<div class="nav-tiroir__tete">
|
||||
<h2 id="nav-tiroir-titre" class="nav-tiroir__titre">Menu</h2>
|
||||
<button type="button" class="nav-tiroir__fermer" aria-label="Fermer le menu" @click="fermer">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="nav-tiroir__corps" aria-label="Échelles et pages">
|
||||
<p class="nav-tiroir__cadre">{{ ECHELLES_CADRE_COURT }}</p>
|
||||
|
||||
<div v-for="id in ORDRE_ECHELLES" :key="id" class="nav-tiroir__grp">
|
||||
<span :id="`nav-tiroir-echelle-${id}`" class="nav-tiroir__kick">
|
||||
<span class="nav-tiroir__pastille">{{ id }}</span>{{ ECHELLES[id].titre }}
|
||||
</span>
|
||||
<ul class="nav-tiroir__liste" :aria-labelledby="`nav-tiroir-echelle-${id}`">
|
||||
<li v-for="o in ECHELLES[id].onglets" :key="o.to">
|
||||
<NuxtLink
|
||||
:to="o.to"
|
||||
class="nav-tiroir__lien"
|
||||
:class="{ 'nav-tiroir__lien--actif': o.actif(route) }"
|
||||
:aria-current="o.actif(route) ? 'page' : undefined"
|
||||
@click="fermer"
|
||||
>{{ o.libelle }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr class="nav-tiroir__sep" />
|
||||
|
||||
<ul class="nav-tiroir__liste">
|
||||
<li v-for="p in PAGES" :key="p.to">
|
||||
<NuxtLink
|
||||
:to="p.to"
|
||||
class="nav-tiroir__second"
|
||||
:aria-current="route.path === p.to ? 'page' : undefined"
|
||||
@click="fermer"
|
||||
>{{ p.libelle }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a :href="SOUTENIR_URL" target="_blank" rel="noopener noreferrer" class="nav-tiroir__soutenir">Soutenir</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, ECHELLES_CADRE_COURT, ORDRE_ECHELLES } from '~/utils/echelles'
|
||||
import { SOUTENIR_URL } from '~/utils/liens'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const PAGES = [
|
||||
{ libelle: 'Manifeste', to: '/manifeste' },
|
||||
{ libelle: 'À propos', to: '/a-propos' },
|
||||
{ libelle: 'Signaler', to: '/signaler' },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const panneau = ref<HTMLElement | null>(null)
|
||||
let retourFocus: HTMLElement | null = null
|
||||
|
||||
function fermer() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function focusables(): HTMLElement[] {
|
||||
if (!panneau.value) return []
|
||||
return [...panneau.value.querySelectorAll<HTMLElement>('a[href], button:not([disabled])')]
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
fermer()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const els = focusables()
|
||||
if (!els.length) return
|
||||
const premier = els[0]
|
||||
const dernier = els[els.length - 1]
|
||||
if (e.shiftKey && document.activeElement === premier) {
|
||||
e.preventDefault()
|
||||
dernier.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === dernier) {
|
||||
e.preventDefault()
|
||||
premier.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, async (ouvert) => {
|
||||
if (ouvert) {
|
||||
retourFocus = document.activeElement as HTMLElement | null
|
||||
await nextTick()
|
||||
focusables()[0]?.focus()
|
||||
} else if (retourFocus) {
|
||||
// Le bouton d'ouverture peut ne plus être visible (fenêtre élargie) : focus() est alors sans effet.
|
||||
retourFocus.focus()
|
||||
retourFocus = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.fullPath, () => { if (props.open) fermer() })
|
||||
|
||||
let mq: MediaQueryList | null = null
|
||||
function onLarge(e: MediaQueryListEvent) { if (e.matches && props.open) fermer() }
|
||||
onMounted(() => {
|
||||
mq = window.matchMedia('(min-width: 1280px)')
|
||||
mq.addEventListener('change', onLarge)
|
||||
})
|
||||
onBeforeUnmount(() => mq?.removeEventListener('change', onLarge))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-tiroir {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.nav-tiroir__voile {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(var(--nav-primary-raw), 0.35);
|
||||
}
|
||||
|
||||
.nav-tiroir__panneau {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 88vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.nav-tiroir__tete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-xs) var(--space-sm) var(--space-xs) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-tiroir__titre {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.nav-tiroir__fermer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-tiroir__corps {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-sm) var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-tiroir__cadre {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: var(--lh-base);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.nav-tiroir__grp {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.nav-tiroir__kick {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2xs);
|
||||
font-size: 0.72rem;
|
||||
font-weight: var(--fw-bold);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.nav-tiroir__pastille {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.nav-tiroir__liste {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-tiroir__lien,
|
||||
.nav-tiroir__second {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-tiroir__lien {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tiroir__lien--actif {
|
||||
background: var(--bg-alt);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.nav-tiroir__second {
|
||||
font-size: var(--fs-sm);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.nav-tiroir__lien:focus-visible,
|
||||
.nav-tiroir__second:focus-visible,
|
||||
.nav-tiroir__soutenir:focus-visible,
|
||||
.nav-tiroir__fermer:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-tiroir__sep {
|
||||
margin: var(--space-sm) 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-tiroir__soutenir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-tiroir-enter-active,
|
||||
.nav-tiroir-leave-active {
|
||||
transition: opacity var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.nav-tiroir-enter-active .nav-tiroir__panneau,
|
||||
.nav-tiroir-leave-active .nav-tiroir__panneau {
|
||||
transition: transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.nav-tiroir-enter-from,
|
||||
.nav-tiroir-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-tiroir-enter-from .nav-tiroir__panneau,
|
||||
.nav-tiroir-leave-to .nav-tiroir__panneau {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-tiroir-enter-active,
|
||||
.nav-tiroir-leave-active,
|
||||
.nav-tiroir-enter-active .nav-tiroir__panneau,
|
||||
.nav-tiroir-leave-active .nav-tiroir__panneau {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ export default defineNuxtConfig({
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
|
||||
|
||||
@@ -185,6 +185,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B10-M5 : Signaler a quitté la barre d'ordinateur ; il reste ici, dans le menu et dans chaque fiche -->
|
||||
<p class="section-text apropos-pied">
|
||||
Une fiche fausse, un lien mort, un contenu à retirer ?
|
||||
<NuxtLink to="/signaler">Signaler un problème</NuxtLink>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -270,6 +276,18 @@ function copyApiUrl() {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.apropos-pied {
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.apropos-pied a {
|
||||
color: var(--text);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.section-intro {
|
||||
color: var(--nav-text-muted);
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
+7
-40
@@ -1,56 +1,23 @@
|
||||
<template>
|
||||
<!-- B10-M5 : plus de sous-onglets ici, la nav XL porte « Recherche média » et « Publications » (?tab=projets) -->
|
||||
<div class="media-page" style="background: var(--nav-bg);">
|
||||
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'visuel' }]"
|
||||
@click="tab = 'visuel'"
|
||||
>
|
||||
📚 bibliothèque des pensées écologiques
|
||||
</button>
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'projets' }]"
|
||||
@click="tab = 'projets'"
|
||||
>
|
||||
📐 Projets
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<MediaTabVisuel v-if="tab === 'visuel'" />
|
||||
<MediaTabProjets v-else-if="tab === 'projets'" />
|
||||
<MediaTabProjets v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tab = ref<'visuel' | 'projets'>(
|
||||
(['visuel', 'projets'].includes(route.query.tab as string)
|
||||
? route.query.tab as 'visuel' | 'projets'
|
||||
: 'visuel')
|
||||
)
|
||||
|
||||
watch(tab, (newTab) => {
|
||||
router.replace({ query: { ...route.query, tab: newTab } })
|
||||
// Lu à chaque changement de ?tab= (un lien /media → /media?tab=projets ne remonte pas la page).
|
||||
const tab = ref<'visuel' | 'projets'>(route.query.tab === 'projets' ? 'projets' : 'visuel')
|
||||
watch(() => route.query.tab, (t) => {
|
||||
tab.value = t === 'projets' ? 'projets' : 'visuel'
|
||||
})
|
||||
|
||||
useHead({ title: 'AEP - Media' })
|
||||
useHead(() => ({ title: tab.value === 'projets' ? 'AEP - Publications' : 'AEP - Recherche média' }))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
|
||||
.subtab-btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--nav-text-muted);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.subtab-btn:hover { color: var(--nav-text); }
|
||||
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
|
||||
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
|
||||
*
|
||||
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
|
||||
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
|
||||
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
|
||||
* le cas « schéma sans timestamp ».
|
||||
* Usage : node scripts/test-circuit-breaker.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
|
||||
const { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
|
||||
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
|
||||
)
|
||||
|
||||
const NOW = new Date('2026-09-29T10:00:00Z')
|
||||
|
||||
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
|
||||
function colonnesReferencees(query) {
|
||||
const cols = []
|
||||
if (query.where) {
|
||||
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
|
||||
}
|
||||
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
|
||||
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
|
||||
return cols.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
|
||||
function fauxNocodb({ schema, rows, panne = false }) {
|
||||
const appels = []
|
||||
const fetchJson = async (url, { headers, query }) => {
|
||||
appels.push({ url, query })
|
||||
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
|
||||
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
|
||||
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
|
||||
if (inconnues.length) {
|
||||
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
|
||||
}
|
||||
const limit = Number(query.limit ?? 25)
|
||||
const offset = Number(query.offset ?? 0)
|
||||
const list = rows.slice(offset, offset + limit)
|
||||
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
|
||||
}
|
||||
return { fetchJson, appels }
|
||||
}
|
||||
|
||||
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
|
||||
|
||||
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
|
||||
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
|
||||
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
|
||||
|
||||
const results = []
|
||||
function check(name, cond, detail = '') {
|
||||
results.push({ name, ok: !!cond, detail })
|
||||
}
|
||||
|
||||
// Journal capturé pour vérifier la ligne de budget
|
||||
const logs = []
|
||||
const origInfo = console.info
|
||||
const origWarn = console.warn
|
||||
console.info = (...a) => logs.push(['info', a.join(' ')])
|
||||
console.warn = (...a) => logs.push(['warn', a.join(' ')])
|
||||
|
||||
try {
|
||||
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
|
||||
{
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
|
||||
let status = null
|
||||
try {
|
||||
await fetchJson('x', {
|
||||
headers: { 'xc-token': 'tok' },
|
||||
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
|
||||
})
|
||||
} catch (e) {
|
||||
status = e.statusCode
|
||||
}
|
||||
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
|
||||
}
|
||||
|
||||
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
|
||||
{
|
||||
const rows = [
|
||||
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
|
||||
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
|
||||
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
|
||||
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
|
||||
]
|
||||
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
|
||||
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
|
||||
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
|
||||
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
|
||||
JSON.stringify(appels.map((a) => a.query)))
|
||||
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
|
||||
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
|
||||
}
|
||||
|
||||
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
|
||||
{
|
||||
const rows = [
|
||||
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
|
||||
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
|
||||
]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
|
||||
}
|
||||
|
||||
// 3. Lignes sans aucune date : ignorées et comptées
|
||||
{
|
||||
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
|
||||
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
|
||||
}
|
||||
|
||||
// 3bis. Aucune ligne datée : jamais « 0 € vérifié »
|
||||
{
|
||||
const rows = [{ Id: 1, cout_eur: 30 }, { Id: 2, cout_eur: 1 }]
|
||||
const { fetchJson } = fauxNocodb({ schema: ['Id', 'cout_eur'], rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('aucune date : verified=false, pas 0 € vérifié', s.verified === false && s.lignesSansDate === 2)
|
||||
check('aucune date : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
|
||||
}
|
||||
|
||||
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
|
||||
{
|
||||
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
|
||||
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
|
||||
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
|
||||
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
|
||||
}
|
||||
|
||||
// 5. Alerte entre 18 et 20 €
|
||||
{
|
||||
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
|
||||
}
|
||||
|
||||
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
|
||||
{
|
||||
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
|
||||
logs.length = 0
|
||||
const s = await checkBudget({ ...base, fetchJson })
|
||||
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
|
||||
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
|
||||
}
|
||||
} finally {
|
||||
console.info = origInfo
|
||||
console.warn = origWarn
|
||||
}
|
||||
|
||||
let failed = 0
|
||||
for (const r of results) {
|
||||
if (!r.ok) failed++
|
||||
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
|
||||
}
|
||||
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test des données de nav S / M / XL (B10-M1).
|
||||
* Charge le VRAI fichier utils/echelles.ts via jiti (même patron que test-submit-libre.mjs) :
|
||||
* chaque route → bon groupe et bon onglet actif, un seul onglet actif par route.
|
||||
* Usage : node scripts/test-echelles-nav.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
|
||||
const { ECHELLES, ORDRE_ECHELLES, ECHELLES_CADRE_COURT, echelleDeRoute } = await jiti.import(
|
||||
join(__dirname, '..', 'utils', 'echelles.ts'),
|
||||
)
|
||||
|
||||
const cases = [
|
||||
// [path, query, groupe attendu, onglet attendu]
|
||||
['/trouver-du-taf', {}, 'S', 'Jobs'],
|
||||
['/outils', {}, 'S', 'Outils'],
|
||||
['/', {}, 'M', "Écosystème d'entraide"],
|
||||
['/', { vue: 'fiches' }, 'M', "Écosystème d'entraide"],
|
||||
['/agences', {}, 'M', 'Réseaux AEP'],
|
||||
['/agences', { vue: 'fiches' }, 'M', 'Réseaux AEP'],
|
||||
['/codev', {}, 'M', 'Codev'],
|
||||
['/codev/demo', {}, 'M', 'Codev'],
|
||||
['/codev/carto', {}, 'M', 'Codev'],
|
||||
['/media', {}, 'XL', 'Recherche média'],
|
||||
['/media', { tab: 'visuel' }, 'XL', 'Recherche média'],
|
||||
['/media', { tab: 'projets' }, 'XL', 'Publications'],
|
||||
['/manifeste', {}, null, null],
|
||||
['/a-propos', {}, null, null],
|
||||
['/proposer', {}, null, null],
|
||||
['/signaler', {}, null, null],
|
||||
['/fiche/42', {}, null, null],
|
||||
['/outilsXYZ', {}, null, null],
|
||||
['/codevelopper', {}, null, null],
|
||||
]
|
||||
|
||||
let fail = 0
|
||||
for (const [path, query, groupe, onglet] of cases) {
|
||||
const route = { path, query }
|
||||
const r = echelleDeRoute(route)
|
||||
const gotG = r?.echelle ?? null
|
||||
const gotO = r?.onglet.libelle ?? null
|
||||
// Un seul onglet actif sur tout le site pour une route donnée.
|
||||
const actifs = ORDRE_ECHELLES.flatMap((id) => ECHELLES[id].onglets.filter((o) => o.actif(route)))
|
||||
const ok = gotG === groupe && gotO === onglet && actifs.length === (groupe ? 1 : 0)
|
||||
if (!ok) fail++
|
||||
console.log(`${ok ? 'OK ' : 'FAIL'} ${path}${Object.keys(query).length ? '?' + new URLSearchParams(query) : ''} → ${gotG ?? '∅'} / ${gotO ?? '∅'} (actifs : ${actifs.length})`)
|
||||
}
|
||||
|
||||
// Ordre et contenu des groupes (directive Jules du 29/09 + arbitrage 1A).
|
||||
const attendu = {
|
||||
S: ['Jobs', 'Outils'],
|
||||
M: ["Écosystème d'entraide", 'Réseaux AEP', 'Codev'],
|
||||
XL: ['Recherche média', 'Publications'],
|
||||
}
|
||||
for (const id of ORDRE_ECHELLES) {
|
||||
const got = ECHELLES[id].onglets.map((o) => o.libelle)
|
||||
const ok = JSON.stringify(got) === JSON.stringify(attendu[id])
|
||||
if (!ok) fail++
|
||||
console.log(`${ok ? 'OK ' : 'FAIL'} groupe ${id} = ${got.join(' · ')}`)
|
||||
}
|
||||
|
||||
const cadreOk = ECHELLES_CADRE_COURT === 'Trois échelles, imbriquées. Entrez par celle qui presse.'
|
||||
if (!cadreOk) fail++
|
||||
console.log(`${cadreOk ? 'OK ' : 'FAIL'} cadre court = « ${ECHELLES_CADRE_COURT} »`)
|
||||
|
||||
const total = cases.length + ORDRE_ECHELLES.length + 1
|
||||
console.log(`\n${total - fail}/${total}`)
|
||||
process.exit(fail ? 1 : 0)
|
||||
+100
-27
@@ -19,53 +19,126 @@ export interface BudgetStatus {
|
||||
cumulEur: number
|
||||
blocked: boolean
|
||||
warning: boolean
|
||||
/** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */
|
||||
verified: boolean
|
||||
/** Lignes lues dans stats_usage (toutes dates). */
|
||||
lignesLues: number
|
||||
/** Lignes retenues pour le mois courant. */
|
||||
lignesMois: number
|
||||
/** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */
|
||||
lignesSansDate: number
|
||||
}
|
||||
|
||||
/** Page NocoDB v2 : seuls les champs lus ici sont typés. */
|
||||
interface StatsPage {
|
||||
list?: Record<string, unknown>[]
|
||||
pageInfo?: { isLastPage?: boolean }
|
||||
}
|
||||
|
||||
type FetchJson = (
|
||||
url: string,
|
||||
opts: { headers: Record<string, string>; query: Record<string, string | number> },
|
||||
) => Promise<StatsPage>
|
||||
|
||||
const PAGE_SIZE = 1000
|
||||
const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier
|
||||
|
||||
/**
|
||||
* Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`,
|
||||
* getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon
|
||||
* la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version).
|
||||
*/
|
||||
export function dateLigne(row: Record<string, unknown>): Date | null {
|
||||
const brut = row.timestamp ?? row.CreatedAt ?? row.created_at
|
||||
if (brut === null || brut === undefined || brut === '') return null
|
||||
const d = new Date(brut as string)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||
* Retourne blocked=true si le budget est atteint.
|
||||
*
|
||||
* AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`,
|
||||
* ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait
|
||||
* un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence
|
||||
* (fail-open). On lit la table page par page et on filtre le mois en JS,
|
||||
* comme le worker, qui lit la même table sans erreur.
|
||||
*/
|
||||
export async function checkBudget(config: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
/** Injecté par les tests ; `$fetch` de Nitro sinon. */
|
||||
fetchJson?: FetchJson
|
||||
/** Injecté par les tests ; maintenant sinon. */
|
||||
now?: Date
|
||||
}): Promise<BudgetStatus> {
|
||||
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(url, opts))
|
||||
const now = config.now ?? new Date()
|
||||
const annee = now.getUTCFullYear()
|
||||
const mois = now.getUTCMonth()
|
||||
|
||||
// Premier du mois courant à minuit UTC
|
||||
const now = new Date()
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const monthStartIso = monthStart.toISOString()
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
let cumulEur = 0
|
||||
let lignesLues = 0
|
||||
let lignesMois = 0
|
||||
let lignesSansDate = 0
|
||||
|
||||
try {
|
||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
|
||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
||||
url,
|
||||
{
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const res = await fetchJson(url, {
|
||||
headers: { 'xc-token': nocodbToken },
|
||||
query: {
|
||||
where: `(timestamp,gte,${monthStartIso})`,
|
||||
limit: 1000,
|
||||
fields: 'cout_eur,timestamp',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const rows = res?.list ?? []
|
||||
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
||||
|
||||
return {
|
||||
cumulEur,
|
||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||
query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
|
||||
})
|
||||
const rows = res?.list ?? []
|
||||
for (const row of rows) {
|
||||
lignesLues++
|
||||
const d = dateLigne(row)
|
||||
if (!d) {
|
||||
lignesSansDate++
|
||||
continue
|
||||
}
|
||||
if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) {
|
||||
lignesMois++
|
||||
cumulEur += Number(row.cout_eur) || 0
|
||||
}
|
||||
}
|
||||
if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
||||
return { cumulEur: 0, blocked: false, warning: false }
|
||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message)
|
||||
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
|
||||
}
|
||||
|
||||
// Des lignes, mais aucune datée : on ne sait pas ce qui relève du mois.
|
||||
// Rendre « 0 € vérifié » reproduirait le fail-open silencieux d'avant AF5.
|
||||
if (lignesLues > 0 && lignesSansDate === lignesLues) {
|
||||
console.warn(
|
||||
`[circuitBreaker] budget NON vérifié : ${lignesLues} lignes stats_usage sans timestamp, CreatedAt ni created_at`,
|
||||
)
|
||||
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
|
||||
}
|
||||
|
||||
const status: BudgetStatus = {
|
||||
cumulEur,
|
||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||
verified: true,
|
||||
lignesLues,
|
||||
lignesMois,
|
||||
lignesSansDate,
|
||||
}
|
||||
const mm = String(mois + 1).padStart(2, '0')
|
||||
const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : ''
|
||||
const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : ''
|
||||
console.info(
|
||||
`[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` +
|
||||
`(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`,
|
||||
)
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+50
-1
@@ -1,10 +1,23 @@
|
||||
/**
|
||||
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
|
||||
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
|
||||
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
|
||||
* Source unique : la nav S / M / XL (B10 : NavEchelles, NavTiroir, FilEchelle) lit aussi ce fichier.
|
||||
* Aucun libellé d'échelle ni d'onglet ne se recopie ailleurs.
|
||||
*/
|
||||
export type EchelleId = 'S' | 'M' | 'XL'
|
||||
|
||||
/** Ce que la nav lit d'une route : compatible avec `useRoute()` sans en dépendre (testable en node). */
|
||||
export interface RouteNav {
|
||||
path: string
|
||||
query?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OngletNav {
|
||||
libelle: string
|
||||
to: string
|
||||
actif: (route: RouteNav) => boolean
|
||||
}
|
||||
|
||||
export interface Echelle {
|
||||
id: EchelleId
|
||||
titre: string
|
||||
@@ -13,8 +26,13 @@ export interface Echelle {
|
||||
phrase: string
|
||||
/** Catégorie de /proposer présélectionnée par le lien de contribution. */
|
||||
categorieProposer: 'ecosysteme' | 'reseau' | 'jobs' | 'outils' | 'references'
|
||||
/** Onglets du groupe, dans l'ordre de la barre, du menu et du fil d'échelle (B10, directive Jules du 29/09 + arbitrage 1A du 26/09). */
|
||||
onglets: OngletNav[]
|
||||
}
|
||||
|
||||
const sousChemin = (base: string) => (r: RouteNav) => r.path === base || r.path.startsWith(`${base}/`)
|
||||
const surPublications = (r: RouteNav) => r.query?.tab === 'projets'
|
||||
|
||||
export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
S: {
|
||||
id: 'S',
|
||||
@@ -31,6 +49,10 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
],
|
||||
phrase: 'Des ressources, des méthodes, des pairs : matériel et mutualisé.',
|
||||
categorieProposer: 'outils',
|
||||
onglets: [
|
||||
{ libelle: 'Jobs', to: '/trouver-du-taf', actif: sousChemin('/trouver-du-taf') },
|
||||
{ libelle: 'Outils', to: '/outils', actif: sousChemin('/outils') },
|
||||
],
|
||||
},
|
||||
M: {
|
||||
id: 'M',
|
||||
@@ -46,6 +68,11 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
],
|
||||
phrase: "C'est le groupe qui remet un individu debout. L'affinité fait tenir, la confrontation fait avancer.",
|
||||
categorieProposer: 'ecosysteme',
|
||||
onglets: [
|
||||
{ libelle: "Écosystème d'entraide", to: '/', actif: (r) => r.path === '/' },
|
||||
{ libelle: 'Réseaux AEP', to: '/agences', actif: sousChemin('/agences') },
|
||||
{ libelle: 'Codev', to: '/codev', actif: sousChemin('/codev') },
|
||||
],
|
||||
},
|
||||
XL: {
|
||||
id: 'XL',
|
||||
@@ -61,8 +88,30 @@ export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
],
|
||||
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
|
||||
categorieProposer: 'references',
|
||||
onglets: [
|
||||
{ libelle: 'Recherche média', to: '/media', actif: (r) => sousChemin('/media')(r) && !surPublications(r) },
|
||||
{ libelle: 'Publications', to: '/media?tab=projets', actif: (r) => sousChemin('/media')(r) && surPublications(r) },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** Ordre des groupes dans la barre et le menu. */
|
||||
export const ORDRE_ECHELLES: EchelleId[] = ['S', 'M', 'XL']
|
||||
|
||||
/** Slide 15, commune aux trois échelles. */
|
||||
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
|
||||
|
||||
/** Version courte de la slide 15 (tête du menu) : première et dernière phrase, tirées du texte ci-dessus. */
|
||||
export const ECHELLES_CADRE_COURT = (() => {
|
||||
const phrases = ECHELLES_CADRE.match(/[^.]+\./g) ?? [ECHELLES_CADRE]
|
||||
return phrases.length > 1 ? `${phrases[0].trim()} ${phrases[phrases.length - 1].trim()}` : ECHELLES_CADRE
|
||||
})()
|
||||
|
||||
/** Groupe de la page courante et son onglet actif, ou null hors groupe (Manifeste, À propos, Proposer, fiche…). */
|
||||
export function echelleDeRoute(route: RouteNav): { echelle: EchelleId; onglet: OngletNav } | null {
|
||||
for (const id of ORDRE_ECHELLES) {
|
||||
const onglet = ECHELLES[id].onglets.find((o) => o.actif(route))
|
||||
if (onglet) return { echelle: id, onglet }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Lien de don (Liberapay), partagé par la barre, le tiroir et À propos. */
|
||||
export const SOUTENIR_URL = 'https://liberapay.com/trans-former.fr/donate'
|
||||
Reference in New Issue
Block a user