feat(outils): Partir de soi en vue fiches, graphe des sept mots (B6-M2)
Le 3e sous-onglet de /outils remplace le rendu provisoire par VueFiches (état Mixte : liste des mots, graphe SVG statique, fiche d'un mot avec ses ressources). L'en-tête se compacte sur cet onglet, la vue prend la hauteur. Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
This commit is contained in:
co-authored by
Claude Sonnet
parent
11f24c7925
commit
dfbac7dd12
@@ -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>
|
||||
+49
-138
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="outils-page">
|
||||
<div class="outils-page" :class="{ 'outils-page--vue': vueFiches }">
|
||||
|
||||
<!-- ══════════════════════ EN-TÊTE PAGE ══════════════════════ -->
|
||||
<header class="outils-header">
|
||||
@@ -9,9 +9,13 @@
|
||||
</div>
|
||||
<div class="outils-header__body">
|
||||
<h1 class="outils-header__title">Outils</h1>
|
||||
<EchelleTete echelle="S" :contribuer="false" />
|
||||
<p v-if="outils?.intro" class="outils-header__intro">{{ outils.intro }}</p>
|
||||
<!-- Partir de soi : la vue fiches prend la hauteur, l'en-tête se compacte (les sept mots sont dans le graphe) -->
|
||||
<EchelleTete v-if="!vueFiches" echelle="S" :contribuer="false" />
|
||||
<p v-if="outils?.intro && !vueFiches" class="outils-header__intro">{{ outils.intro }}</p>
|
||||
</div>
|
||||
<NuxtLink v-if="vueFiches && outils?.contribution" :to="outils.contribution.lien" class="outils-header__proposer">
|
||||
{{ outils.contribution.cta }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -44,6 +48,7 @@
|
||||
<main
|
||||
id="outils-panneau"
|
||||
class="outils-main"
|
||||
:class="{ 'outils-main--vue': vueFiches }"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="`outils-tab-${tab}`"
|
||||
>
|
||||
@@ -159,57 +164,11 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════ ONGLET 3 — Partir de soi (rendu provisoire, M2 le remplace par VueFiches) ══════════════ -->
|
||||
<template v-else>
|
||||
<p class="outils-intention">{{ outils.partir_de_soi.intention }}</p>
|
||||
<!-- ══════════════ ONGLET 3 — Partir de soi : la vue fiches (B6-M2) ══════════════ -->
|
||||
<OutilsPartirDeSoi v-else :data="outils.partir_de_soi" />
|
||||
|
||||
<section
|
||||
v-for="f in outils.partir_de_soi.fiches"
|
||||
:id="f.id"
|
||||
:key="f.id"
|
||||
class="outils-section fiche-s"
|
||||
:aria-labelledby="`titre-${f.id}`"
|
||||
>
|
||||
<h2 :id="`titre-${f.id}`" class="outils-section__title">{{ f.mot }}</h2>
|
||||
<p class="fiche-s__resume">{{ f.resume }}</p>
|
||||
<p v-if="f.vide" class="fiche-s__vide">{{ f.vide }}</p>
|
||||
<ul v-else class="ressources">
|
||||
<li v-for="r in ressourcesDe(f.id)" :key="r.id" class="ressource">
|
||||
<p class="ressource__titre">
|
||||
<a v-if="r.url" :href="r.url" target="_blank" rel="noopener noreferrer">{{ r.titre }}</a>
|
||||
<template v-else>{{ r.titre }}</template>
|
||||
</p>
|
||||
<p class="ressource__meta">{{ r.format }} · {{ r.acces }}</p>
|
||||
<p class="ressource__pourquoi">{{ r.pourquoi }}</p>
|
||||
<p v-if="r.urls?.length" class="ressource__liens">
|
||||
<template v-for="(u, i) in r.urls" :key="u">
|
||||
<span v-if="i > 0"> · </span>
|
||||
<a :href="u" target="_blank" rel="noopener noreferrer">{{ u.replace(/^https:\/\//, '') }}</a>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="r.note" class="ressource__note">{{ r.note }}</p>
|
||||
<ul v-if="r.episodes?.length" class="ofqa-list">
|
||||
<li v-for="ep in r.episodes" :key="ep.ep" class="ofqa-list__item">
|
||||
<component
|
||||
:is="ep.url ? 'a' : 'span'"
|
||||
v-bind="ep.url ? { href: ep.url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="ofqa-list__link"
|
||||
:class="{ 'ofqa-list__link--disabled': !ep.url }"
|
||||
>
|
||||
<span class="ofqa-list__ep">EP/{{ ep.ep }}</span>
|
||||
<span class="ofqa-list__titre">{{ ep.titre }}</span>
|
||||
<span class="ofqa-list__personnes">— {{ ep.personnes }}</span>
|
||||
<span v-if="ep.note" class="ofqa-list__note">({{ ep.note }})</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ══════════════ CONTRIBUTION (les 3 onglets) ══════════════ -->
|
||||
<footer v-if="outils?.contribution" class="outils-contribution">
|
||||
<!-- ══════════════ CONTRIBUTION (onglets 1 et 2 ; sur le 3e, le lien est dans l'en-tête) ══════════════ -->
|
||||
<footer v-if="outils?.contribution && !vueFiches" class="outils-contribution">
|
||||
<p class="outils-contribution__texte">{{ outils.contribution.texte }}</p>
|
||||
<NuxtLink :to="outils.contribution.lien" class="outils-contribution__cta">
|
||||
{{ outils.contribution.cta }}
|
||||
@@ -242,8 +201,8 @@ watch(() => route.query.tab, (q) => { tab.value = lireTab(q) })
|
||||
const { data: outils } = await useFetch<any>('/data/outils.json')
|
||||
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch<any>('/data/fmhy-curated.json')
|
||||
|
||||
const ressourcesDe = (ficheId: string) =>
|
||||
(outils.value?.partir_de_soi?.ressources ?? []).filter((r: any) => r.fiches?.includes(ficheId))
|
||||
// Partir de soi : la vue fiches remplit la hauteur sous la barre d'onglets (pas de défilement de page).
|
||||
const vueFiches = computed(() => tab.value === 'partir-de-soi' && !!outils.value)
|
||||
|
||||
// Flèches ← → (+ Début / Fin) : on se déplace et on active, comme un tablist à activation automatique.
|
||||
const tablistRef = ref<HTMLElement | null>(null)
|
||||
@@ -559,89 +518,6 @@ useSeoMeta({
|
||||
margin: 0.5rem 0 -0.5rem;
|
||||
}
|
||||
|
||||
/* ── Partir de soi (provisoire) ─────────────────────────────── */
|
||||
.fiche-s { gap: var(--space-sm); }
|
||||
|
||||
.fiche-s__resume {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.fiche-s__vide {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.ressources {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.ressource { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.ressource p { margin: 0; overflow-wrap: anywhere; }
|
||||
.ressource__titre { font-size: 0.92rem; font-weight: var(--fw-semibold); }
|
||||
.ressource__titre a, .ressource__liens a { color: var(--nav-primary-solid); }
|
||||
.ressource__meta { font-size: 0.78rem; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
.ressource__pourquoi { font-size: 0.84rem; line-height: 1.5; }
|
||||
.ressource__liens { font-size: 0.82rem; }
|
||||
.ressource__note { font-size: 0.78rem; font-style: italic; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
|
||||
/* ── Liste OFQA ─────────────────────────────────────────────── */
|
||||
.ofqa-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-xs) 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ofqa-list__link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.84rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ofqa-list__link:not(.ofqa-list__link--disabled):hover {
|
||||
background: var(--bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ofqa-list__link--disabled { cursor: default; }
|
||||
|
||||
.ofqa-list__ep {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', 'Courier New', monospace);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5rem;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.ofqa-list__titre { font-weight: var(--fw-medium); }
|
||||
.ofqa-list__personnes,
|
||||
.ofqa-list__note { font-size: 0.8rem; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
.ofqa-list__note { font-style: italic; }
|
||||
|
||||
/* ── Logiciels pro (placeholder) ────────────────────────────── */
|
||||
.outils-section--placeholder { opacity: 0.8; }
|
||||
|
||||
@@ -714,10 +590,45 @@ useSeoMeta({
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Partir de soi : la vue fiches remplit la hauteur du conteneur de défilement (app.vue) ── */
|
||||
.outils-page--vue {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.outils-page--vue .outils-header {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-xs) var(--space-md) 0;
|
||||
}
|
||||
.outils-page--vue .outils-header__inner { align-items: center; }
|
||||
.outils-page--vue .outils-header__icon-wrap { width: 2rem; height: 2rem; padding: 0.4rem; }
|
||||
.outils-page--vue .outils-header__title { font-size: 1.25rem; margin: 0; }
|
||||
.outils-page--vue .outils-header__body { flex: 1 1 auto; }
|
||||
.outils-page--vue .outils-tabs { flex-shrink: 0; margin: 0; padding: 0 var(--space-md); }
|
||||
.outils-main--vue { flex: 1 1 0; min-height: 0; gap: 0; }
|
||||
|
||||
.outils-header__proposer {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--nav-primary-solid);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.outils-header__proposer:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.outils-page { padding: var(--space-lg) var(--space-md) 4rem; }
|
||||
.outils-header__inner { flex-direction: column; gap: var(--space-sm); }
|
||||
.outils-page--vue { padding: 0; }
|
||||
.outils-page--vue .outils-header__inner { flex-direction: row; gap: var(--space-sm); }
|
||||
.outils-page--vue .outils-header__icon-wrap { display: none; }
|
||||
.outils-header__title { font-size: 1.4rem; }
|
||||
.outil-cards-grid { grid-template-columns: 1fr; }
|
||||
.outils-section--fmhy { padding: 1.25rem 1rem; }
|
||||
|
||||
Reference in New Issue
Block a user