Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8f9fc1bd | ||
|
|
f4ea1639f2 | ||
|
|
43ae065e26 | ||
|
|
dfbac7dd12 | ||
|
|
11f24c7925 | ||
|
|
68d3b080d7 | ||
|
|
c6123f1f59 | ||
|
|
4f0f0d95c9 |
@@ -101,6 +101,8 @@ Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cli
|
||||
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
|
||||
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
|
||||
|
||||
**Troisième page en place : `/outils?tab=partir-de-soi` (B6-M2)** — sept fiches, une par mot de la slide 17 (`components/OutilsPartirDeSoi.vue`, adaptateur `toMiniMot`). Trois extensions optionnelles du composant, sans effet sur `/` et `/agences` : tri `ordre` (ordre du tableau d'entrée) avec `config.triDefaut` (tri sans filtre ; la pertinence reste prioritaire dès qu'un filtre est actif) et `config.libellesTri` ; prop `libelles-vue` (`{ carte: 'Graphe' }` : le bouton, et « Voir dans le graphe » au lieu de « Voir sur la carte » ; l'URL garde `vue=carte`). Le slot `#carte` y est `GrapheMots.vue`, un SVG statique (nœud central, mots en couronne, ressources en feuilles) dans le vocabulaire du motif maillage. La page vit dans un onglet : `tab=` est un paramètre que la vue ne gère pas, donc recopié à chaque écriture.
|
||||
|
||||
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
|
||||
|
||||
## Intention d'abord
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<!--
|
||||
GrapheMots — B6-M2. Le graphe des sept mots de « Partir de soi » : un nœud
|
||||
central, les mots en couronne (ordre de la slide, sens horaire depuis le
|
||||
haut), et autour de chaque mot ses ressources en petits nœuds-feuilles.
|
||||
Vocabulaire du motif maillage (DESIGN-SYSTEM) : nœuds pleins, arêtes fines,
|
||||
un nœud central mis en avant. Statique et déterministe : pas de simulation,
|
||||
rien ne bouge ; la mise en page est une fonction pure de la taille mesurée.
|
||||
|
||||
Il vit dans le slot #carte de VueFiches et ne connaît que `vf` :
|
||||
clic / Entrée sur un mot ouvre sa fiche (`vf.ouvrirFiche`), le mot ouvert
|
||||
(`vf.ficheId`) et le mot survolé dans la liste (`vf.survolId`) s'allument,
|
||||
les mots hors du résultat (`vf.idsResultat`) s'estompent sans disparaître.
|
||||
Les feuilles ne sont pas dans l'ordre de tabulation (on y accède par leur
|
||||
mot) ; leur libellé apparaît au survol.
|
||||
-->
|
||||
<template>
|
||||
<div ref="racine" class="gm">
|
||||
<svg
|
||||
class="gm__svg"
|
||||
:viewBox="`0 0 ${taille.w} ${taille.h}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="group"
|
||||
aria-labelledby="gm-titre"
|
||||
>
|
||||
<title id="gm-titre">Graphe des sept mots de « Partir de soi » et des ressources de chacun</title>
|
||||
|
||||
<!-- Arêtes : centre → mot, mot → feuilles -->
|
||||
<g class="gm__aretes" aria-hidden="true">
|
||||
<template v-for="n in noeuds" :key="`a-${n.id}`">
|
||||
<line
|
||||
:x1="centre.x" :y1="centre.y" :x2="n.x" :y2="n.y"
|
||||
class="gm__arete" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
|
||||
/>
|
||||
<line
|
||||
v-for="f in n.feuilles" :key="`a-${n.id}-${f.id}`"
|
||||
:x1="n.x" :y1="n.y" :x2="f.x" :y2="f.y"
|
||||
class="gm__arete gm__arete--feuille" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
|
||||
<!-- Nœud central -->
|
||||
<g class="gm__centre" aria-hidden="true">
|
||||
<circle :cx="centre.x" :cy="centre.y" :r="RAYON_CENTRE" class="gm__centre-rond" />
|
||||
<text :x="centre.x" :y="centre.y - 7" class="gm__centre-texte" text-anchor="middle">Partir</text>
|
||||
<text :x="centre.x" :y="centre.y + 8" class="gm__centre-texte" text-anchor="middle">de soi</text>
|
||||
</g>
|
||||
|
||||
<!-- Feuilles (ressources) -->
|
||||
<g v-for="n in noeuds" :key="`f-${n.id}`" class="gm__feuilles" :class="{ 'gm__feuilles--hors': hors(n.id) }">
|
||||
<circle
|
||||
v-for="f in n.feuilles" :key="f.id"
|
||||
:cx="f.x" :cy="f.y" r="5.5"
|
||||
class="gm__feuille" :class="{ 'gm__feuille--vive': allume(n.id) || survolFeuille === `${n.id}/${f.id}` }"
|
||||
@click="vf.ouvrirFiche(n.id)"
|
||||
@mouseenter="survolerFeuille(n.id, f.id)"
|
||||
@mouseleave="survolerFeuille(null, null)"
|
||||
>
|
||||
<title>{{ f.titre }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Mots -->
|
||||
<g
|
||||
v-for="n in noeuds"
|
||||
:key="n.id"
|
||||
class="gm__mot"
|
||||
:class="{ 'gm__mot--actif': allume(n.id), 'gm__mot--hors': hors(n.id), 'gm__mot--vide': n.vide }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="n.aria"
|
||||
:aria-pressed="actif(n.id)"
|
||||
@click="vf.ouvrirFiche(n.id)"
|
||||
@keydown.enter.prevent="vf.ouvrirFiche(n.id)"
|
||||
@keydown.space.prevent="vf.ouvrirFiche(n.id)"
|
||||
@mouseenter="vf.survolId = n.id"
|
||||
@mouseleave="vf.survolId = null"
|
||||
@focus="vf.survolId = n.id"
|
||||
@blur="vf.survolId = null"
|
||||
>
|
||||
<rect
|
||||
:x="n.x - n.l / 2" :y="n.y - n.h / 2" :width="n.l" :height="n.h" :rx="n.h / 2"
|
||||
class="gm__pilule"
|
||||
/>
|
||||
<text :x="n.x" :y="n.y" text-anchor="middle" class="gm__libelle" aria-hidden="true">
|
||||
<tspan v-for="(ligne, i) in n.lignes" :key="i" :x="n.x" :y="n.y + (i - (n.lignes.length - 1) / 2) * INTERLIGNE + 4.5">{{ ligne }}</tspan>
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Libellé de la feuille survolée, au-dessus de tout -->
|
||||
<g v-if="bulle" class="gm__bulle" aria-hidden="true">
|
||||
<rect :x="bulle.x - bulle.l / 2" :y="bulle.y - 11" :width="bulle.l" height="22" rx="6" class="gm__bulle-fond" />
|
||||
<text :x="bulle.x" :y="bulle.y + 4" text-anchor="middle" class="gm__bulle-texte">{{ bulle.texte }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VueFichesEtat } from '~/composables/useVueFiches'
|
||||
|
||||
const props = defineProps<{
|
||||
vf: VueFichesEtat
|
||||
mots: { id: string; mot: string; ressources: { id: string; titre: string }[]; vide?: boolean }[]
|
||||
}>()
|
||||
|
||||
const RAYON_CENTRE = 36
|
||||
const INTERLIGNE = 15
|
||||
const CARACTERES_LIGNE = 16
|
||||
const PORTEE_FEUILLES = 46
|
||||
const MARGE = 10
|
||||
|
||||
// ── Taille : mesurée au montage (le rendu serveur part d'une taille plausible) ──
|
||||
const racine = ref<HTMLElement | null>(null)
|
||||
const taille = reactive({ w: 720, h: 600 })
|
||||
onMounted(() => {
|
||||
const el = racine.value
|
||||
if (!el || typeof ResizeObserver === 'undefined') return
|
||||
const maj = (w: number, h: number) => {
|
||||
if (w > 40 && h > 40) { taille.w = Math.round(w); taille.h = Math.round(h) }
|
||||
}
|
||||
maj(el.clientWidth, el.clientHeight)
|
||||
const ro = new ResizeObserver((entrees) => {
|
||||
const r = entrees[0]?.contentRect
|
||||
if (r) maj(r.width, r.height)
|
||||
})
|
||||
ro.observe(el)
|
||||
onBeforeUnmount(() => ro.disconnect())
|
||||
})
|
||||
|
||||
// ── Mise en page : fonction pure de (taille, mots) ──
|
||||
/** Une ligne si ça tient, sinon deux lignes équilibrées (« naviguer / avec soi », pas « naviguer avec / soi »). */
|
||||
function couper(texte: string): string[] {
|
||||
if (texte.length <= CARACTERES_LIGNE) return [texte]
|
||||
const mots = texte.split(' ')
|
||||
let meilleur: string[] = [texte]
|
||||
let pire = Infinity
|
||||
for (let i = 1; i < mots.length; i++) {
|
||||
const l = [mots.slice(0, i).join(' '), mots.slice(i).join(' ')]
|
||||
const m = Math.max(l[0].length, l[1].length)
|
||||
if (m < pire) { pire = m; meilleur = l }
|
||||
}
|
||||
return meilleur
|
||||
}
|
||||
|
||||
function tronquer(t: string, max = 44): string {
|
||||
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
|
||||
}
|
||||
|
||||
const centre = computed(() => ({ x: taille.w / 2, y: taille.h / 2 }))
|
||||
|
||||
const noeuds = computed(() => {
|
||||
const n = props.mots.length
|
||||
const { w, h } = taille
|
||||
const base = props.mots.map((m) => {
|
||||
const lignes = couper(m.mot)
|
||||
return { m, lignes, l: Math.max(...lignes.map((x) => x.length)) * 6.9 + 24, h: lignes.length * INTERLIGNE + 11 }
|
||||
})
|
||||
const lMax = Math.max(...base.map((b) => b.l))
|
||||
const hMax = Math.max(...base.map((b) => b.h))
|
||||
const rx = Math.max(48, w / 2 - lMax / 2 - PORTEE_FEUILLES - MARGE)
|
||||
const ry = Math.max(70, h / 2 - hMax / 2 - PORTEE_FEUILLES - MARGE)
|
||||
const cx = centre.value.x
|
||||
const cy = centre.value.y
|
||||
|
||||
// Colonne étroite : la couronne ne tient plus (les mots du bas se chevaucheraient).
|
||||
// Les mots s'étagent alors en zigzag autour du centre, une rangée chacun, dans le
|
||||
// même sens de lecture (haut, puis la droite en descendant, puis la gauche en remontant),
|
||||
// et leurs ressources se rangent SOUS le mot.
|
||||
const etroit = rx < lMax * 1.3
|
||||
let place: { x: number; y: number }[]
|
||||
if (etroit) {
|
||||
const droite = Math.ceil((n - 1) / 2)
|
||||
const rangsDroite = Array.from({ length: droite }, (_, i) => 1 + 2 * i)
|
||||
const rangsGauche = Array.from({ length: n }, (_, i) => i).filter((r) => r > 0 && !rangsDroite.includes(r)).reverse()
|
||||
const rang = (i: number) => (i === 0 ? 0 : i <= droite ? rangsDroite[i - 1] : rangsGauche[i - droite - 1])
|
||||
const y0 = hMax / 2 + MARGE
|
||||
const y1 = h - hMax / 2 - MARGE - 24 // de la place sous le dernier mot pour ses feuilles
|
||||
const pas = n > 1 ? (y1 - y0) / (n - 1) : 0
|
||||
const dx = Math.max(0, w / 2 - lMax / 2 - 6)
|
||||
place = base.map((_, i) => ({ x: i === 0 ? cx : i <= droite ? cx + dx : cx - dx, y: y0 + rang(i) * pas }))
|
||||
} else {
|
||||
place = base.map((_, i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / n // depuis le haut, sens horaire
|
||||
return { x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }
|
||||
})
|
||||
}
|
||||
|
||||
return base.map((b, i) => {
|
||||
const { x, y } = place[i]
|
||||
const k = b.m.ressources.length
|
||||
let feuilles: { id: string; titre: string; x: number; y: number }[]
|
||||
if (etroit) {
|
||||
const ecart = k > 1 ? Math.min(17, (b.l - 20) / (k - 1)) : 0
|
||||
feuilles = b.m.ressources.map((r, j) => ({
|
||||
id: r.id,
|
||||
titre: r.titre,
|
||||
x: x + (j - (k - 1) / 2) * ecart,
|
||||
y: y + b.h / 2 + 13 + (j % 2 ? 9 : 0),
|
||||
}))
|
||||
} else {
|
||||
// direction « vers l'extérieur » réelle (l'ellipse n'est pas un cercle)
|
||||
const dir = Math.atan2(y - cy, x - cx)
|
||||
const pas = Math.min(0.55, 2.3 / Math.max(k, 1))
|
||||
const dist = Math.abs(Math.cos(dir)) * (b.l / 2) + Math.abs(Math.sin(dir)) * (b.h / 2) + 20
|
||||
feuilles = b.m.ressources.map((r, j) => {
|
||||
const t = dir + (j - (k - 1) / 2) * pas
|
||||
const d = dist + (j % 2 ? 12 : 0)
|
||||
return {
|
||||
id: r.id,
|
||||
titre: r.titre,
|
||||
x: Math.min(w - 8, Math.max(8, x + d * Math.cos(t))),
|
||||
y: Math.min(h - 8, Math.max(8, y + d * Math.sin(t))),
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
id: b.m.id,
|
||||
x, y, l: b.l, h: b.h, lignes: b.lignes,
|
||||
vide: !!b.m.vide || k === 0,
|
||||
aria: `${b.m.mot}, ${k === 0 ? 'aucune ressource pour l’instant' : k === 1 ? '1 ressource' : `${k} ressources`}`,
|
||||
feuilles,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── États (tout vient de vf) ──
|
||||
const actif = (id: string) => String(props.vf.ficheId ?? '') === id
|
||||
const survol = (id: string) => props.vf.survolId != null && String(props.vf.survolId) === id
|
||||
const allume = (id: string) => actif(id) || survol(id)
|
||||
const hors = (id: string) => !props.vf.idsResultat.has(id)
|
||||
|
||||
const survolFeuille = ref<string | null>(null)
|
||||
function survolerFeuille(mot: string | null, feuille: string | null) {
|
||||
survolFeuille.value = mot && feuille ? `${mot}/${feuille}` : null
|
||||
props.vf.survolId = mot
|
||||
}
|
||||
|
||||
const bulle = computed(() => {
|
||||
if (!survolFeuille.value) return null
|
||||
const [mot, feuille] = survolFeuille.value.split('/')
|
||||
const f = noeuds.value.find((n) => n.id === mot)?.feuilles.find((x) => x.id === feuille)
|
||||
if (!f) return null
|
||||
const texte = tronquer(f.titre)
|
||||
const l = texte.length * 6.3 + 16
|
||||
const x = Math.min(taille.w - l / 2 - 4, Math.max(l / 2 + 4, f.x))
|
||||
const y = f.y < 30 ? f.y + 24 : f.y - 18
|
||||
return { x, y, l, texte }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gm {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
.gm__svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Arêtes fines */
|
||||
.gm__arete { stroke: var(--nav-primary-solid); stroke-width: 1; stroke-opacity: 0.35; }
|
||||
.gm__arete--feuille { stroke-opacity: 0.28; }
|
||||
.gm__arete--vif { stroke-width: 2; stroke-opacity: 0.8; }
|
||||
.gm__arete--hors { stroke-opacity: 0.12; }
|
||||
|
||||
/* Nœud central, mis en avant */
|
||||
.gm__centre-rond { fill: var(--nav-primary-solid); stroke: var(--accent); stroke-width: 3; }
|
||||
.gm__centre-texte { fill: var(--nav-text-on-primary); font-size: 12px; font-weight: 700; }
|
||||
|
||||
/* Feuilles : petits nœuds pleins */
|
||||
.gm__feuille {
|
||||
fill: var(--nav-primary-solid);
|
||||
stroke: var(--bg-alt);
|
||||
stroke-width: 1.5;
|
||||
cursor: pointer;
|
||||
transition: fill 120ms ease-out;
|
||||
}
|
||||
.gm__feuille--vive { fill: var(--accent); stroke: var(--nav-primary-solid); }
|
||||
.gm__feuilles--hors { opacity: 0.3; }
|
||||
|
||||
/* Mots : pastilles qui portent leur libellé */
|
||||
.gm__mot { cursor: pointer; outline: none; transition: opacity 120ms ease-out; }
|
||||
.gm__pilule {
|
||||
fill: var(--surface);
|
||||
stroke: var(--nav-primary-solid);
|
||||
stroke-width: 2;
|
||||
transition: fill 120ms ease-out, stroke 120ms ease-out;
|
||||
}
|
||||
.gm__libelle { fill: var(--text); font-size: 12.5px; font-weight: 600; pointer-events: none; }
|
||||
.gm__mot--vide .gm__pilule { stroke-dasharray: 5 4; }
|
||||
.gm__mot--actif .gm__pilule { fill: var(--accent); stroke: var(--nav-primary-solid); }
|
||||
.gm__mot--actif .gm__libelle { fill: var(--on-accent); }
|
||||
.gm__mot--hors { opacity: 0.4; }
|
||||
.gm__mot:focus-visible .gm__pilule { stroke: var(--focus-ring); stroke-width: 4; }
|
||||
|
||||
/* Libellé de feuille */
|
||||
.gm__bulle { pointer-events: none; }
|
||||
.gm__bulle-fond { fill: var(--surface); stroke: var(--nav-primary-solid); stroke-width: 1; }
|
||||
.gm__bulle-texte { fill: var(--text); font-size: 11.5px; font-weight: 500; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gm__feuille, .gm__mot, .gm__pilule { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
OutilsPartirDeSoi — B6-M2. Le sous-onglet « Partir de soi » de /outils : les
|
||||
sept mots de la slide 17 de la conférence, un mot = une fiche, rendus par
|
||||
la vue fiches partagée (état Mixte : liste des mots | graphe | fiche ouverte).
|
||||
Troisième page branchée sur VueFiches (DESIGN-SYSTEM §Brancher une troisième
|
||||
page). Les données viennent de `partir_de_soi` dans public/data/outils.json.
|
||||
|
||||
Monté seulement quand `?tab=partir-de-soi` : l'état de la vue (q, filtres,
|
||||
fiche…) vit dans l'URL et disparaît avec l'onglet. `tab=` est recopié par la
|
||||
vue à chacune de ses écritures (paramètre qu'elle ne gère pas).
|
||||
-->
|
||||
<template>
|
||||
<div class="ops">
|
||||
<VueFiches
|
||||
:vf="vf"
|
||||
intention="Sept mots de la conférence, et pour chacun des ressources libres qui m'ont aidé ou qu'on m'a confiées. Ouvre un mot."
|
||||
libelle-recherche="Rechercher une ressource…"
|
||||
:libelles-vue="{ carte: 'Graphe' }"
|
||||
:lien-page="() => null"
|
||||
>
|
||||
<template #carte>
|
||||
<GrapheMots :vf="vf" :mots="motsGraphe" />
|
||||
</template>
|
||||
|
||||
<template #detail="{ fiche, filtrer }">
|
||||
<article v-if="parId.get(String(fiche.id))" class="mot">
|
||||
<h2 class="mot__titre">{{ parId.get(String(fiche.id))!.mot }}</h2>
|
||||
<p class="mot__resume">{{ parId.get(String(fiche.id))!.resume }}</p>
|
||||
|
||||
<template v-if="parId.get(String(fiche.id))!.vide">
|
||||
<p class="mot__vide">{{ parId.get(String(fiche.id))!.vide }}</p>
|
||||
<NuxtLink to="/proposer?categorie=outils" class="mot__proposer">Proposer une ressource</NuxtLink>
|
||||
</template>
|
||||
|
||||
<ul v-else class="ress" :aria-label="`Ressources pour ${parId.get(String(fiche.id))!.mot}`">
|
||||
<li v-for="r in ressourcesDe(String(fiche.id))" :key="r.id" class="ress__carte">
|
||||
<p class="ress__titre">
|
||||
<a v-if="r.url" :href="r.url" target="_blank" rel="noopener noreferrer">{{ r.titre }}<span class="ress__ext" aria-hidden="true"> ↗</span><span class="vf-sr-mot"> (nouvel onglet)</span></a>
|
||||
<template v-else>{{ r.titre }}</template>
|
||||
</p>
|
||||
|
||||
<ChipGroup :aria-label="`Format et accès de ${r.titre}`" class="ress__chips">
|
||||
<Chip
|
||||
:label="r.format"
|
||||
variant="fonction"
|
||||
:active="(vf.etat.filtres.format ?? []).includes(slugEtiquette(r.format))"
|
||||
:aria-label="`Filtrer par format : ${r.format}`"
|
||||
@toggle="filtrer('format', slugEtiquette(r.format))"
|
||||
/>
|
||||
<Chip
|
||||
:label="r.acces"
|
||||
variant="echelle"
|
||||
:active="(vf.etat.filtres.acces ?? []).includes(slugEtiquette(r.acces))"
|
||||
:aria-label="`Filtrer par accès : ${r.acces}`"
|
||||
@toggle="filtrer('acces', slugEtiquette(r.acces))"
|
||||
/>
|
||||
</ChipGroup>
|
||||
|
||||
<p v-if="r.pourquoi" class="ress__pourquoi">{{ r.pourquoi }}</p>
|
||||
<p v-if="r.urls?.length" class="ress__liens">
|
||||
<template v-for="(u, i) in r.urls" :key="u">
|
||||
<span v-if="i > 0" aria-hidden="true"> · </span>
|
||||
<a :href="u" target="_blank" rel="noopener noreferrer">{{ u.replace(/^https:\/\//, '') }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="r.note" class="ress__note">{{ r.note }}</p>
|
||||
|
||||
<ul v-if="r.episodes?.length" class="eps" :aria-label="`Épisodes de ${r.titre}`">
|
||||
<li v-for="ep in r.episodes" :key="ep.ep" class="eps__item" :class="{ 'eps__item--muet': !ep.url }">
|
||||
<span class="eps__ep">EP/{{ ep.ep }}</span>
|
||||
<span class="eps__corps">
|
||||
<a v-if="ep.url" :href="ep.url" target="_blank" rel="noopener noreferrer" class="eps__titre">{{ ep.titre }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
|
||||
<span v-else class="eps__titre">{{ ep.titre }}</span>
|
||||
<span class="eps__personnes"> — {{ ep.personnes }}</span>
|
||||
<span v-if="ep.note" class="eps__note"> ({{ ep.note }})</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</template>
|
||||
</VueFiches>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ConfigVueFiches } from '~/utils/vueFiches'
|
||||
import { toMiniMot, valeursRessources, slugEtiquette, type FicheMot, type RessourceMot } from '~/composables/useFicheAdapter'
|
||||
|
||||
interface RessourceComplete extends RessourceMot {
|
||||
url?: string | null
|
||||
urls?: string[]
|
||||
note?: string
|
||||
episodes?: { ep: string; titre: string; personnes: string; url?: string | null; note?: string }[]
|
||||
}
|
||||
interface DonneesPartirDeSoi {
|
||||
fiches: (FicheMot & { vide?: string | null })[]
|
||||
ressources: RessourceComplete[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ data: DonneesPartirDeSoi }>()
|
||||
|
||||
const ressourcesDe = (motId: string) => props.data.ressources.filter((r) => r.fiches.includes(motId))
|
||||
const parId = computed(() => new Map(props.data.fiches.map((f) => [f.id, f])))
|
||||
|
||||
// Étape 1 : adaptateur (un mot → une MiniFiche). L'ordre du tableau est celui de la slide.
|
||||
const fichesMini = computed(() => props.data.fiches.map((f) => toMiniMot(f, ressourcesDe(f.id))))
|
||||
|
||||
// Étape 2 : configuration. Sept fiches, pas de groupe ; tri « ordre de la slide » par défaut.
|
||||
const config = computed<ConfigVueFiches>(() => ({
|
||||
dimensions: [
|
||||
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: valeursRessources(props.data.ressources, 'format') },
|
||||
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: valeursRessources(props.data.ressources, 'acces') },
|
||||
],
|
||||
tris: ['ordre', 'pertinence', 'nom'],
|
||||
triDefaut: 'ordre',
|
||||
libellesTri: { ordre: 'Ordre de la slide' },
|
||||
groupes: [],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
}))
|
||||
|
||||
const vf = useVueFiches(fichesMini, config)
|
||||
|
||||
// Étape 3 : le graphe (slot #carte) lit les mêmes données et l'état de la vue.
|
||||
const motsGraphe = computed(() =>
|
||||
props.data.fiches.map((f) => ({
|
||||
id: f.id,
|
||||
mot: f.mot,
|
||||
vide: !!f.vide,
|
||||
ressources: ressourcesDe(f.id).map((r) => ({ id: r.id, titre: r.titre })),
|
||||
})),
|
||||
)
|
||||
|
||||
// Une fiche ouverte donne son nom à l'onglet du navigateur.
|
||||
useSeoMeta({
|
||||
title: () => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — Partir de soi — AEP` : 'Outils — AEP — Partir de soi'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ops { height: 100%; min-height: 0; min-width: 0; }
|
||||
|
||||
/* Lecteurs d'écran seulement */
|
||||
.vf-sr-mot {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Fiche d'un mot */
|
||||
.mot__titre { margin: 0 0 var(--space-xs); font-size: 1.25rem; font-weight: var(--fw-bold); line-height: 1.25; }
|
||||
.mot__resume {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.55;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
.mot__vide {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mot__proposer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-md);
|
||||
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;
|
||||
}
|
||||
.mot__proposer:hover { opacity: 0.9; }
|
||||
.mot__proposer:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* Ressources */
|
||||
.ress { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.ress__carte {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
}
|
||||
.ress__carte p { margin: 0; overflow-wrap: anywhere; }
|
||||
.ress__titre { font-size: var(--fs-sm); font-weight: var(--fw-semibold); line-height: 1.35; }
|
||||
.ress__titre a,
|
||||
.ress__liens a { color: var(--nav-primary-solid); text-underline-offset: 2px; }
|
||||
.ress__titre a:focus-visible,
|
||||
.ress__liens a:focus-visible,
|
||||
.eps__titre:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
|
||||
.ress__pourquoi { font-size: var(--fs-sm); line-height: 1.5; }
|
||||
.ress__liens { font-size: var(--fs-xs, 0.75rem); }
|
||||
.ress__note { font-size: var(--fs-xs, 0.75rem); font-style: italic; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
|
||||
/* Épisodes OFQA */
|
||||
.eps { list-style: none; margin: var(--space-2xs) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
|
||||
.eps__item { display: flex; gap: var(--space-xs); align-items: baseline; font-size: var(--fs-sm); line-height: 1.4; }
|
||||
.eps__ep {
|
||||
flex: 0 0 3.6rem;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', 'Courier New', monospace);
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
.eps__corps { min-width: 0; overflow-wrap: anywhere; }
|
||||
.eps__titre { font-weight: var(--fw-medium); color: var(--text); }
|
||||
a.eps__titre { color: var(--nav-primary-solid); }
|
||||
.eps__personnes,
|
||||
.eps__note { color: color-mix(in srgb, var(--text) 78%, transparent); font-size: var(--fs-xs, 0.75rem); }
|
||||
.eps__note { font-style: italic; }
|
||||
.eps__item--muet .eps__titre { color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@
|
||||
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
|
||||
</div>
|
||||
<p class="simu-feature__desc">{{ description }}</p>
|
||||
<p v-if="mention" class="simu-feature__mention">{{ mention }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
|
||||
@@ -29,6 +30,8 @@ const props = defineProps<{
|
||||
description?: string
|
||||
cta?: string
|
||||
tag?: string
|
||||
/** Ligne de réserve datée sous la description (ex. « Ordre de grandeur — version du … »). */
|
||||
mention?: string | null
|
||||
}>()
|
||||
|
||||
const tagLabels: Record<string, string> = {
|
||||
@@ -127,6 +130,13 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.simu-feature__mention {
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.simu-feature__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -145,6 +155,12 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.simu-feature { padding: 1.1rem 1rem; }
|
||||
.simu-feature__inner { flex-direction: column; align-items: stretch; gap: 1rem; }
|
||||
.simu-feature__cta { justify-content: center; }
|
||||
}
|
||||
|
||||
:global(.dark) .simu-feature__badge {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
:class="`vf-seg__btn--${e}`"
|
||||
:aria-pressed="vf.vueAffichee === e"
|
||||
@click="vf.setVue(e)"
|
||||
>{{ LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
|
||||
>{{ libellesVue?.[e] ?? LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
|
||||
</div>
|
||||
|
||||
<label class="vf-recherche">
|
||||
@@ -79,7 +79,7 @@
|
||||
<label class="vf-select vf-select--tri">
|
||||
<span>Tri</span>
|
||||
<select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)">
|
||||
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ LIBELLES_TRI[t] }}</option>
|
||||
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ vf.cfg.libellesTri?.[t] ?? LIBELLES_TRI[t] }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
<template v-if="vf.ficheOuverte">
|
||||
<div class="vf-detail__actions">
|
||||
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
|
||||
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button>
|
||||
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">{{ libelleVoirCarte }}</button>
|
||||
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
|
||||
<span class="vf-detail__statut" role="status">{{ statutLien }}</span>
|
||||
</div>
|
||||
@@ -311,6 +311,8 @@ const props = defineProps<{
|
||||
selectedId?: string | number | null
|
||||
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
|
||||
lienPage?: (f: MiniFiche) => string | null
|
||||
/** Libellés des trois états quand « Carte » n'est pas le mot juste (ex. { carte: 'Graphe' }). Le paramètre d'URL reste `vue=carte`. */
|
||||
libellesVue?: Partial<Record<EtatVue, string>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -319,6 +321,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' }
|
||||
/** « Voir sur la carte » suit le mot choisi pour l'état Carte : « Voir dans le graphe ». */
|
||||
const libelleVoirCarte = computed(() => (props.libellesVue?.carte ? `Voir dans le ${props.libellesVue.carte.toLowerCase()}` : 'Voir sur la carte'))
|
||||
const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e)))
|
||||
|
||||
// ── Pastilles actives (état Carte, et téléphone en Fiches) ──
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface MiniFiche {
|
||||
chipsDimension?: string
|
||||
echelle?: string
|
||||
geoloc: boolean
|
||||
kind: 'org' | 'structure'
|
||||
kind: 'org' | 'structure' | 'mot'
|
||||
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
|
||||
etiquettes?: Record<string, string[]>
|
||||
coords?: [number, number]
|
||||
@@ -134,6 +134,65 @@ export function toMini(source: Org | StructureV2): MiniFiche {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partir de soi (B6-M2) : un mot de la slide 17 = une fiche ─────────────
|
||||
|
||||
/** Une fiche de `public/data/outils.json` → `partir_de_soi.fiches[]`. */
|
||||
export interface FicheMot {
|
||||
id: string
|
||||
mot: string
|
||||
resume: string
|
||||
vide?: string | null
|
||||
}
|
||||
|
||||
/** Une ressource de `partir_de_soi.ressources[]`, reliée à ses mots par `fiches`. */
|
||||
export interface RessourceMot {
|
||||
id: string
|
||||
titre: string
|
||||
format: string
|
||||
acces: string
|
||||
pourquoi?: string
|
||||
fiches: string[]
|
||||
}
|
||||
|
||||
/** « don libre » → `don-libre` : la forme d'une valeur de filtre dans l'URL (`acces=don-libre`). */
|
||||
export function slugEtiquette(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/** Valeurs distinctes d'un champ des ressources, dans l'ordre de première apparition (ordre stable des chips). */
|
||||
export function valeursRessources(ressources: RessourceMot[], champ: 'format' | 'acces'): { valeur: string; libelle: string }[] {
|
||||
const vus = new Map<string, string>()
|
||||
for (const r of ressources) {
|
||||
const l = r[champ]
|
||||
if (l && !vus.has(slugEtiquette(l))) vus.set(slugEtiquette(l), l)
|
||||
}
|
||||
return [...vus].map(([valeur, libelle]) => ({ valeur, libelle }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Un mot de la conférence → `MiniFiche`. `ressources` = celles qui portent ce mot.
|
||||
* Pas de carte géographique : `geoloc: true` (aucune fiche « sans adresse »).
|
||||
* La recherche couvre aussi les titres et les « pourquoi » des ressources.
|
||||
*/
|
||||
export function toMiniMot(fiche: FicheMot, ressources: RessourceMot[]): MiniFiche {
|
||||
const formats = [...new Set(ressources.map((r) => r.format).filter(Boolean))]
|
||||
const acces = [...new Set(ressources.map((r) => r.acces).filter(Boolean))]
|
||||
return {
|
||||
id: fiche.id,
|
||||
nom: fiche.mot,
|
||||
resume: buildResume([fiche.resume, fiche.vide]),
|
||||
chips: formats.slice(0, 3),
|
||||
chipsDimension: 'format',
|
||||
geoloc: true,
|
||||
kind: 'mot',
|
||||
etiquettes: {
|
||||
format: formats.map(slugEtiquette),
|
||||
acces: acces.map(slugEtiquette),
|
||||
},
|
||||
texte: [fiche.resume, ...ressources.flatMap((r) => [r.titre, r.pourquoi])].filter(Boolean).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
return { toMini }
|
||||
return { toMini, toMiniMot }
|
||||
}
|
||||
|
||||
+492
-527
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "FMHY — Sélection Architecte",
|
||||
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
|
||||
"description": "Une quarantaine de ressources légales curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
|
||||
"children": [
|
||||
{
|
||||
"name": "IA & Outils cognitifs",
|
||||
@@ -16,10 +16,6 @@
|
||||
{
|
||||
"name": "Lecture & Documentation",
|
||||
"children": [
|
||||
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
|
||||
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
|
||||
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
|
||||
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
|
||||
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
|
||||
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
|
||||
]
|
||||
@@ -27,12 +23,7 @@
|
||||
{
|
||||
"name": "Dessin & Modélisation",
|
||||
"children": [
|
||||
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." },
|
||||
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
|
||||
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
|
||||
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
|
||||
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
|
||||
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
|
||||
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -92,7 +83,6 @@
|
||||
"name": "Divers Utiles",
|
||||
"children": [
|
||||
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
|
||||
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
|
||||
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
|
||||
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
|
||||
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
|
||||
|
||||
+930
-27
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"simulateurs": [
|
||||
"version": 2,
|
||||
"maj": "2026-10-08",
|
||||
"intro": "En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin, calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel utiliser quand : c'est une forme d'entraide concrète. Voici ceux que je propose dans un premier temps. Chacun peut contribuer pour enrichir cette boîte à outils commune.",
|
||||
"conception": {
|
||||
"intention": "Les outils de calcul situés d'AEP, pour cadrer un projet avant de dessiner. Ouvre un simulateur et lis le résultat comme un ordre de grandeur.",
|
||||
"outils": [
|
||||
{
|
||||
"id": "autonomie",
|
||||
"icon": "🟢",
|
||||
@@ -7,20 +12,444 @@
|
||||
"url": "https://calculs.trans-former.fr/autonomie/",
|
||||
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
|
||||
"cta": "Lancer le simulateur →",
|
||||
"tag": "outil-aep"
|
||||
"tag": "outil-aep",
|
||||
"mention": "Ordre de grandeur — version du 16/08/2026",
|
||||
"verifie_le": "2026-10-08"
|
||||
},
|
||||
{
|
||||
"id": "autoconstruction",
|
||||
"icon": "🔨",
|
||||
"titre": "Simulateur Autoconstruction",
|
||||
"url": "https://calculs.trans-former.fr/autoconstruction/",
|
||||
"description": "Cadrer un projet d'autoconstruction : quantités et coûts des matériaux, surfaces, géométrie, calculés en temps réel.",
|
||||
"cta": "Lancer le simulateur →",
|
||||
"tag": "outil-aep",
|
||||
"mention": "Ordre de grandeur — version du 16/08/2026",
|
||||
"verifie_le": "2026-10-08"
|
||||
}
|
||||
],
|
||||
"simulateurs_inspirations": [
|
||||
"inspirations": [
|
||||
{
|
||||
"id": "florquin-prix-m2",
|
||||
"icon": "💡",
|
||||
"titre": "Estimation prix au m² — Florquin Studio",
|
||||
"url": "https://offre.florquinstudio.com/",
|
||||
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
|
||||
"tag": "inspiration-externe"
|
||||
"tag": "inspiration-externe",
|
||||
"mention": null,
|
||||
"verifie_le": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"open_source": {
|
||||
"intention": "Des outils libres choisis pour un architecte indépendant, puis nos recommandations maison et un annuaire du web libre classé par usage. Choisis un bloc dans le sommaire ou fais défiler.",
|
||||
"selection": {
|
||||
"intention": "Une sélection d'outils libres utiles au quotidien d'un architecte indépendant : dictée, bureautique, dessin et modélisation, cartes et données publiques. Chacun a été ouvert et vérifié le 8 octobre 2026, avec sa licence et ses plateformes.",
|
||||
"rubriques": [
|
||||
{
|
||||
"id": "dictee-voix",
|
||||
"titre": "Dictée et voix",
|
||||
"note": "Ces outils font tourner Whisper sur ton ordinateur : rien ne part dans le cloud, ce qui compte quand tu dictes un CR de chantier ou un échange client.",
|
||||
"liens": [
|
||||
{
|
||||
"nom": "Handy",
|
||||
"url": "https://handy.computer/",
|
||||
"desc": "Raccourci clavier, tu parles, le texte s'insère au curseur dans n'importe quelle application : dicter un mail ou une note de chantier sans quitter le logiciel ouvert, avec transcription locale.",
|
||||
"source": "aep",
|
||||
"licence": "MIT",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"opensource": [
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v0.9.8 du 2026-10-03)"
|
||||
},
|
||||
{
|
||||
"nom": "Buzz",
|
||||
"url": "https://buzzcaptions.com/",
|
||||
"desc": "Transcrit un enregistrement de réunion ou de visite (fichier audio ou vidéo) en texte, SRT ou TXT, hors ligne : base brute pour un compte rendu.",
|
||||
"source": "aep",
|
||||
"licence": "MIT",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v1.4.5 du 2026-08-23)"
|
||||
},
|
||||
{
|
||||
"nom": "Vibe",
|
||||
"url": "https://github.com/thewh1teagle/vibe",
|
||||
"desc": "Transcription locale de fichiers audio et vidéo, interface simple : pratique pour transformer un vocal de visite en texte à retravailler.",
|
||||
"source": "aep",
|
||||
"licence": "MIT",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v3.2.2 du 2026-09-05)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "quotidien",
|
||||
"titre": "Le quotidien d'une agence",
|
||||
"note": "De quoi tenir un cabinet d'architecte sans abonnement : bureautique, courrier, notes, PDF, visio, devis et factures.",
|
||||
"liens": [
|
||||
{
|
||||
"nom": "LibreOffice",
|
||||
"url": "https://www.libreoffice.org/",
|
||||
"desc": "Rédiger CCTP, notes de calcul, tableurs de métrés et devis au format Word/Excel/PDF sans licence Microsoft.",
|
||||
"source": "aep",
|
||||
"licence": "MPL-2.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-08 (v26.8.1)"
|
||||
},
|
||||
{
|
||||
"nom": "Thunderbird",
|
||||
"url": "https://www.thunderbird.net/fr/",
|
||||
"desc": "Client mail et agenda pour centraliser plusieurs adresses (pro, projets, associatives) hors webmail, avec filtres et signatures.",
|
||||
"source": "aep",
|
||||
"licence": "MPL-2.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux",
|
||||
"Android"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026 (v157.0.1 sur le site le 2026-10-08)"
|
||||
},
|
||||
{
|
||||
"nom": "Joplin",
|
||||
"url": "https://joplinapp.org/",
|
||||
"desc": "Notes en markdown synchronisées sur téléphone et ordinateur (chiffrées de bout en bout) : carnet de chantier, listes de réserves, recherche rapide.",
|
||||
"source": "aep",
|
||||
"licence": "AGPL-3.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux",
|
||||
"Android",
|
||||
"iOS"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-09 (v3.7.21 du 2026-09-25)"
|
||||
},
|
||||
{
|
||||
"nom": "Stirling-PDF",
|
||||
"url": "https://github.com/Stirling-Tools/Stirling-PDF",
|
||||
"desc": "Fusionner, découper, compresser, signer ou passer en OCR un PDF (dossier de DP, pièces d'un marché) sans envoyer les plans chez un service tiers quand on l'installe en local.",
|
||||
"source": "aep",
|
||||
"licence": "MIT (cœur ; fonctions entreprise sous licence propriétaire, non nécessaires)",
|
||||
"plateformes": [
|
||||
"Web (auto-hébergé)",
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v3.1.0 du 2026-10-05)"
|
||||
},
|
||||
{
|
||||
"nom": "PDF Arranger",
|
||||
"url": "https://github.com/pdfarranger/pdfarranger",
|
||||
"desc": "Réordonner, pivoter, extraire ou fusionner les pages d'un PDF à la souris : composer un dossier de consultation propre en deux minutes.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-3.0",
|
||||
"plateformes": [
|
||||
"Linux",
|
||||
"Windows"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v1.14.0 du 2026-05-23, dépôt actif le 2026-10-01)"
|
||||
},
|
||||
{
|
||||
"nom": "Xournal++",
|
||||
"url": "https://xournalpp.github.io/",
|
||||
"desc": "Annoter un plan ou un PDF au stylet sur tablette ou écran tactile : markup de visite, retours sur un plan d'exécution.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-2.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-09 (v1.3.8 du 2026-09-26)"
|
||||
},
|
||||
{
|
||||
"nom": "Jitsi Meet",
|
||||
"url": "https://jitsi.org/jitsi-meet/",
|
||||
"desc": "Visioconférence sans compte, avec partage d'écran, pour une réunion de chantier à distance ou un rendez-vous client ; instance publique gratuite ou auto-hébergeable.",
|
||||
"source": "aep",
|
||||
"licence": "Apache-2.0",
|
||||
"plateformes": [
|
||||
"Web",
|
||||
"Android",
|
||||
"iOS"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-09 (version stable du 2026-09-14)"
|
||||
},
|
||||
{
|
||||
"nom": "CryptPad",
|
||||
"url": "https://cryptpad.org/",
|
||||
"desc": "Documents, tableurs et tableaux partagés en ligne, chiffrés : co-éditer un CR ou un planning avec un client sans lui demander de créer un compte.",
|
||||
"source": "aep",
|
||||
"licence": "AGPL-3.0",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v2026.9.0 du 2026-10-07)"
|
||||
},
|
||||
{
|
||||
"nom": "Dolibarr",
|
||||
"url": "https://www.dolibarr.org/",
|
||||
"desc": "ERP léger auto-hébergeable : devis, factures, suivi des paiements pour une agence ou un freelance qui veut sortir du tableur.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-3.0",
|
||||
"plateformes": [
|
||||
"Web (auto-hébergé)"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-09 (v24.0.1 du 2026-09-07)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dessiner-modeliser",
|
||||
"titre": "Dessiner, modéliser, construire",
|
||||
"note": "La chaîne libre de la CAO au BIM, de l'illustration à la maquette. FreeCAD et Bonsai sont sérieux mais demandent plusieurs semaines de prise en main.",
|
||||
"liens": [
|
||||
{
|
||||
"nom": "FreeCAD",
|
||||
"url": "https://www.freecad.org/",
|
||||
"desc": "Modélisation paramétrique 3D avec atelier BIM, coupes et mise en plan : modéliser un projet et sortir des plans sans licence.",
|
||||
"source": "aep",
|
||||
"licence": "LGPL-2.1",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (v1.1.4 du 2026-09-28)"
|
||||
},
|
||||
{
|
||||
"nom": "Bonsai (ex BlenderBIM)",
|
||||
"url": "https://bonsaibim.org/",
|
||||
"desc": "Créer et lire des modèles IFC (OpenBIM) dans Blender : produire un modèle BIM échangeable avec un bureau d'études sans passer par Revit ou ArchiCAD.",
|
||||
"source": "aep",
|
||||
"licence": "LGPL-3.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (build alpha du 2026-10-07, dépôt IfcOpenShell)"
|
||||
},
|
||||
{
|
||||
"nom": "Blender",
|
||||
"url": "https://www.blender.org/",
|
||||
"desc": "Modélisation, rendu photoréaliste et animation pour illustrer un concours ou une esquisse ; socle de Bonsai.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-3.0-or-later",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026 (v5.2 en téléchargement le 2026-10-08)"
|
||||
},
|
||||
{
|
||||
"nom": "LibreCAD",
|
||||
"url": "https://librecad.org/",
|
||||
"desc": "Dessin 2D en DXF : reprendre un relevé, redessiner un plan de masse ou préparer un fichier pour la découpe, sans licence AutoCAD (pas de DWG natif).",
|
||||
"source": "aep",
|
||||
"licence": "GPL-2.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-05 (v2.2.1.5 du 2026-05-02, dépôt actif le 2026-10-07)"
|
||||
},
|
||||
{
|
||||
"nom": "Inkscape",
|
||||
"url": "https://inkscape.org/fr/",
|
||||
"desc": "Dessin vectoriel : mettre au propre un schéma de principe, un plan-masse ou une planche de concours, export SVG et PDF.",
|
||||
"source": "aep",
|
||||
"licence": "GPL",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (commits du 2026-10-08)"
|
||||
},
|
||||
{
|
||||
"nom": "GIMP",
|
||||
"url": "https://www.gimp.org/",
|
||||
"desc": "Retouche d'image : traiter des photos de visite, détourer, préparer des photomontages et des planches.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-3.0-or-later",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (actualités du 2026-10-01)"
|
||||
},
|
||||
{
|
||||
"nom": "WikiHouse Skylark",
|
||||
"url": "https://github.com/wikihouseproject/Skylark",
|
||||
"desc": "Système constructif open source en blocs isolants, plans de fabrication CNC publiés : référence pour étudier ou proposer une ossature bois partagée ; à faire valider par un ingénieur structure.",
|
||||
"source": "aep",
|
||||
"licence": "CC BY-SA 4.0",
|
||||
"plateformes": [
|
||||
"Web (dépôt de fichiers CAO)"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-10 (commit du 2026-10-08)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cartes-territoire",
|
||||
"titre": "Cartes, cadastre et territoire",
|
||||
"note": "Les données publiques utiles avant une étude de faisabilité : parcelle, urbanisme, risques, histoire du site.",
|
||||
"liens": [
|
||||
{
|
||||
"nom": "QGIS",
|
||||
"url": "https://qgis.org/",
|
||||
"desc": "Système d'information géographique complet : croiser cadastre, PLU, relief et servitudes sur un même plan-masse, export DXF.",
|
||||
"source": "aep",
|
||||
"licence": "GPL-2.0",
|
||||
"plateformes": [
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "2026-09 (v3.44.15 du 2026-09-25)"
|
||||
},
|
||||
{
|
||||
"nom": "Géoportail (cartes.gouv.fr)",
|
||||
"url": "https://cartes.gouv.fr/explorer-les-cartes/",
|
||||
"desc": "Fonds IGN, cadastre, orthophotos et relief : repérer une parcelle, mesurer, imprimer un extrait ; l'ancien géoportail.gouv.fr redirige ici.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Cadastre – données ouvertes",
|
||||
"url": "https://cadastre.data.gouv.fr/",
|
||||
"desc": "Plan cadastral vectoriel et fichiers par commune en téléchargement libre, à charger dans QGIS pour un plan de situation.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Géoportail de l'Urbanisme",
|
||||
"url": "https://www.geoportail-urbanisme.gouv.fr/",
|
||||
"desc": "PLU, PLUi, cartes communales et servitudes opposables : la source officielle pour vérifier la règle d'une parcelle avant de rédiger une étude.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Géorisques",
|
||||
"url": "https://www.georisques.gouv.fr/",
|
||||
"desc": "Inondation, argiles, radon, sismicité, sites pollués pour une adresse : le contrôle de base avant d'acheter ou de chiffrer un projet.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Remonter le temps (IGN)",
|
||||
"url": "https://remonterletemps.ign.fr/",
|
||||
"desc": "Cartes et photos aériennes anciennes d'un lieu : lire l'histoire d'un site, repérer d'anciennes constructions ou d'anciens usages.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "API Carto (IGN)",
|
||||
"url": "https://apicarto.ign.fr/api/doc/",
|
||||
"desc": "Interroger parcelles, zonage et servitudes par API : base technique pour automatiser un plan de situation.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Demandes de valeurs foncières",
|
||||
"url": "https://app.dvf.etalab.gouv.fr/",
|
||||
"desc": "Prix de vente des biens par commune ou parcelle : étayer une fourchette d'estimation ou un argumentaire de réhabilitation.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
},
|
||||
{
|
||||
"nom": "Cartofriches (Cerema)",
|
||||
"url": "https://cartofriches.cerema.fr/cartofriches/",
|
||||
"desc": "Recensement national des friches : repérer des sites à reconvertir pour une approche de réhabilitation ou de renaturation.",
|
||||
"source": "aep",
|
||||
"licence": "Service public, gratuit",
|
||||
"plateformes": [
|
||||
"Web"
|
||||
],
|
||||
"verifie_le": "2026-10-08",
|
||||
"derniere_activite": "service public en ligne, ouvert le 2026-10-08"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"recommandes": [
|
||||
{
|
||||
"id": "dictee-universelle-groq",
|
||||
"icon": "🎤",
|
||||
@@ -54,37 +483,511 @@
|
||||
"tag": "a-venir"
|
||||
}
|
||||
],
|
||||
"bifurcation": {
|
||||
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.",
|
||||
"videos_ofqa": [
|
||||
"rubriques": [
|
||||
{
|
||||
"id": "vie-privee",
|
||||
"titre": "Vie privée & adblocking — les bases",
|
||||
"note": "Le premier réflexe du guide débutant fmhy : un bon bloqueur de pubs rend la moitié du web à nouveau respirable.",
|
||||
"liens": [
|
||||
{ "nom": "uBlock Origin", "url": "https://github.com/gorhill/uBlock", "desc": "bloqueur de pubs de référence, léger et open source", "source": "fmhy" },
|
||||
{ "nom": "SponsorBlock", "url": "https://sponsor.ajay.app/", "desc": "saute automatiquement les segments sponsorisés de YouTube", "source": "fmhy" },
|
||||
{ "nom": "LibreWolf", "url": "https://librewolf.net/", "desc": "Firefox durci vie privée, pré-configuré", "source": "fmhy" },
|
||||
{ "nom": "Proton VPN", "url": "https://protonvpn.com", "desc": "VPN suisse, offre gratuite illimitée", "source": "fmhy" },
|
||||
{ "nom": "Bitwarden", "url": "https://bitwarden.com/", "desc": "gestionnaire de mots de passe open source", "source": "fmhy" },
|
||||
{ "nom": "Cryptomator", "url": "https://cryptomator.org/", "desc": "chiffrement de tes fichiers avant envoi dans le cloud", "source": "fmhy" },
|
||||
{ "nom": "Tor Browser", "url": "https://www.torproject.org/", "desc": "navigation anonyme via le réseau Tor", "source": "fmhy" },
|
||||
{ "nom": "KeePassXC", "url": "https://keepassxc.org/", "desc": "coffre de mots de passe 100% local", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "livres",
|
||||
"titre": "Lire : domaine public & liseuses",
|
||||
"liens": [
|
||||
{ "nom": "Project Gutenberg", "url": "https://www.gutenberg.org/", "desc": "70 000+ classiques du domaine public, 100% légal", "source": "fmhy" },
|
||||
{ "nom": "Calibre", "url": "https://calibre-ebook.com/", "desc": "gestion et conversion de bibliothèque numérique", "source": "fmhy" },
|
||||
{ "nom": "KOReader", "url": "https://koreader.rocks/", "desc": "lecteur ebook open source pour liseuses", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "films",
|
||||
"titre": "Films & séries en accès légal",
|
||||
"liens": [
|
||||
{ "nom": "Tubi", "url": "https://tubitv.com", "desc": "streaming légal et gratuit (financé par la pub)", "source": "fmhy" },
|
||||
{ "nom": "Stremio", "url": "https://www.stremio.com/", "desc": "centre multimédia extensible par extensions", "source": "fmhy" },
|
||||
{ "nom": "Simkl", "url": "https://simkl.com/", "desc": "suivi de films, séries et anime déjà vus", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "musique",
|
||||
"titre": "Radios & podcasts",
|
||||
"liens": [
|
||||
{ "nom": "NTS Radio", "url": "https://www.nts.live/", "desc": "shows de DJ indépendants, archives immenses", "source": "fmhy" },
|
||||
{ "nom": "SomaFM", "url": "https://somafm.com/", "desc": "30+ radios internet indépendantes", "source": "fmhy" },
|
||||
{ "nom": "Radio Garden", "url": "https://radio.garden/", "desc": "explorer les radios du monde entier sur un globe", "source": "fmhy" },
|
||||
{ "nom": "Pocket Casts", "url": "https://play.pocketcasts.com/", "desc": "lecteur de podcasts libre et complet, aussi dans le navigateur", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "logiciels",
|
||||
"titre": "Logiciels libres",
|
||||
"liens": [
|
||||
{ "nom": "qBittorrent", "url": "https://www.qbittorrent.org/", "desc": "client BitTorrent libre, sans pub — la référence", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mobile",
|
||||
"titre": "Android / iOS",
|
||||
"liens": [
|
||||
{ "nom": "Aurora Store", "url": "https://auroraoss.com/", "desc": "Google Play alternatif, sans compte Google", "source": "fmhy" },
|
||||
{ "nom": "Droid-ify", "url": "https://droidify.app/", "desc": "client F-Droid moderne — applications libres", "source": "fmhy" },
|
||||
{ "nom": "APKMirror", "url": "https://www.apkmirror.com/", "desc": "archives APK vérifiées", "source": "fmhy" },
|
||||
{ "nom": "Fossify", "url": "https://github.com/FossifyOrg", "desc": "suite d’apps Android simples et libres (galerie, agenda, fichiers…)", "source": "fmhy" },
|
||||
{ "nom": "Obtainium", "url": "https://obtainium.imranr.dev/", "desc": "installe et met à jour tes APKs depuis les sources officielles", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "education",
|
||||
"titre": "Éducation, cours & recherche",
|
||||
"liens": [
|
||||
{ "nom": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "les cours du MIT en accès libre complet", "source": "fmhy" },
|
||||
{ "nom": "edX", "url": "https://www.edx.org/", "desc": "MOOCs de grandes universités", "source": "fmhy" },
|
||||
{ "nom": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "maths, sciences, économie — gratuit, du collège au supérieur", "source": "fmhy" },
|
||||
{ "nom": "Class Central", "url": "https://www.classcentral.com/", "desc": "moteur de recherche de cours en ligne", "source": "fmhy" },
|
||||
{ "nom": "WolframAlpha", "url": "https://www.wolframalpha.com/", "desc": "moteur de calcul et base de connaissances", "source": "fmhy" },
|
||||
{ "nom": "PhET", "url": "https://phet.colorado.edu/", "desc": "simulations interactives physique / chimie / maths", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ia",
|
||||
"titre": "Intelligence artificielle gratuite",
|
||||
"liens": [
|
||||
{ "nom": "DeepSeek", "url": "https://chat.deepseek.com/", "desc": "modèle de pointe, usage illimité gratuit", "source": "fmhy" },
|
||||
{ "nom": "Qwen Studio", "url": "https://chat.qwen.ai/", "desc": "chat IA (Qwen) illimité gratuit", "source": "fmhy" },
|
||||
{ "nom": "Google AI Studio", "url": "https://aistudio.google.com/", "desc": "Gemini en accès généreux gratuit", "source": "fmhy" },
|
||||
{ "nom": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, gratuit", "source": "fmhy" },
|
||||
{ "nom": "LM Studio", "url": "https://lmstudio.ai/", "desc": "faire tourner des modèles en local, sans cloud", "source": "fmhy" },
|
||||
{ "nom": "Jan", "url": "https://jan.ai/", "desc": "application desktop libre pour IA locale", "source": "fmhy" },
|
||||
{ "nom": "Open WebUI", "url": "https://openwebui.com/", "desc": "interface web auto-hébergeable pour modèles locaux", "source": "fmhy" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "francophone",
|
||||
"titre": "Contenus francophones",
|
||||
"liens": [
|
||||
{ "nom": "Gallica", "url": "https://gallica.bnf.fr/", "desc": "millions de documents numérisés de la BnF, domaine public", "source": "fmhy" },
|
||||
{ "nom": "Nos Livres", "url": "https://noslivres.net/", "desc": "classiques français du domaine public, formats propres", "source": "fmhy" },
|
||||
{ "nom": "Audiocite", "url": "https://www.audiocite.net/", "desc": "livres audio francophones du domaine public", "source": "fmhy" },
|
||||
{ "nom": "Littérature audio", "url": "https://www.litteratureaudio.com/", "desc": "autre grande bibliothèque audio francophone libre", "source": "fmhy" },
|
||||
{ "nom": "Dealabs", "url": "https://www.dealabs.com/", "desc": "bons plans et gratuités débusqués par la communauté", "source": "fmhy" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"fmhy": {
|
||||
"snapshot": "2026-08-25",
|
||||
"url": "https://fmhy.net/",
|
||||
"titre": "Annuaire du web libre — la cartographie FMHY",
|
||||
"texte_intro": "FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources gratuites du web : des milliers de liens classés et vérifiés par des centaines de contributeurs bénévoles, mis à jour en continu. C'est une forme de résistance numérique — accéder librement au savoir, à la culture et aux outils, hors des catalogues marchands. J'en ai traduit et organisé les entrées phares par usage, pour aller droit au but.",
|
||||
"avertissement": "Cette page ne relaie que des ressources légales. FMHY indexe aussi des contenus protégés, que cette page ne reprend pas. FMHY maintient une liste des sites dangereux (malwares, arnaques) : consulte-la avant de t'aventurer au-delà de cette sélection.",
|
||||
"avertissement_lien": "https://fmhy.net/unsafe",
|
||||
"avertissement_lien_texte": "liste des sites dangereux",
|
||||
"snapshot_texte": "Cartographie établie d'après un snapshot du 25 août 2026 — l'index fmhy évolue en continu, voir",
|
||||
"arbre_label": "Sélection interactive — clique sur les branches pour déplier",
|
||||
"lien_texte": "Explorer tout l'arbre → fmhy.net"
|
||||
},
|
||||
"logiciels_pro": {
|
||||
"badge": "Bientôt — nécessite un compte",
|
||||
"titre": "Logiciels pro",
|
||||
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
|
||||
"status": "bientot-login"
|
||||
}
|
||||
},
|
||||
"partir_de_soi": {
|
||||
"intention": "Des ressources libres ou à porte gratuite, rangées sous les sept mots de la tête d'échelle. Cherche ton mot et ouvre ce qui te parle.",
|
||||
"fiches": [
|
||||
{
|
||||
"id": "quete-de-sens",
|
||||
"mot": "quête de sens",
|
||||
"resume": "Bifurquer, donner du sens à son métier : un film, des témoignages d'architectes qui ont changé de voie, des podcasts.",
|
||||
"vide": null
|
||||
},
|
||||
{
|
||||
"id": "ou-habiter",
|
||||
"mot": "où habiter, avec qui",
|
||||
"resume": "Habiter avec d'autres : un guide pour monter un projet d'habitat participatif.",
|
||||
"vide": null
|
||||
},
|
||||
{
|
||||
"id": "gagner-sa-vie",
|
||||
"mot": "gagner sa vie",
|
||||
"resume": "Créer son activité et en vivre.",
|
||||
"vide": "Rien encore : la conférence promettait des formations en accès libre (business, gestion du temps), aucune ressource libre ne les porte aujourd'hui. Si tu en connais une, propose-la."
|
||||
},
|
||||
{
|
||||
"id": "sante-mentale",
|
||||
"mot": "santé mentale",
|
||||
"resume": "Repérer, comprendre, se faire accompagner : premiers secours en santé mentale, emprise, TDAH.",
|
||||
"vide": null
|
||||
},
|
||||
{
|
||||
"id": "mener-ses-projets",
|
||||
"mot": "mener ses projets",
|
||||
"resume": "Faire le bilan et se projeter, clarifier son après-diplôme. « Gérer son temps » est promis et pas encore porté.",
|
||||
"vide": null
|
||||
},
|
||||
{
|
||||
"id": "naviguer-avec-soi",
|
||||
"mot": "naviguer avec soi",
|
||||
"resume": "Méditation et pratiques d'introspection accessibles sans payer.",
|
||||
"vide": null
|
||||
},
|
||||
{
|
||||
"id": "competences-psychosociales",
|
||||
"mot": "les compétences psychosociales",
|
||||
"resume": "Le cadre de référence, la communication non violente, la facilitation, la confiance en soi.",
|
||||
"vide": null
|
||||
}
|
||||
],
|
||||
"ressources": [
|
||||
{
|
||||
"id": "en-quete-de-sens",
|
||||
"titre": "En quête de sens (Coste, de la Ménardière)",
|
||||
"url": "https://enquetedesens-lefilm.com",
|
||||
"format": "film",
|
||||
"acces": "don libre",
|
||||
"pourquoi": "Le film nommé dans le deck ; streaming à participation libre.",
|
||||
"fiches": ["quete-de-sens"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "lecteur non testé (rendu JS)"
|
||||
},
|
||||
{
|
||||
"id": "ofqa",
|
||||
"titre": "OFQA, « On fait quoi après ? »",
|
||||
"url": null,
|
||||
"format": "vidéo",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Diplômés d'archi qui ont bifurqué (indépendant, artisanat, éco-construction).",
|
||||
"fiches": ["quete-de-sens"],
|
||||
"source": "outils.json (avant B6)",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"episodes": [
|
||||
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
|
||||
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
|
||||
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
|
||||
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
|
||||
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
|
||||
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" },
|
||||
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" },
|
||||
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" },
|
||||
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": null, "note": "lien en attente d'ouverture du partage" },
|
||||
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": null, "note": "lien en attente d'ouverture du partage" },
|
||||
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": null, "note": "lien en attente d'ouverture du partage" },
|
||||
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
|
||||
],
|
||||
"coalition_ensa_pb": {
|
||||
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
|
||||
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
|
||||
]
|
||||
},
|
||||
"ressources_externes": [
|
||||
{
|
||||
"id": "drop-the-kutch",
|
||||
"icon": "🎧",
|
||||
"titre": "Podcast Drop the Kutch — Sâm Afchar",
|
||||
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch",
|
||||
"description": "Témoignages de bifurcations post études d'archi. Super taf."
|
||||
"id": "drop-the-kutsch",
|
||||
"titre": "Drop the Kutsch (Sâm Afchar)",
|
||||
"url": "https://podcasts.apple.com/fr/podcast/drop-the-kutsch/id1536280194",
|
||||
"format": "podcast",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Témoignages d'architectes sortis de l'agence.",
|
||||
"fiches": ["quete-de-sens"],
|
||||
"source": "outils.json (avant B6)",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "ca-commence-par-moi",
|
||||
"titre": "Ça commence par moi (Julien Vidal), épisode « Mon métier aura du sens »",
|
||||
"url": "https://cacommenceparmoi.org/blog/2024/04/07/2030-glorieuses-111-caroline-sicard-et-julien-vidal-mon-metier-aura-du-sens/",
|
||||
"format": "podcast",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Sens au travail et reconversion, par l'auteur du livre de la sélection.",
|
||||
"fiches": ["quete-de-sens"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "coalition-ensa-pb",
|
||||
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
|
||||
"url": null,
|
||||
"format": "récit",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif.",
|
||||
"fiches": ["quete-de-sens"],
|
||||
"source": "outils.json (avant B6)",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-10-08"
|
||||
},
|
||||
{
|
||||
"id": "regain-habitat-participatif",
|
||||
"titre": "Guide pratique de l'habitat participatif (REGAIN)",
|
||||
"url": "https://www.regain-hg.org/wp-content/uploads/2021/06/REGAIN-GUIDE-HABITAT-PARTICIPATIF-MD.pdf",
|
||||
"format": "guide PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Monter un projet d'habitat participatif, débouché direct pour un architecte.",
|
||||
"fiches": ["ou-habiter"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "pssm-france",
|
||||
"titre": "PSSM France, premiers secours en santé mentale",
|
||||
"url": "https://www.pssmfrance.fr/",
|
||||
"format": "formation",
|
||||
"acces": "financé selon région",
|
||||
"pourquoi": "Repérer et soutenir un collègue en difficulté.",
|
||||
"fiches": ["sante-mentale", "competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "guide-prevention-manipulation",
|
||||
"titre": "Guide de prévention de la manipulation (Femmes autistes francophones)",
|
||||
"url": "https://femmesautistesfrancophones.com/wp-content/uploads/2018/04/guide-prevention-manipulation.pdf",
|
||||
"format": "guide PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Reconnaître les mécanismes d'emprise, en agence comme ailleurs.",
|
||||
"fiches": ["sante-mentale"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "revivre-apres-l-emprise",
|
||||
"titre": "Revivre après l'emprise",
|
||||
"url": "https://revivre-apres-lemprise.org/",
|
||||
"format": "association",
|
||||
"acces": "don libre",
|
||||
"pourquoi": "Pairs et ressources pour sortir d'une relation d'emprise ; articles en lecture libre.",
|
||||
"fiches": ["sante-mentale"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "manuel-auto-coaching-tdah",
|
||||
"titre": "Manuel d'auto-coaching TDAH adulte",
|
||||
"url": "https://www.tdah-adulte.org/TDAH-Manuel-Auto-Coaching.pdf",
|
||||
"format": "guide PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "S'organiser avec un TDAH.",
|
||||
"fiches": ["sante-mentale"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "contenu non relu"
|
||||
},
|
||||
{
|
||||
"id": "hypersupers-tdah-france",
|
||||
"titre": "HyperSupers, TDAH France",
|
||||
"url": "https://www.tdah-france.fr/",
|
||||
"format": "association",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Communauté de référence TDAH et haut potentiel ; fiches en lecture libre.",
|
||||
"fiches": ["sante-mentale"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "adhésion facultative, 20-40 €/an"
|
||||
},
|
||||
{
|
||||
"id": "yearcompass",
|
||||
"titre": "YearCompass",
|
||||
"url": "https://booklet.yearcompass.com/fr-FR-YearCompass-booklet-A5-printable.pdf",
|
||||
"format": "livret PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Faire le bilan de son année et se projeter.",
|
||||
"fiches": ["mener-ses-projets"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "licence CC BY-NC-SA 4.0"
|
||||
},
|
||||
{
|
||||
"id": "fma-forum-metiers-archi",
|
||||
"titre": "FMA, Forum des métiers de l'architecture (ENSA Paris-Belleville)",
|
||||
"url": "https://www.paris-belleville.archi.fr/forum-des-metiers-de-larchitecture/",
|
||||
"format": "événement",
|
||||
"acces": "gratuit avec inscription",
|
||||
"pourquoi": "Clarifier son projet après le diplôme.",
|
||||
"fiches": ["mener-ses-projets"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "public restreint : L3, M, jeunes diplômés"
|
||||
},
|
||||
{
|
||||
"id": "vipassana",
|
||||
"titre": "Vipassana (Dhamma.org, S. N. Goenka)",
|
||||
"url": "https://www.dhamma.org/fr",
|
||||
"format": "retraite",
|
||||
"acces": "don libre",
|
||||
"pourquoi": "Introspection longue sans barrière d'argent : aucun frais, financé par les anciens élèves.",
|
||||
"fiches": ["naviguer-avec-soi"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "retraite de 10 jours"
|
||||
},
|
||||
{
|
||||
"id": "plum-village-appli",
|
||||
"titre": "Application Plum Village (Village des Pruniers)",
|
||||
"url": "https://plumvillage.org/mindfulness/mindful-apps",
|
||||
"format": "appli",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Porte gratuite du Village des Pruniers : méditations guidées, 300 enseignements audio, en français.",
|
||||
"fiches": ["naviguer-avec-soi"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "isha-kriya",
|
||||
"titre": "Isha Kriya (Isha Foundation)",
|
||||
"url": "https://isha.sadhguru.org/en/wisdom/video/isha-kriya-guided-meditation",
|
||||
"format": "vidéo",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Porte gratuite d'« Isha yoga », sans compte.",
|
||||
"fiches": ["naviguer-avec-soi"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "méditation guidée de 12 à 18 minutes"
|
||||
},
|
||||
{
|
||||
"id": "hridaya-yoga",
|
||||
"titre": "Hridaya Yoga, lives et chaîne",
|
||||
"url": "https://www.youtube.com/c/HridayaYoga/videos",
|
||||
"format": "vidéo",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Méditations hebdomadaires en direct, sans passer par les retraites (140-595 €, tarif réduit sur demande).",
|
||||
"fiches": ["naviguer-avec-soi"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "christophe-andre-meditations",
|
||||
"titre": "Méditations guidées de Christophe André",
|
||||
"url": "https://www.christopheandre.com/meditation-et-spiritualite/",
|
||||
"format": "vidéo",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Pleine conscience en français, par un psychiatre qui a vulgarisé la pratique.",
|
||||
"fiches": ["naviguer-avec-soi"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "audio et vidéo"
|
||||
},
|
||||
{
|
||||
"id": "spf-referentiel-cps",
|
||||
"titre": "Compétences psychosociales, référentiel (Santé publique France)",
|
||||
"url": "https://www.santepubliquefrance.fr/sites/default/files/cadic_files/documents/spf00006442_s1.pdf",
|
||||
"format": "guide PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Le cadre public de référence des compétences psychosociales, celles que la salle du 23/09 a demandé d'apprendre.",
|
||||
"fiches": ["competences-psychosociales", "sante-mentale"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "cercle-nonviolence-ateliers",
|
||||
"titre": "Cercle de la NonViolence, ateliers gratuits",
|
||||
"url": "https://cercle-nv.com/ateliers-et-webinaires-gratuits/",
|
||||
"format": "atelier",
|
||||
"acces": "gratuit avec inscription",
|
||||
"pourquoi": "Porte gratuite vers la communication non violente.",
|
||||
"fiches": ["competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "ateliers en ligne"
|
||||
},
|
||||
{
|
||||
"id": "formation-cnv-famille-positive",
|
||||
"titre": "Formation CNV (La Famille Positive, 16 vidéos)",
|
||||
"url": "https://www.lafamillepositive.com/formation-gratuite-a-la-cnv/",
|
||||
"format": "formation",
|
||||
"acces": "gratuit avec inscription",
|
||||
"pourquoi": "Les fondamentaux de Rosenberg en vidéo.",
|
||||
"fiches": ["competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
},
|
||||
{
|
||||
"id": "universite-du-nous",
|
||||
"titre": "Université du Nous, ressources",
|
||||
"url": "https://universite-du-nous.org/fr_FR/ressources",
|
||||
"format": "fiches",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Gouvernance partagée et intelligence collective, outils de facilitation réutilisables.",
|
||||
"fiches": ["competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "licence CC BY-SA"
|
||||
},
|
||||
{
|
||||
"id": "fertiles",
|
||||
"titre": "Fertîles, PACoo' et FACIL'",
|
||||
"url": null,
|
||||
"urls": ["https://fertiles.co/pacoo/", "https://fertiles.co/facilitation/"],
|
||||
"format": "formation",
|
||||
"acces": "prix libre",
|
||||
"pourquoi": "Coopération et facilitation, citées par Jules le 23/09.",
|
||||
"fiches": ["competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27",
|
||||
"note": "une place à prix libre par session ; sinon 335-365 €"
|
||||
},
|
||||
{
|
||||
"id": "fora-confiance-en-soi",
|
||||
"titre": "Module « Confiance en soi » (Centre FORA)",
|
||||
"url": "https://centrefora.com/wp-content/uploads/2019/05/Confiance-en-soi_VF.pdf",
|
||||
"format": "guide PDF",
|
||||
"acces": "libre",
|
||||
"pourquoi": "Exercices structurés, par un organisme d'éducation publique.",
|
||||
"fiches": ["competences-psychosociales"],
|
||||
"source": "inventaire S 27/09",
|
||||
"couche": "socle",
|
||||
"statut": "vivant",
|
||||
"verifie_le": "2026-09-27"
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_5_placeholder": {
|
||||
"titre": "[V2 — Login] Logiciels pro",
|
||||
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
|
||||
"status": "bientot-login"
|
||||
},
|
||||
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
|
||||
"contribution": {
|
||||
"texte": "Tu utilises un outil ou une ressource qui mérite d'être ici ?",
|
||||
"lien": "/proposer?categorie=outils",
|
||||
"cta": "Proposer une ressource"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Contrôle des liens de /outils (B6-M4) — outil de revue, PAS un test : ne fait jamais échouer le build.
|
||||
* Lit public/data/outils.json (toutes les URL, y compris `urls[]` et `episodes[].url`) et
|
||||
* public/data/fmhy-curated.json, fait un GET (redirections suivies, délai 15 s, 4 requêtes en parallèle)
|
||||
* et liste les réponses hors 2xx/3xx.
|
||||
*
|
||||
* Un 403/429 d'un site qui bloque les robots n'est pas un lien mort : lire la liste avant de retirer quoi que ce soit.
|
||||
* Usage : node scripts/check-liens-outils.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
|
||||
|
||||
const DELAI_MS = 15000
|
||||
const PARALLELE = 4
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
|
||||
|
||||
/** url -> Set des entrées qui la portent. Toute chaîne https:// est prise, quel que soit son champ. */
|
||||
const urls = new Map()
|
||||
function ajoute(url, qui) {
|
||||
if (!urls.has(url)) urls.set(url, new Set())
|
||||
urls.get(url).add(qui)
|
||||
}
|
||||
|
||||
/** Titre lisible de l'entrée courante : nom / titre / mot, sinon l'id. */
|
||||
const titreDe = (o) => o.nom ?? o.titre ?? o.name ?? o.mot ?? o.id
|
||||
|
||||
function parcourt(valeur, fichier, contexte) {
|
||||
if (typeof valeur === 'string') {
|
||||
if (/^https:\/\//i.test(valeur) || /^http:\/\//i.test(valeur)) ajoute(valeur, `${fichier} › ${contexte}`)
|
||||
} else if (Array.isArray(valeur)) {
|
||||
for (const v of valeur) parcourt(v, fichier, contexte)
|
||||
} else if (valeur && typeof valeur === 'object') {
|
||||
// `lien_texte`-like chaînes ne commencent pas par https:// : seules les vraies URL sont retenues.
|
||||
const ctx = titreDe(valeur) ? `${contexte ? contexte + ' › ' : ''}${titreDe(valeur)}` : contexte
|
||||
for (const [, v] of Object.entries(valeur)) parcourt(v, fichier, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
parcourt(lire('outils.json'), 'outils.json', '')
|
||||
parcourt(lire('fmhy-curated.json'), 'fmhy-curated.json', '')
|
||||
|
||||
async function verifie(url) {
|
||||
const ctrl = new AbortController()
|
||||
const minuteur = setTimeout(() => ctrl.abort(), DELAI_MS)
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: ctrl.signal,
|
||||
headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.7' },
|
||||
})
|
||||
r.body?.cancel().catch(() => {})
|
||||
return r.status
|
||||
} catch (e) {
|
||||
return e?.name === 'AbortError' ? 'délai' : `erreur (${e?.cause?.code ?? e?.message ?? 'réseau'})`
|
||||
} finally {
|
||||
clearTimeout(minuteur)
|
||||
}
|
||||
}
|
||||
|
||||
const liste = [...urls.keys()]
|
||||
const resultats = new Map()
|
||||
let curseur = 0
|
||||
async function travailleur() {
|
||||
while (curseur < liste.length) {
|
||||
const url = liste[curseur++]
|
||||
resultats.set(url, await verifie(url))
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: PARALLELE }, travailleur))
|
||||
|
||||
const ko = liste.filter((u) => {
|
||||
const c = resultats.get(u)
|
||||
return !(typeof c === 'number' && c >= 200 && c < 400)
|
||||
})
|
||||
|
||||
console.log(`Contrôle de ${liste.length} URL (GET, délai ${DELAI_MS / 1000} s, ${PARALLELE} en parallèle)\n`)
|
||||
if (ko.length) {
|
||||
console.log('code'.padEnd(18) + 'URL · entrée')
|
||||
for (const u of ko) {
|
||||
console.log(String(resultats.get(u)).padEnd(18) + `${u} · ${[...urls.get(u)].join(' | ')}`)
|
||||
}
|
||||
}
|
||||
console.log(`\n${ko.length} réponse(s) hors 2xx/3xx sur ${liste.length} URL.`)
|
||||
// Outil de revue : toujours code de sortie 0.
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test des données de /outils (B6-M1).
|
||||
* Garde-fou pour les ajouts futurs (à la main, par M3, ou par un pipe n8n) :
|
||||
* fiches de l'onglet « Partir de soi », ressources, purge des liens litigieux, URL en https.
|
||||
* Charge le VRAI utils/echelles.ts via jiti (même patron que test-echelles-nav.mjs).
|
||||
* Usage : node scripts/test-outils-data.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { readFileSync } from 'node:fs'
|
||||
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 } = await jiti.import(join(__dirname, '..', 'utils', 'echelles.ts'))
|
||||
|
||||
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
|
||||
const outils = lire('outils.json')
|
||||
const fmhy = lire('fmhy-curated.json')
|
||||
|
||||
const LISTE_NOIRE = [
|
||||
'annas-archive', 'z-lib', 'libgen', 'sci-hub', 'mobilism', 'zone-ebook', 'rivestream', 'cineby',
|
||||
'flixer', 'miruro', 'monochrome.tf', 'lucida', 'spotx', 'fitgirl', 'steamrip', '1337x',
|
||||
]
|
||||
const FORMATS = ['film', 'vidéo', 'podcast', 'guide PDF', 'livret PDF', 'appli', 'retraite', 'atelier', 'formation', 'association', 'fiches', 'événement', 'récit']
|
||||
const ACCES = ['libre', 'don libre', 'gratuit avec inscription', 'prix libre', 'financé selon région']
|
||||
const SOURCES = ['inventaire S 27/09', 'outils.json (avant B6)']
|
||||
|
||||
let n = 0
|
||||
const echecs = []
|
||||
function verifie(ok, message) {
|
||||
n++
|
||||
if (!ok) echecs.push(message)
|
||||
}
|
||||
|
||||
/** Parcourt un JSON à tous les niveaux : (chemin, clé, valeur) pour chaque chaîne. */
|
||||
function chaines(valeur, chemin, visiteur) {
|
||||
if (typeof valeur === 'string') visiteur(chemin, valeur)
|
||||
else if (Array.isArray(valeur)) valeur.forEach((v, i) => chaines(v, `${chemin}[${i}]`, visiteur))
|
||||
else if (valeur && typeof valeur === 'object') for (const [k, v] of Object.entries(valeur)) chaines(v, `${chemin}.${k}`, visiteur)
|
||||
}
|
||||
|
||||
// ── Fiches ────────────────────────────────────────────────────
|
||||
const p = outils.partir_de_soi
|
||||
const fiches = p?.fiches ?? []
|
||||
const mots = ECHELLES.S.mots
|
||||
verifie(outils.version === 2, 'version doit valoir 2')
|
||||
verifie(fiches.length === 7, `7 fiches attendues, ${fiches.length} trouvées`)
|
||||
verifie(new Set(fiches.map((f) => f.id)).size === fiches.length, 'ids de fiches non uniques')
|
||||
verifie(
|
||||
JSON.stringify(fiches.map((f) => f.mot)) === JSON.stringify(mots),
|
||||
`mots des fiches ≠ ECHELLES.S.mots (dans l'ordre) : ${JSON.stringify(fiches.map((f) => f.mot))}`,
|
||||
)
|
||||
const idsFiches = new Set(fiches.map((f) => f.id))
|
||||
|
||||
// ── Ressources ────────────────────────────────────────────────
|
||||
const ressources = p?.ressources ?? []
|
||||
const idsRes = new Set()
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
for (const r of ressources) {
|
||||
const nom = `ressource « ${r.id ?? r.titre ?? '?'} »`
|
||||
verifie(typeof r.id === 'string' && r.id && !idsRes.has(r.id), `${nom} : id manquant ou en double`)
|
||||
idsRes.add(r.id)
|
||||
verifie(typeof r.titre === 'string' && r.titre, `${nom} : titre manquant`)
|
||||
verifie(FORMATS.includes(r.format), `${nom} : format « ${r.format} » hors liste`)
|
||||
verifie(ACCES.includes(r.acces), `${nom} : accès « ${r.acces} » hors liste`)
|
||||
verifie(typeof r.pourquoi === 'string' && r.pourquoi, `${nom} : pourquoi manquant`)
|
||||
verifie(Array.isArray(r.fiches) && r.fiches.length > 0, `${nom} : fiches vide`)
|
||||
verifie((r.fiches ?? []).every((id) => idsFiches.has(id)), `${nom} : fiche inconnue dans ${JSON.stringify(r.fiches)}`)
|
||||
verifie(SOURCES.includes(r.source), `${nom} : source « ${r.source} » hors liste`)
|
||||
verifie(['socle', 'actualite'].includes(r.couche), `${nom} : couche « ${r.couche} »`)
|
||||
verifie(['vivant', 'dormant', 'archive'].includes(r.statut), `${nom} : statut « ${r.statut} »`)
|
||||
verifie(DATE.test(r.verifie_le ?? ''), `${nom} : verifie_le « ${r.verifie_le} » n'est pas AAAA-MM-JJ`)
|
||||
}
|
||||
|
||||
// ── Fiche vide ⇔ sans ressource ───────────────────────────────
|
||||
for (const f of fiches) {
|
||||
const nb = ressources.filter((r) => r.fiches?.includes(f.id)).length
|
||||
if (nb === 0) verifie(typeof f.vide === 'string' && f.vide, `fiche « ${f.id} » sans ressource : \`vide\` doit être renseigné`)
|
||||
else verifie(f.vide == null, `fiche « ${f.id} » a ${nb} ressource(s) : \`vide\` doit être nul`)
|
||||
}
|
||||
|
||||
// ── URL : https partout, rien de la liste noire ───────────────
|
||||
for (const [fichier, donnees] of [['outils.json', outils], ['fmhy-curated.json', fmhy]]) {
|
||||
chaines(donnees, fichier, (chemin, s) => {
|
||||
const cle = chemin.split('.').pop().replace(/\[\d+\]$/, '')
|
||||
const estUrl = /^https?:\/\//i.test(s) || ['url', 'urls', 'lien', 'avertissement_lien'].includes(cle)
|
||||
// `lien` de contribution est un chemin interne (/proposer…) : seul le préfixe http est contrôlé.
|
||||
if (/^https?:\/\//i.test(s)) {
|
||||
verifie(s.startsWith('https://'), `${chemin} : URL non https (${s})`)
|
||||
const bas = s.toLowerCase()
|
||||
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
|
||||
verifie(!hit, `${chemin} : domaine de la liste noire « ${hit} » (${s})`)
|
||||
} else if (estUrl && cle !== 'lien' && s) {
|
||||
verifie(false, `${chemin} : URL sans https (${s})`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Liste noire aussi dans les noms/titres (un lien retiré ne doit pas survivre en texte).
|
||||
chaines({ outils, fmhy }, 'données', (chemin, s) => {
|
||||
const bas = s.toLowerCase()
|
||||
verifie(!bas.includes('tantra'), `${chemin} : contient « tantra »`)
|
||||
verifie(!bas.includes('the nest'), `${chemin} : contient « The Nest »`)
|
||||
const hit = ['sci-hub', 'z-library', 'library genesis', "anna's archive", 'fitgirl', 'steamrip', '1337x'].find((d) => bas.includes(d))
|
||||
verifie(!hit, `${chemin} : contient « ${hit} »`)
|
||||
})
|
||||
|
||||
// ── Structure minimale des autres blocs ───────────────────────
|
||||
verifie(Array.isArray(outils.conception?.outils) && outils.conception.outils.length > 0, 'conception.outils vide')
|
||||
verifie(
|
||||
(outils.conception?.outils ?? []).every((o) => o.mention && DATE.test(o.verifie_le ?? '')),
|
||||
'conception.outils : `mention` et `verifie_le` obligatoires',
|
||||
)
|
||||
verifie(Array.isArray(outils.open_source?.rubriques) && outils.open_source.rubriques.every((r) => r.liens?.length > 0), 'open_source.rubriques : une rubrique vide doit disparaître')
|
||||
verifie(!!outils.contribution?.texte && !!outils.contribution?.lien && !!outils.contribution?.cta, 'contribution incomplète')
|
||||
|
||||
// ── Open source : sélection (B6-M4) ───────────────────────────
|
||||
// Chaque lien de la sélection est vérifié par une personne (licence, date) ; la liste noire
|
||||
// piraterie s'applique déjà à toute chaîne d'outils.json (voir plus haut), sélection comprise.
|
||||
const sel = outils.open_source?.selection
|
||||
verifie(!!sel && typeof sel.intention === 'string' && sel.intention, 'open_source.selection : intention manquante')
|
||||
verifie(Array.isArray(sel?.rubriques) && sel.rubriques.length >= 1, 'open_source.selection : au moins une rubrique attendue')
|
||||
const idsRub = new Set()
|
||||
for (const r of sel?.rubriques ?? []) {
|
||||
const rub = `sélection « ${r.id ?? r.titre ?? '?'} »`
|
||||
verifie(typeof r.id === 'string' && r.id && !idsRub.has(r.id), `${rub} : id manquant ou en double`)
|
||||
idsRub.add(r.id)
|
||||
verifie(typeof r.titre === 'string' && r.titre, `${rub} : titre manquant`)
|
||||
verifie(Array.isArray(r.liens) && r.liens.length > 0, `${rub} : rubrique vide`)
|
||||
for (const l of r.liens ?? []) {
|
||||
const nom = `${rub} › ${l.nom ?? '?'}`
|
||||
verifie(typeof l.nom === 'string' && l.nom, `${nom} : nom manquant`)
|
||||
verifie(typeof l.url === 'string' && l.url.startsWith('https://'), `${nom} : url https manquante (${l.url})`)
|
||||
verifie(typeof l.desc === 'string' && l.desc, `${nom} : desc manquante`)
|
||||
verifie(typeof l.licence === 'string' && l.licence, `${nom} : licence manquante`)
|
||||
verifie(DATE.test(l.verifie_le ?? ''), `${nom} : verifie_le « ${l.verifie_le} » n'est pas AAAA-MM-JJ`)
|
||||
const bas = (l.url ?? '').toLowerCase()
|
||||
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
|
||||
verifie(!hit, `${nom} : domaine de la liste noire « ${hit} »`)
|
||||
}
|
||||
}
|
||||
|
||||
// Aucun doublon d'URL dans tout open_source (sélection + recommandés + rubriques fmhy), à la barre finale et à la casse près.
|
||||
const norm = (u) => u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')
|
||||
const vues = new Map()
|
||||
const urlsOS = [
|
||||
...(sel?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`sélection › ${l.nom}`, l.url])),
|
||||
...(outils.open_source?.recommandes ?? []).filter((o) => o.url).map((o) => [`recommandé › ${o.titre}`, o.url]),
|
||||
...(outils.open_source?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`fmhy › ${l.nom}`, l.url])),
|
||||
]
|
||||
for (const [qui, url] of urlsOS) {
|
||||
const k = norm(url)
|
||||
verifie(!vues.has(k), `open_source : URL en double « ${url} » (${vues.get(k)} et ${qui})`)
|
||||
vues.set(k, qui)
|
||||
}
|
||||
|
||||
if (echecs.length) {
|
||||
for (const e of echecs) console.log(`FAIL ${e}`)
|
||||
console.log(`\n${n - echecs.length}/${n} vérifications OK — ${echecs.length} échec(s)`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`${n} vérifications OK`)
|
||||
@@ -186,5 +186,62 @@ nav = navigateur(qListe)
|
||||
nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer()
|
||||
ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)')
|
||||
|
||||
// ── Partir de soi : sept mots, ordre de la slide (B6-M2) ──
|
||||
const MOTS = [
|
||||
{ id: 'quete-de-sens', mot: 'quête de sens', resume: 'Bifurquer, donner du sens à son métier.' },
|
||||
{ id: 'gagner-sa-vie', mot: 'gagner sa vie', resume: 'Créer son activité et en vivre.', vide: 'Rien encore.' },
|
||||
{ id: 'sante-mentale', mot: 'santé mentale', resume: 'Repérer, comprendre, se faire accompagner.' },
|
||||
{ id: 'naviguer-avec-soi', mot: 'naviguer avec soi', resume: 'Méditation et pratiques d’introspection.' },
|
||||
]
|
||||
const RESS = [
|
||||
{ id: 'film', titre: 'En quête de sens', format: 'film', acces: 'don libre', pourquoi: 'Le film nommé dans le deck.', fiches: ['quete-de-sens'] },
|
||||
{ id: 'ofqa', titre: 'OFQA', format: 'vidéo', acces: 'libre', pourquoi: 'Diplômés qui ont bifurqué.', fiches: ['quete-de-sens'] },
|
||||
{ id: 'psc', titre: 'Premiers secours en santé mentale', format: 'formation', acces: 'financé selon région', pourquoi: 'Repérer les signes.', fiches: ['sante-mentale'] },
|
||||
{ id: 'vipassana', titre: 'Vipassana', format: 'retraite', acces: 'don libre', pourquoi: 'Dix jours de silence.', fiches: ['naviguer-avec-soi'] },
|
||||
{ id: 'insight', titre: 'Insight Timer', format: 'appli', acces: 'gratuit avec inscription', pourquoi: 'Méditations guidées.', fiches: ['naviguer-avec-soi', 'sante-mentale'] },
|
||||
]
|
||||
const parMot = (id) => RESS.filter((r) => r.fiches.includes(id))
|
||||
const mots = MOTS.map((m) => A.toMiniMot(m, parMot(m.id)))
|
||||
const cfgMots = {
|
||||
dimensions: [
|
||||
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: A.valeursRessources(RESS, 'format') },
|
||||
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: A.valeursRessources(RESS, 'acces') },
|
||||
],
|
||||
tris: ['ordre', 'pertinence', 'nom'],
|
||||
triDefaut: 'ordre',
|
||||
libellesTri: { ordre: 'Ordre de la slide' },
|
||||
groupes: [],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
}
|
||||
const etatM = (query = {}, over = {}) => ({ ...V.lireQuery(query, cfgMots), ...over })
|
||||
const sansDefaut = { ...cfgMots, triDefaut: undefined, tris: ['pertinence', 'nom'] }
|
||||
|
||||
ok(mots[0].kind === 'mot' && mots[0].nom === 'quête de sens' && eq(mots[0].chips, ['film', 'vidéo']) && mots[0].chipsDimension === 'format' && mots[0].geoloc === true, 'mot → kind mot, nom = le mot, chips = formats, geoloc true (pas de carte géographique)')
|
||||
ok(eq(mots[0].etiquettes, { format: ['film', 'video'], acces: ['don-libre', 'libre'] }), 'mot → étiquettes en slug (sans accent, sans espace) pour l’URL', JSON.stringify(mots[0].etiquettes))
|
||||
ok(eq(mots[3].etiquettes.acces, ['don-libre', 'gratuit-avec-inscription']) && mots[3].chips.length === 2, 'mot → union des formats et des accès de ses ressources')
|
||||
ok(mots[1].resume === 'Créer son activité et en vivre.' && eq(mots[1].etiquettes, { format: [], acces: [] }), 'mot sans ressource (gagner sa vie) → résumé gardé, aucune étiquette')
|
||||
ok(A.toMiniMot({ id: 'x', mot: 'x', resume: '', vide: 'Rien encore. Propose.' }, []).resume === 'Rien encore.', 'mot sans résumé → texte « vide » (première phrase)')
|
||||
ok(eq(A.valeursRessources(RESS, 'acces').map((v) => v.libelle), ['don libre', 'libre', 'financé selon région', 'gratuit avec inscription']) && A.slugEtiquette('financé selon région') === 'finance-selon-region', 'valeurs de dimension : libellé lisible, valeur en slug, ordre de première apparition')
|
||||
const idsM = (q, tri) => ids(V.trierFiches(V.filtrerFiches(mots, etatM(q), cfgMots), tri ?? V.triEffectif(etatM(q), cfgMots), etatM(q)))
|
||||
ok(eq(idsM({}), ['quete-de-sens', 'gagner-sa-vie', 'sante-mentale', 'naviguer-avec-soi']), 'tri ordre : l’ordre du tableau d’entrée, pas A → Z')
|
||||
ok(eq(ids(V.trierFiches([...mots].reverse(), 'ordre', etatM())), ['naviguer-avec-soi', 'sante-mentale', 'gagner-sa-vie', 'quete-de-sens']), 'tri ordre : stable, suit l’entrée quelle qu’elle soit')
|
||||
ok(V.triEffectif(etatM(), cfgMots) === 'ordre', 'triDefaut respecté sans filtre')
|
||||
ok(V.triEffectif(etatM({ q: 'vipassana' }), cfgMots) === 'ordre', 'triDefaut respecté avec une recherche seule (pas de filtre d’étiquette)')
|
||||
ok(V.triEffectif(etatM({ acces: 'don-libre' }), cfgMots) === 'pertinence', 'pertinence prioritaire dès qu’un filtre est actif, même avec triDefaut')
|
||||
ok(V.triEffectif(etatM({ acces: 'don-libre', tri: 'ordre' }), cfgMots) === 'ordre', 'tri explicite (tri=ordre dans l’URL) prioritaire')
|
||||
ok(V.lireQuery({ tri: 'ordre' }, cfgMots).tri === 'ordre', 'tri=ordre lu dans l’URL quand la page l’offre')
|
||||
ok(V.lireQuery({ tri: 'ordre' }, cfg).tri === null && V.lireQuery({ tri: 'ordre' }, sansDefaut).tri === null, 'tri=ordre ignoré quand la page ne l’offre pas (/ et /agences)')
|
||||
ok(V.triEffectif(etatM(), sansDefaut) === 'nom' && V.triEffectif(etat0(), cfg) === 'nom', 'sans triDefaut : comportement inchangé (nom)')
|
||||
ok(V.triEffectif(etatM(), { ...cfgMots, triDefaut: 'recent' }) === 'nom', 'triDefaut absent de tris → ignoré')
|
||||
ok(eq(idsM({ acces: 'don-libre' }), ['naviguer-avec-soi', 'quete-de-sens']), 'filtre Accès « don libre » : pertinence (ex æquo → A → Z), pas l’ordre de la slide', JSON.stringify(idsM({ acces: 'don-libre' })))
|
||||
ok(eq(idsM({ q: 'vipassana' }), ['naviguer-avec-soi']), 'recherche « Vipassana » (titre d’une ressource) → le mot « naviguer avec soi »')
|
||||
ok(eq(idsM({ q: 'VIPASSANA' }), ['naviguer-avec-soi']) && eq(idsM({ q: 'quete' }), ['quete-de-sens']), 'recherche insensible à la casse et aux accents')
|
||||
const cm = V.compterEtiquettes(mots, etatM({ acces: 'don-libre' }), cfgMots)
|
||||
ok(cm.format.film === 1 && cm.format.retraite === 1 && cm.format.formation === 0 && cm.acces['don-libre'] === 2 && cm.acces.libre === 1, 'compteurs : format dans le résultat du filtre Accès, accès dans celui des formats', JSON.stringify(cm))
|
||||
const eMot = V.lireQuery({ tab: 'partir-de-soi', acces: 'don-libre', format: 'film,retraite', fiche: 'quete-de-sens' }, cfgMots)
|
||||
const qMot = V.ecrireQuery(eMot, cfgMots, { tab: 'partir-de-soi', acces: 'don-libre', fiche: 'quete-de-sens' })
|
||||
ok(qMot.tab === 'partir-de-soi' && qMot.fiche === 'quete-de-sens' && qMot.acces === 'don-libre' && qMot.format === 'film,retraite', 'tab= et fiche= survivent à l’écriture des filtres')
|
||||
ok(V.queryAvecFiche({ tab: 'partir-de-soi', vue: 'carte' }, 'sante-mentale').tab === 'partir-de-soi' && V.queryAvecFiche({ tab: 'partir-de-soi', fiche: 'x' }, null).tab === 'partir-de-soi', 'tab= survit à l’ouverture et à la fermeture d’une fiche')
|
||||
|
||||
console.log(`\n${n - fail}/${n}`)
|
||||
process.exit(fail ? 1 : 0)
|
||||
|
||||
+8
-1
@@ -10,13 +10,14 @@
|
||||
import type { MiniFiche } from '../composables/useFicheAdapter'
|
||||
|
||||
export type EtatVue = 'carte' | 'mixte' | 'fiches'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent' | 'ordre'
|
||||
|
||||
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
|
||||
export const LIBELLES_TRI: Record<TriFiches, string> = {
|
||||
pertinence: 'Pertinence',
|
||||
nom: 'A → Z',
|
||||
recent: 'Récentes',
|
||||
ordre: 'Ordre d’origine',
|
||||
}
|
||||
|
||||
export interface ValeurFiltre {
|
||||
@@ -57,6 +58,10 @@ export interface ConfigVueFiches {
|
||||
groupes: GroupeDef[]
|
||||
etats: EtatVue[]
|
||||
sousVues?: SousVuesCarte
|
||||
/** Tri sans filtre actif (défaut : `nom`). Doit figurer dans `tris`. */
|
||||
triDefaut?: TriFiches
|
||||
/** Libellés de tri propres à la page (sinon `LIBELLES_TRI`). */
|
||||
libellesTri?: Partial<Record<TriFiches, string>>
|
||||
}
|
||||
|
||||
export interface EtatFiches {
|
||||
@@ -227,6 +232,7 @@ export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
|
||||
if (etat.tri) return etat.tri
|
||||
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
|
||||
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
|
||||
if (cfg.triDefaut && cfg.tris.includes(cfg.triDefaut)) return cfg.triDefaut
|
||||
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
|
||||
}
|
||||
|
||||
@@ -250,6 +256,7 @@ function idNum(f: MiniFiche): number {
|
||||
|
||||
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
|
||||
const copie = [...fiches]
|
||||
if (tri === 'ordre') return copie // ordre du tableau d'entrée (déjà filtré, jamais réordonné)
|
||||
if (tri === 'nom') return copie.sort(parNom)
|
||||
if (tri === 'pertinence') {
|
||||
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
|
||||
|
||||
Reference in New Issue
Block a user