Compare commits
8 Commits
main
...
feat/aep-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa32552864 | ||
|
|
ac88f344cc | ||
|
|
cf60d4b973 | ||
|
|
ddbc67fb5c | ||
|
|
95e1d1df20 | ||
|
|
3b2fce335e | ||
|
|
a073b14a81 | ||
|
|
a05db54d7a |
4
.dropboxignore
Normal file
4
.dropboxignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.nitro
|
||||
64
app.vue
64
app.vue
@@ -7,34 +7,32 @@
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
||||
<a href="/" class="flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0 group relative" title="Architecture d'Écologie Politique">
|
||||
<div
|
||||
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
class="h-7 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||
style="background: var(--nav-primary-solid);"
|
||||
>
|
||||
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
||||
</div>
|
||||
<div class="logo-text flex flex-col leading-tight">
|
||||
<span class="logo-line-1 font-bold tracking-tight" style="color: var(--nav-text);">Architecture</span>
|
||||
<span class="logo-line-2 font-bold tracking-tight" style="color: var(--nav-text);">d'Écologie Politique</span>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-base tracking-tight leading-tight" style="color: var(--nav-text);">AEP</span>
|
||||
<span class="text-xs leading-tight hidden lg:inline" style="color: var(--nav-text-muted);">Architecture d'Écologie Politique</span>
|
||||
</div>
|
||||
<!-- Tooltip sm (quand le sous-titre lg est caché) -->
|
||||
<div class="absolute left-0 top-full mt-2 px-2 py-1 rounded text-xs whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity lg:hidden z-50"
|
||||
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);">
|
||||
Architecture d'Écologie Politique
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||
<NuxtLink
|
||||
to="/outils"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
||||
>
|
||||
Outils
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||
>
|
||||
Écosystème Entraide
|
||||
Écosystème Entraide Architecture
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/agences"
|
||||
@@ -48,21 +46,15 @@
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
||||
>
|
||||
Jobs
|
||||
Trouver du taf
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/codev"
|
||||
to="/rag"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
||||
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
||||
>
|
||||
Codev
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||
>
|
||||
recherche-média
|
||||
RAG
|
||||
<span class="nav-tab-badge">en construction</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
@@ -178,16 +170,11 @@
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/outils" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,7 +182,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<div class="flex-1 h-full min-h-0" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
@@ -267,21 +254,6 @@ function goRandom() {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
||||
.logo-text {
|
||||
line-height: 1.05;
|
||||
}
|
||||
.logo-line-1, .logo-line-2 {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.78rem; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
||||
}
|
||||
|
||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||
.nav-tab {
|
||||
position: relative;
|
||||
|
||||
@@ -108,16 +108,3 @@
|
||||
.dark .leaflet-popup-tip {
|
||||
background: var(--nav-surface);
|
||||
}
|
||||
|
||||
/* ── Rendu Markdown chatbot (useMarkdown composable) ────────────────────── */
|
||||
.md-content { font-size: inherit; line-height: 1.6; }
|
||||
.md-content p { margin: 0 0 0.5em; }
|
||||
.md-content p:last-child { margin-bottom: 0; }
|
||||
.md-content strong, .md-h1, .md-h2, .md-h3 { font-weight: 700; }
|
||||
.md-h2 { font-size: 0.9375em; display: block; margin-bottom: 0.25em; }
|
||||
.md-h3 { font-size: 0.875em; display: block; }
|
||||
.md-content em { font-style: italic; }
|
||||
.md-list { margin: 0.375em 0 0.375em 1em; padding: 0; list-style: disc; }
|
||||
.md-list li { margin-bottom: 0.2em; }
|
||||
.md-link { text-decoration: underline; opacity: 0.85; }
|
||||
.md-link:hover { opacity: 1; }
|
||||
|
||||
@@ -139,7 +139,72 @@
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
|
||||
<!-- ═══════════════════════════════════════ FAB MOBILE (< 1024px) ════════ -->
|
||||
<div v-else>
|
||||
<!-- FAB soutenir (à gauche du chatbot) -->
|
||||
<button
|
||||
class="fab-soutenir"
|
||||
type="button"
|
||||
@click="fabSheetOpen = true"
|
||||
aria-label="Soutenir le projet AEP"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Bottom sheet FAB -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="fabSheetOpen"
|
||||
class="fixed inset-0 z-[1020]"
|
||||
style="background: rgba(26,34,56,0.5);"
|
||||
@click="fabSheetOpen = false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="sheet">
|
||||
<div
|
||||
v-if="fabSheetOpen"
|
||||
class="fab-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Soutenir AEP"
|
||||
>
|
||||
<!-- Poignée -->
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||
</div>
|
||||
<div class="px-5 pb-6">
|
||||
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
|
||||
<template v-if="stats">
|
||||
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
|
||||
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
|
||||
</p>
|
||||
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
|
||||
</p>
|
||||
</template>
|
||||
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
|
||||
1 € = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
|
||||
</p>
|
||||
<a
|
||||
href="https://liberapay.com/trans-former.fr/donate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
|
||||
@click="fabSheetOpen = false"
|
||||
>
|
||||
Soutenir sur Liberapay →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -156,6 +221,7 @@ interface Stats {
|
||||
const stats = ref<Stats | null>(null)
|
||||
const loading = ref(true)
|
||||
const modalOpen = ref(false)
|
||||
const fabSheetOpen = ref(false)
|
||||
const tooltipVisible = ref(false)
|
||||
|
||||
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||
@@ -394,6 +460,39 @@ const jaugePct = computed(() => {
|
||||
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||
}
|
||||
|
||||
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
||||
.fab-soutenir {
|
||||
position: fixed;
|
||||
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-accent);
|
||||
color: var(--nav-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
|
||||
.fab-sheet {
|
||||
position: fixed;
|
||||
inset-x: 0;
|
||||
bottom: 0;
|
||||
z-index: 1021;
|
||||
background: var(--nav-surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
<template>
|
||||
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
|
||||
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
||||
<div ref="tooltipRef" style="
|
||||
position: absolute; pointer-events: none;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
||||
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
||||
"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
|
||||
const LINKS_INFLUENCE = [
|
||||
// filiations directes
|
||||
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
|
||||
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
|
||||
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
|
||||
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
|
||||
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
|
||||
// liens de critique
|
||||
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
|
||||
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
|
||||
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
|
||||
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
|
||||
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
|
||||
]
|
||||
|
||||
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
||||
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
|
||||
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
let simulation: any = null
|
||||
let d3LinkSel: any = null
|
||||
let d3InfluenceSel: any = null
|
||||
let d3NodeSel: any = null
|
||||
let d3EdgeLabelSel: any = null
|
||||
|
||||
async function initGraph() {
|
||||
if (!svgRef.value || !props.data) return
|
||||
const d3 = await import('d3')
|
||||
|
||||
const svgEl = svgRef.value
|
||||
const W = svgEl.clientWidth || 900
|
||||
const H = svgEl.clientHeight || 600
|
||||
|
||||
d3.select(svgEl).selectAll('*').remove()
|
||||
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
|
||||
const g = svg.append('g')
|
||||
|
||||
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
|
||||
|
||||
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
||||
|
||||
// Positions fixes des ecoles (base pour forces D3)
|
||||
const ecolePositions = new Map<string, { tx: number; ty: number }>()
|
||||
props.data.ecoles.forEach(e => {
|
||||
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
|
||||
})
|
||||
|
||||
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
|
||||
const gInfluence = g.append('g').attr('class', 'links-influence')
|
||||
|
||||
LINKS_INFLUENCE.forEach(link => {
|
||||
const src = ecolePositions.get(link.source)
|
||||
const tgt = ecolePositions.get(link.target)
|
||||
if (!src || !tgt) return
|
||||
|
||||
const isCritique = link.type === 'critique'
|
||||
const lineEl = gInfluence.append('line')
|
||||
.attr('class', 'influence-link')
|
||||
.attr('x1', src.tx).attr('y1', src.ty)
|
||||
.attr('x2', tgt.tx).attr('y2', tgt.ty)
|
||||
.attr('stroke', isCritique ? '#d99' : '#9aa')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
|
||||
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
|
||||
|
||||
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
|
||||
lineEl
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---- SIMULATION D3 (auteurs) ----
|
||||
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
||||
const ecole = ecoleMap.get(a.ecole_principale)
|
||||
const jitter = () => (Math.random() - 0.5) * 80
|
||||
return {
|
||||
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
|
||||
bio_courte: a.bio_courte,
|
||||
bio_provisoire: a.bio_courte_provisoire ?? '',
|
||||
ingere: a.ingere,
|
||||
ecole_principale: a.ecole_principale,
|
||||
color: ecole?.color ?? '#888', r: 11,
|
||||
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
|
||||
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
|
||||
}
|
||||
})
|
||||
|
||||
// Liens appartenance auteur -> ecole (vers centroid fixe)
|
||||
const links: any[] = []
|
||||
props.data.auteurs.forEach(a => {
|
||||
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
|
||||
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
|
||||
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
|
||||
})
|
||||
})
|
||||
|
||||
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
|
||||
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
|
||||
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
|
||||
x: W * e.x_hint, y: H * e.y_hint,
|
||||
fx: W * e.x_hint, fy: H * e.y_hint,
|
||||
}))
|
||||
|
||||
// Rayon proportionnel au nombre d'auteurs de l'ecole
|
||||
const ecoleAuteurCounts = new Map<string, number>()
|
||||
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
|
||||
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
|
||||
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
|
||||
|
||||
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
||||
|
||||
if (simulation) simulation.stop()
|
||||
simulation = d3.forceSimulation(allNodes)
|
||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
|
||||
.force('charge', d3.forceManyBody().strength(-70))
|
||||
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
|
||||
.force('forceX', d3.forceX<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.tx : W / 2
|
||||
}
|
||||
return W / 2
|
||||
}).strength(0.15))
|
||||
.force('forceY', d3.forceY<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.ty : H / 2
|
||||
}
|
||||
return H / 2
|
||||
}).strength(0.15))
|
||||
|
||||
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
||||
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
||||
ecoleFixedNodes.forEach(eNode => {
|
||||
const ecole = ecoleMap.get(eNode.ecoleId)
|
||||
if (!ecole) return
|
||||
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
|
||||
const r = ecoleRadius(count)
|
||||
gEcoles.append('circle')
|
||||
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
|
||||
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
|
||||
.attr('class', 'ecole-node').style('cursor', 'pointer')
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
|
||||
|
||||
// ---- TITRES ECOLES visibles en permanence ----
|
||||
const labelText = ecole.label
|
||||
const words = labelText.split(' ')
|
||||
const fontSize = Math.max(12, r * 0.45)
|
||||
if (words.length > 2 || labelText.length > 12) {
|
||||
const mid = Math.ceil(words.length / 2)
|
||||
const line1 = words.slice(0, mid).join(' ')
|
||||
const line2 = words.slice(mid).join(' ')
|
||||
const textEl = gEcoles.append('text')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('y', eNode.fy)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('font-weight', '700')
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('fill', '#ffffff')
|
||||
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.5)')
|
||||
.style('user-select', 'none')
|
||||
textEl.append('tspan')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('dy', `-${fontSize * 0.6}px`)
|
||||
.text(line1)
|
||||
textEl.append('tspan')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('dy', `${fontSize * 1.2}px`)
|
||||
.text(line2)
|
||||
} else {
|
||||
gEcoles.append('text')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('y', eNode.fy)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('font-weight', '700')
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('fill', '#ffffff')
|
||||
.style('user-select', 'none')
|
||||
.text(labelText)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- LIENS APPARTENANCE (couche 4) ----
|
||||
const gLinks = g.append('g').attr('class', 'links-appartenance')
|
||||
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
|
||||
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
||||
|
||||
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
||||
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
||||
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
||||
.data(subcourantLinks)
|
||||
.join('text')
|
||||
.attr('class', 'pensees-edge-label')
|
||||
|
||||
// ---- NODES AUTEURS (couche 5) ----
|
||||
const gAuteurs = g.append('g').attr('class', 'auteurs')
|
||||
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
|
||||
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
|
||||
.call(d3.drag<any, any>()
|
||||
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
|
||||
.on('click', (e: any, d: any) => {
|
||||
if (!d.ingere) return
|
||||
e.stopPropagation()
|
||||
emit('select-auteur', d.id)
|
||||
})
|
||||
|
||||
// Phase 8.D : grisage conditionnel auteurs non-ingeres
|
||||
d3NodeSel.append('circle')
|
||||
.attr('r', (d: any) => d.r)
|
||||
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
|
||||
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
||||
|
||||
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
|
||||
d3NodeSel.append('text')
|
||||
.attr('class', 'pensees-auteur-label')
|
||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', (d: any) => -(d.r + 4))
|
||||
.style('pointer-events', 'none')
|
||||
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
|
||||
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
|
||||
|
||||
d3NodeSel
|
||||
.on('mouseenter', (e: any, d: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
let tooltipHtml = ''
|
||||
if (d.ingere) {
|
||||
const rawBio = d.bio_courte || ''
|
||||
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
|
||||
} else {
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
|
||||
}
|
||||
tooltipRef.value.innerHTML = tooltipHtml
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
|
||||
simulation.on('tick', () => {
|
||||
d3LinkSel
|
||||
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
||||
|
||||
d3EdgeLabelSel
|
||||
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
||||
.text((d: any) => {
|
||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
||||
return targetId
|
||||
})
|
||||
|
||||
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.active, (val) => {
|
||||
if (val && import.meta.client && props.data)
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
})
|
||||
watch(() => props.data, (val) => {
|
||||
if (val && props.active && import.meta.client)
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||
})
|
||||
onMounted(async () => {
|
||||
if (import.meta.client && props.data && props.active) {
|
||||
await nextTick()
|
||||
initGraph()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { if (simulation) simulation.stop() })
|
||||
|
||||
function triggerResize() {
|
||||
if (simulation) {
|
||||
simulation.alpha(0.3).restart()
|
||||
} else if (import.meta.client && props.data && props.active) {
|
||||
initGraph()
|
||||
}
|
||||
}
|
||||
defineExpose({ triggerResize })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.pensees-auteur-label {
|
||||
fill: #1a1a1a;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pensees-edge-label {
|
||||
fill: #555;
|
||||
font-size: 8.5px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ecole-node {
|
||||
transition: opacity 0.15s, r 0.15s;
|
||||
}
|
||||
.ecole-node:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[1010]"
|
||||
style="background: rgba(26,34,56,0.5);"
|
||||
@click="emit('update:modelValue', false)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<transition name="sheet">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
||||
style="background: var(--nav-surface); height: 100dvh; max-height: 100dvh; box-shadow: 0 -4px 32px rgba(26,34,56,0.18);"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Assistant Réseaux AEP"
|
||||
>
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-3 shrink-0 border-b" style="border-color: var(--nav-bg-alt);">
|
||||
<button
|
||||
@click="emit('update:modelValue', false)"
|
||||
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text-muted);"
|
||||
aria-label="Retour"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
Retour
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center shrink-0" style="background: var(--nav-primary);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Réseaux AEP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Je connais les <strong>120 réseaux, collectifs et agences</strong> cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.</p>
|
||||
<p>Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet en Occitanie, en transition énergétique ?</p>
|
||||
</div>
|
||||
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||
<div v-else class="assistant-bubble">
|
||||
<div v-html="renderMd(msg.content)" />
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
||||
<p class="fiches-title">Structures recommandées :</p>
|
||||
<a
|
||||
v-for="fiche in msg.fiches"
|
||||
:key="fiche.id"
|
||||
:href="`/agences#${fiche.id}`"
|
||||
class="fiche-card"
|
||||
>
|
||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
placeholder="Décris ta situation…"
|
||||
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
||||
style="border-color: var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text); font-family: var(--nav-font); font-size: 16px;"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
:disabled="loading || !inputText.trim()"
|
||||
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
||||
style="background: var(--nav-primary);"
|
||||
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
||||
aria-label="Envoyer"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco { id: number | string; nom: string; explication?: string }
|
||||
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
document.documentElement.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = ''
|
||||
document.documentElement.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function sendMessage() {
|
||||
const question = inputText.value.trim()
|
||||
if (!question || loading.value) return
|
||||
inputText.value = ''
|
||||
errorMsg.value = ''
|
||||
messages.value.push({ role: 'user', content: question })
|
||||
loading.value = true
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
|
||||
try {
|
||||
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
||||
'/api/chatbot-reseaux',
|
||||
{ method: 'POST', body: { question } }
|
||||
)
|
||||
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
||||
} catch (e: any) {
|
||||
const s = e?.statusCode ?? e?.status
|
||||
errorMsg.value = s === 429
|
||||
? 'Limite de 20 questions par jour atteinte.'
|
||||
: 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||
|
||||
.onboarding-bubble {
|
||||
background: var(--nav-bg); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 12px; padding: 16px;
|
||||
font-size: 0.85rem; line-height: 1.65; color: var(--nav-text-muted);
|
||||
}
|
||||
.onboarding-bubble p { margin-bottom: 10px; }
|
||||
.onboarding-bubble strong { font-weight: 700; color: var(--nav-text); }
|
||||
|
||||
.user-bubble {
|
||||
align-self: flex-end; max-width: 80%;
|
||||
background: var(--nav-primary); color: var(--nav-text-on-primary);
|
||||
border-radius: 16px 16px 4px 16px; padding: 10px 14px;
|
||||
font-size: 0.875rem; line-height: 1.5;
|
||||
}
|
||||
.assistant-bubble {
|
||||
align-self: flex-start; max-width: 90%;
|
||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 16px 16px 16px 4px; padding: 12px 14px;
|
||||
font-size: 0.875rem; line-height: 1.6; color: var(--nav-text);
|
||||
}
|
||||
.loading-bubble { display: flex; gap: 5px; align-items: center; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--nav-text-muted); animation: blink 1.2s infinite; }
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
||||
|
||||
.error-bubble { align-self: flex-start; max-width: 90%; color: #a85d3e; font-size: 0.8rem; padding: 8px 12px; border-radius: 8px; background: rgba(168,93,62,0.08); }
|
||||
|
||||
.fiches-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fiches-title { font-size: 0.75rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
||||
.fiche-card { display: block; background: var(--nav-bg); border: 1px solid var(--nav-bg-alt); border-radius: 8px; padding: 8px 12px; text-decoration: none; transition: background 0.15s; }
|
||||
.fiche-card:hover { background: var(--nav-bg-alt); }
|
||||
.fiche-nom { display: block; font-size: 0.875rem; font-weight: 600; color: var(--nav-text); }
|
||||
.fiche-expl { display: block; font-size: 0.8rem; color: var(--nav-text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
@@ -69,14 +69,18 @@
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<!-- Message onboarding (avant la première question) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||
<li>• Lieu : [région ou ville]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||
employeur, besoin conseil juridique droit du travail,
|
||||
Île-de-France."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
@@ -88,7 +92,7 @@
|
||||
|
||||
<!-- Message assistant -->
|
||||
<div v-else class="assistant-bubble">
|
||||
<div class="md-content" v-html="renderMd(msg.content)" />
|
||||
<p>{{ msg.content }}</p>
|
||||
|
||||
<!-- Fiches recommandées -->
|
||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||
@@ -160,9 +164,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
interface FicheReco {
|
||||
id: number | string
|
||||
nom: string
|
||||
@@ -319,17 +320,7 @@ function scrollToBottom() {
|
||||
line-height: 1.6;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
.assistant-bubble > p { margin: 0; }
|
||||
|
||||
/* Markdown rendu via v-html — :deep() perce le scoped */
|
||||
:deep(.md-content) { font-size: inherit; line-height: 1.6; }
|
||||
:deep(.md-content p) { margin: 0 0 0.4em; }
|
||||
:deep(.md-content p:last-child) { margin-bottom: 0; }
|
||||
:deep(.md-content strong) { font-weight: 700; }
|
||||
:deep(.md-content em) { font-style: italic; }
|
||||
:deep(.md-content ul) { margin: 0.3em 0 0.3em 1.1em; list-style: disc; padding: 0; }
|
||||
:deep(.md-content li) { margin-bottom: 0.15em; }
|
||||
:deep(.md-content a) { text-decoration: underline; opacity: 0.8; }
|
||||
.assistant-bubble p { margin: 0; }
|
||||
|
||||
/* Fiches recommandées */
|
||||
.fiches-list {
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue && orgId != null"
|
||||
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(768px, 92vw);
|
||||
max-height: 90vh;
|
||||
background: var(--nav-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||
@@ -143,21 +144,6 @@ function onCommentSubmitted() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
@@ -170,11 +156,6 @@ function onCommentSubmitted() {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -52%);
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue && structureId != null && structure"
|
||||
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(780px, 94vw);
|
||||
max-height: 90vh;
|
||||
background: var(--nav-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||
@@ -324,21 +325,6 @@ const structuresVoisines = computed<StructureV2[]>(() => {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
|
||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal-v2 {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal-v2 {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-enter-active, .modal-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
@@ -347,11 +333,6 @@ const structuresVoisines = computed<StructureV2[]>(() => {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -52%);
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
|
||||
@@ -587,20 +587,6 @@ async function initGraph() {
|
||||
.attr('fill', '#2a2a2a')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// Labels structures : nom au-dessus du cercle, halo pour lisibilite
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||
.append('text')
|
||||
.attr('class', 'graph-struct-label')
|
||||
.text((d: any) => {
|
||||
const raw = d.label as string
|
||||
return raw.length > 22 ? raw.slice(0, 20) + '…' : raw
|
||||
})
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', (d: any) => -(d.r + 5))
|
||||
.attr('font-size', '9.5px')
|
||||
.attr('font-weight', '500')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
// Tooltip hover pour structures
|
||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||
.on('mouseenter', (_event: any, d: any) => {
|
||||
@@ -872,15 +858,3 @@ onUnmounted(() => {
|
||||
if (simulation) simulation.stop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
||||
.graph-view .graph-struct-label {
|
||||
fill: var(--nav-text);
|
||||
paint-order: stroke;
|
||||
stroke: var(--nav-bg);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="media-tab-backend" style="padding: 2rem; overflow-y: auto;">
|
||||
<div style="max-width: 640px;">
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--nav-text);">LightRAG backend</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text); margin-bottom: 0.5rem;">
|
||||
Voici l'interface brute du <strong>LightRAG</strong> qui alimente la carte des pensées écologiques.
|
||||
C'est la "cuisine" du RAG : ingestion de documents, extraction d'entités, relations, requêtes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
src="https://lightrag.trans-former.fr/"
|
||||
style="width: 100%; height: 70vh; border: 1px solid var(--nav-bg-alt, #ddd); border-radius: 8px; margin-top: 1.5rem;"
|
||||
title="LightRAG backend AEP — lecture seule"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
|
||||
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
|
||||
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
|
||||
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="projets-grid">
|
||||
<article v-for="p in projets" :key="p.id" class="projet-card">
|
||||
<img v-if="p.thumb" :src="p.thumb" :alt="p.titre" class="projet-thumb" loading="lazy" />
|
||||
<div v-else class="projet-thumb projet-thumb--placeholder">📐</div>
|
||||
|
||||
<h3 style="font-weight: 600; font-size: 0.95rem; margin: 0.5rem 0 0.25rem; color: var(--nav-text);">{{ p.titre }}</h3>
|
||||
<p style="font-size: 0.8rem; color: var(--nav-text-muted); margin-bottom: 0.5rem;">
|
||||
{{ (p.auteurs || []).filter((a: string) => a !== 'Inconnu').join(', ') }}
|
||||
<template v-if="p.ecole && p.ecole !== 'Inconnu'"> · {{ p.ecole }}</template>
|
||||
<template v-if="p.annee && p.annee !== 'Inconnu'"> · {{ p.annee }}</template>
|
||||
</p>
|
||||
<p style="font-size: 0.875rem; line-height: 1.5; color: var(--nav-text); flex: 1; margin-bottom: 0.75rem;">{{ p.description }}</p>
|
||||
|
||||
<a v-if="p.url" :href="p.url" target="_blank" rel="noopener" style="color: var(--nav-primary-solid, #3b6ea5); font-weight: 600; font-size: 0.875rem; text-decoration: none;">
|
||||
Découvrir →
|
||||
</a>
|
||||
<span v-if="p.link_status === 'broken'" style="color: #e67e22; font-size: 0.8rem; display: block; margin-top: 0.25rem;">⚠ Lien d'origine cassé</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 2rem; font-size: 0.875rem; color: var(--nav-text-muted);">
|
||||
Tu as un PFE engagé à partager ? <a href="mailto:contact@trans-former.fr" style="color: var(--nav-primary-solid);">Écris-moi</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: pfeData } = await useFetch<{ projets: any[] }>('/data/pfe-engages.json')
|
||||
const projets = computed(() => pfeData.value?.projets ?? [])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.projet-card {
|
||||
border: 1px solid var(--nav-bg-alt, #eee);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--nav-surface);
|
||||
}
|
||||
.projet-thumb {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--nav-bg-alt, #f5f5f5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,650 +0,0 @@
|
||||
<template>
|
||||
<div class="media-visuel">
|
||||
|
||||
<!-- Conteneur principal : carte → toolbar → chatbot (ou bonpote/rag-backend) -->
|
||||
<div class="layout-container">
|
||||
|
||||
<!-- SLOT CARTE D3 (mode carte uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte'"
|
||||
class="carte-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'carte-split' : '',
|
||||
layoutMode === 'carte-full' ? 'carte-full' : '',
|
||||
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
||||
style="position: relative;"
|
||||
>
|
||||
<ClientOnly>
|
||||
<CartePensees
|
||||
ref="cartePenseesRef"
|
||||
:data="penseesData"
|
||||
:active="true"
|
||||
@select-auteur="onSelectAuteur"
|
||||
@select-ecole="onSelectEcole"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
||||
Chargement de la carte...
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Overlay PDF FRACAS -->
|
||||
<div
|
||||
v-if="showFracasPdf"
|
||||
class="fracas-overlay"
|
||||
:style="{ opacity: fracasOpacity / 100 }"
|
||||
>
|
||||
<embed
|
||||
src="/cartes/carte-fracas-bonpote-v2.pdf"
|
||||
type="application/pdf"
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BARRE DE TOGGLE (entre carte et chatbot, toujours visible) -->
|
||||
<div class="layout-toggle-bar shrink-0">
|
||||
|
||||
<!-- Gauche : contrôles layout (seulement en mode carte) -->
|
||||
<template v-if="contentView === 'carte'">
|
||||
<button
|
||||
@click="layoutMode = 'carte-full'"
|
||||
:class="{ active: layoutMode === 'carte-full' }"
|
||||
class="toggle-btn"
|
||||
title="Carte en plein ecran"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
Carte plein ecran
|
||||
</button>
|
||||
<button
|
||||
v-if="layoutMode !== 'split'"
|
||||
@click="layoutMode = 'split'"
|
||||
class="toggle-btn"
|
||||
title="Vue partagee"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
Vue partagee
|
||||
</button>
|
||||
<button
|
||||
@click="layoutMode = 'chatbot-full'"
|
||||
:class="{ active: layoutMode === 'chatbot-full' }"
|
||||
class="toggle-btn"
|
||||
title="Chatbot plein ecran"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Chatbot plein ecran
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Droite : contrôles contenu (toujours, indépendants du layoutMode) -->
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<!-- Slider opacité PDF -->
|
||||
<input
|
||||
v-if="showFracasPdf && contentView === 'carte'"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
v-model.number="fracasOpacity"
|
||||
class="opacity-slider"
|
||||
:title="`Opacité ${fracasOpacity}%`"
|
||||
/>
|
||||
|
||||
<!-- CARTE PRINCIPALE -->
|
||||
<button
|
||||
@click="showCarte"
|
||||
:class="{ active: contentView === 'carte' }"
|
||||
class="toggle-btn"
|
||||
title="Vue principale : carte D3 + chatbot"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="2"/><path d="M12 2a10 10 0 0 0-7.07 17.07M12 2a10 10 0 0 1 7.07 17.07M3.34 7h17.32M3.34 17h17.32"/>
|
||||
</svg>
|
||||
CARTE PRINCIPALE
|
||||
</button>
|
||||
|
||||
<!-- Tickbox PDF + carte des pensées -->
|
||||
<div class="carte-pensees-ctrl">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showFracasPdf"
|
||||
class="fracas-check"
|
||||
title="Superposer la carte FRACAS en PDF"
|
||||
/>
|
||||
<button
|
||||
@click="contentView = 'bonpote'"
|
||||
:class="{ active: contentView === 'bonpote' }"
|
||||
class="toggle-btn carte-pensees-btn"
|
||||
title="Carte des pensées écologiques — référence FRACAS Bonpote V2"
|
||||
>
|
||||
📗 carte des pensées écologiques
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RAG backend -->
|
||||
<button
|
||||
@click="contentView = 'rag-backend'"
|
||||
:class="{ active: contentView === 'rag-backend' }"
|
||||
class="toggle-btn"
|
||||
title="Interface LightRAG backend"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
RAG backend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POIGNEE DRAGGABLE (split uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte' && layoutMode === 'split'"
|
||||
class="split-handle"
|
||||
@mousedown.prevent="onHandleMousedown"
|
||||
title="Redimensionner"
|
||||
>
|
||||
<span class="split-handle-grip"></span>
|
||||
</div>
|
||||
|
||||
<!-- SLOT CHATBOT (mode carte uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte'"
|
||||
class="chatbot-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'chatbot-split' : '',
|
||||
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
||||
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
|
||||
>
|
||||
<ClientOnly>
|
||||
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- VUE BONPOTE -->
|
||||
<div
|
||||
v-if="contentView === 'bonpote'"
|
||||
class="flex-1 overflow-y-auto px-6 py-8"
|
||||
style="max-width: 680px; margin: 0 auto;"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
|
||||
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
|
||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
|
||||
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
|
||||
</p>
|
||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
|
||||
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Lire l'article Bonpote + carte interactive
|
||||
</a>
|
||||
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Telecharger le poster PDF (recto/verso)
|
||||
</a>
|
||||
<button
|
||||
@click="showCarte"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Interroger le RAG ATIS sur ces pensees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
|
||||
class="flex items-start gap-3 px-3 py-2 rounded-lg"
|
||||
style="background: var(--nav-bg-alt);">
|
||||
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
|
||||
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VUE RAG BACKEND -->
|
||||
<div
|
||||
v-if="contentView === 'rag-backend'"
|
||||
style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"
|
||||
>
|
||||
<MediaTabBackend />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Fiche auteur modal -->
|
||||
<FicheAuteur
|
||||
:open="ficheOpen"
|
||||
:auteurId="ficheAuteurId"
|
||||
:data="penseesData"
|
||||
@close="ficheOpen = false"
|
||||
@interroger-rag="onInterrogerRag"
|
||||
/>
|
||||
|
||||
<!-- Fiche ecole modal -->
|
||||
<FicheEcole
|
||||
:open="ficheEcoleOpen"
|
||||
:ecoleId="ficheEcoleId"
|
||||
:data="penseesData"
|
||||
@close="ficheEcoleOpen = false"
|
||||
@select-auteur="onSelectAuteurFromEcole"
|
||||
@interroger-ecole="onInterrogerEcole"
|
||||
/>
|
||||
|
||||
<!-- Modal info RAG -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
|
||||
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Chunking intelligent</p>
|
||||
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
|
||||
type ContentView = 'carte' | 'bonpote' | 'rag-backend'
|
||||
|
||||
const LAYOUT_KEY = 'media-layout-mode'
|
||||
const CONTENT_KEY = 'media-content-view'
|
||||
const SPLIT_RATIO_KEY = 'media-split-ratio'
|
||||
const DEFAULT_SPLIT_RATIO = 0.66
|
||||
|
||||
const ficheOpen = ref(false)
|
||||
const ficheAuteurId = ref<string | null>(null)
|
||||
const ficheEcoleOpen = ref(false)
|
||||
const ficheEcoleId = ref<string | null>(null)
|
||||
const ragInfoOpen = ref(false)
|
||||
const chatbotAuteur = ref<string | null>(null)
|
||||
|
||||
const layoutMode = ref<LayoutMode>('split')
|
||||
const contentView = ref<ContentView>('carte')
|
||||
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
||||
|
||||
const showFracasPdf = ref(false)
|
||||
const fracasOpacity = ref(60)
|
||||
|
||||
const penseesData = ref<PenseesData | null>(null)
|
||||
|
||||
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
||||
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
||||
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
||||
|
||||
let dragStartY = 0
|
||||
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
||||
let containerHeight = 0
|
||||
|
||||
function showCarte() {
|
||||
contentView.value = 'carte'
|
||||
layoutMode.value = 'split'
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(CONTENT_KEY, 'carte')
|
||||
localStorage.setItem(LAYOUT_KEY, 'split')
|
||||
}
|
||||
nextTick(() => cartePenseesRef.value?.triggerResize())
|
||||
}
|
||||
|
||||
function onHandleMousedown(e: MouseEvent) {
|
||||
dragStartY = e.clientY
|
||||
dragStartRatio = splitRatio.value
|
||||
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
|
||||
containerHeight = container ? container.clientHeight : window.innerHeight
|
||||
window.addEventListener('mousemove', onHandleMousemove)
|
||||
window.addEventListener('mouseup', onHandleMouseup)
|
||||
}
|
||||
|
||||
function onHandleMousemove(e: MouseEvent) {
|
||||
const delta = e.clientY - dragStartY
|
||||
const newRatio = dragStartRatio + delta / containerHeight
|
||||
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
|
||||
}
|
||||
|
||||
function onHandleMouseup() {
|
||||
window.removeEventListener('mousemove', onHandleMousemove)
|
||||
window.removeEventListener('mouseup', onHandleMouseup)
|
||||
if (typeof window !== 'undefined') localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedLayout = localStorage.getItem(LAYOUT_KEY) as LayoutMode | null
|
||||
if (savedLayout && (['split', 'carte-full', 'chatbot-full'] as string[]).includes(savedLayout)) {
|
||||
layoutMode.value = savedLayout
|
||||
}
|
||||
const savedContent = localStorage.getItem(CONTENT_KEY) as ContentView | null
|
||||
if (savedContent && (['carte', 'bonpote', 'rag-backend'] as string[]).includes(savedContent)) {
|
||||
contentView.value = savedContent
|
||||
}
|
||||
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
||||
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
||||
splitRatio.value = savedRatio
|
||||
}
|
||||
if (!localStorage.getItem('rag-fracas-info-seen')) {
|
||||
ragInfoOpen.value = true
|
||||
localStorage.setItem('rag-fracas-info-seen', '1')
|
||||
}
|
||||
}
|
||||
try {
|
||||
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||
}
|
||||
})
|
||||
|
||||
watch(layoutMode, (v) => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(LAYOUT_KEY, v)
|
||||
if (v === 'split' || v === 'carte-full') {
|
||||
setTimeout(() => cartePenseesRef.value?.triggerResize(), 350)
|
||||
}
|
||||
})
|
||||
|
||||
watch(contentView, (v) => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(CONTENT_KEY, v)
|
||||
})
|
||||
|
||||
function onSelectAuteur(id: string) {
|
||||
ficheAuteurId.value = id
|
||||
ficheOpen.value = true
|
||||
chatbotAuteur.value = null
|
||||
}
|
||||
|
||||
function onSelectEcole(id: string) {
|
||||
ficheEcoleId.value = id
|
||||
ficheEcoleOpen.value = true
|
||||
}
|
||||
|
||||
function onSelectAuteurFromEcole(auteurId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
onSelectAuteur(auteurId)
|
||||
}
|
||||
|
||||
function onInterrogerEcole(ecoleId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
|
||||
chatbotAuteur.value = ecole?.label ?? null
|
||||
if (contentView.value !== 'carte') showCarte()
|
||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
||||
}
|
||||
|
||||
function onInterrogerRag(auteurId: string) {
|
||||
ficheOpen.value = false
|
||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
||||
chatbotAuteur.value = auteur?.nom ?? null
|
||||
if (contentView.value !== 'carte') showCarte()
|
||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-visuel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* --- Slot carte --- */
|
||||
.carte-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.carte-split {
|
||||
flex: 0 0 66%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-full {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Overlay PDF FRACAS --- */
|
||||
.fracas-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- Barre de toggle (entre carte et chatbot) --- */
|
||||
.layout-toggle-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--nav-bg);
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
||||
min-height: 38px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-color: var(--nav-primary);
|
||||
}
|
||||
|
||||
/* --- Contrôle fusionné carte des pensées --- */
|
||||
.carte-pensees-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(180, 170, 160, 0.3);
|
||||
}
|
||||
|
||||
.fracas-check {
|
||||
margin: 0 2px 0 7px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--nav-primary, #3b6ea5);
|
||||
}
|
||||
|
||||
.carte-pensees-btn {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--nav-primary, #3b6ea5);
|
||||
}
|
||||
|
||||
/* --- Poignee draggable --- */
|
||||
.split-handle {
|
||||
flex-shrink: 0;
|
||||
height: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: row-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.split-handle:hover {
|
||||
background: rgba(180, 170, 160, 0.18);
|
||||
}
|
||||
|
||||
.split-handle-grip {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(160, 150, 140, 0.55) 0px,
|
||||
rgba(160, 150, 140, 0.55) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.split-handle { display: none; }
|
||||
}
|
||||
|
||||
/* --- Slot chatbot --- */
|
||||
.chatbot-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.28);
|
||||
}
|
||||
|
||||
.chatbot-split {
|
||||
flex: 0 0 34%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-full-mode {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Transitions modal RAG info --- */
|
||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||
|
||||
/* --- Mobile --- */
|
||||
@media (max-width: 767px) {
|
||||
.carte-split {
|
||||
flex: 0 0 60vh;
|
||||
height: 60vh;
|
||||
}
|
||||
.chatbot-split {
|
||||
flex: 0 0 calc(40vh - 38px);
|
||||
height: calc(40vh - 38px);
|
||||
}
|
||||
.toggle-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-[1500]"
|
||||
style="background: rgba(26,34,56,0.55);"
|
||||
@click="close"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="mission-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mission-title"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<button
|
||||
class="mission-close"
|
||||
type="button"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="mission-body">
|
||||
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||
<slot>
|
||||
<p class="mission-text">
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Cette carte est née de cette frustration — et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société — et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||
</p>
|
||||
</slot>
|
||||
|
||||
<div class="mission-cta-wrap">
|
||||
<button class="btn-explorer" type="button" @click="close">{{ ctaLabel }}</button>
|
||||
<NuxtLink to="/manifeste" class="link-manifeste" @click="close">Lire le manifeste →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
ctaLabel?: string
|
||||
storageKey?: string
|
||||
}>(), {
|
||||
title: "L'écosystème d'entraide architecte",
|
||||
ctaLabel: 'Explorer la carte',
|
||||
storageKey: 'aep_mission_seen',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
if (typeof window !== 'undefined') {
|
||||
try { localStorage.setItem(props.storageKey, '1') } catch {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-modal {
|
||||
position: fixed;
|
||||
z-index: 1501;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(560px, 92vw);
|
||||
max-height: calc(100dvh - 80px);
|
||||
background: var(--nav-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mission-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
.mission-close:hover { background: var(--nav-surface); }
|
||||
|
||||
.mission-body {
|
||||
padding: 1.75rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mission-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.25;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.mission-text,
|
||||
:slotted(.mission-text) {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
:slotted(.mission-text strong) { font-weight: 700; }
|
||||
:slotted(.mission-text a) { color: var(--nav-primary-solid); text-decoration: underline; }
|
||||
|
||||
.mission-cta-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-explorer {
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-explorer:hover { opacity: 0.88; }
|
||||
|
||||
.link-manifeste {
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.link-manifeste:hover { opacity: 0.75; }
|
||||
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; transform: translate(-50%, -48%); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mission-body { padding: 1.5rem 1.1rem 1.25rem; }
|
||||
.mission-title { font-size: 1.1rem; }
|
||||
.mission-text { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -221,12 +221,7 @@ function updateTileTheme(dark: boolean) {
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
initMap()
|
||||
})
|
||||
})
|
||||
initMap()
|
||||
|
||||
// Observer les changements de classe dark sur <html>
|
||||
themeObserver = new MutationObserver(() => {
|
||||
|
||||
@@ -222,7 +222,12 @@ function updateTileTheme(dark: boolean) {
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
initMap()
|
||||
})
|
||||
})
|
||||
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
<span
|
||||
v-for="fn in orgFonctions(org)"
|
||||
:key="fn"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="url ? 'a' : 'div'"
|
||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="outil-card"
|
||||
:class="{ 'outil-card--link': !!url, 'outil-card--disabled': !url }"
|
||||
>
|
||||
<div class="outil-card__header">
|
||||
<span class="outil-card__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<span :class="['outil-card__badge', `outil-card__badge--${tag}`]">{{ tagLabel }}</span>
|
||||
</div>
|
||||
<h3 class="outil-card__titre">{{ titre }}</h3>
|
||||
<p class="outil-card__desc">{{ description }}</p>
|
||||
<span v-if="cta && url" class="outil-card__cta">{{ cta }}</span>
|
||||
<span v-else-if="!url" class="outil-card__cta outil-card__cta--disabled">Bientôt disponible</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
icon?: string
|
||||
titre: string
|
||||
url?: string | null
|
||||
description?: string
|
||||
cta?: string
|
||||
tag?: string
|
||||
}>()
|
||||
|
||||
const tagLabels: Record<string, string> = {
|
||||
'outil-aep': 'Outil AEP',
|
||||
'inspiration-externe': 'Inspiration',
|
||||
'disponible': 'Disponible',
|
||||
'recommande': 'Recommandé',
|
||||
'a-venir': 'À venir',
|
||||
}
|
||||
|
||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.outil-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.outil-card--link:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.outil-card--disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.outil-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.outil-card__icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.outil-card__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.outil-card__badge--outil-aep {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.outil-card__badge--inspiration-externe {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.outil-card__badge--disponible {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.outil-card__badge--recommande {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.outil-card__badge--a-venir {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.outil-card__titre {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.outil-card__desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.outil-card__cta {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-primary-solid);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.outil-card__cta--disabled {
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Dark mode badge overrides */
|
||||
:global(.dark) .outil-card__badge--outil-aep {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--inspiration-externe {
|
||||
background: #78350f;
|
||||
color: #fde68a;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--disponible {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--recommande {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,41 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="taff-card"
|
||||
:style="`border-left-color: ${tagConfig.accent};`"
|
||||
class="w-full text-left rounded-xl border transition-all duration-200 hover:shadow-md focus-visible:outline-none"
|
||||
:style="`
|
||||
background: var(--nav-surface);
|
||||
border-color: ${tagBorderColor};
|
||||
border-left: 4px solid ${tagAccentColor};
|
||||
`"
|
||||
@click="$emit('open', plateforme)"
|
||||
>
|
||||
<!-- Ligne 1 : tag + badge AO + lien -->
|
||||
<div class="taff-card-top">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="taff-tag" :style="`background: ${tagConfig.bg}; color: ${tagConfig.text};`">
|
||||
{{ tagConfig.emoji }} {{ tagConfig.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="plateforme.type === 'appel-offre-public'"
|
||||
class="taff-badge-ao"
|
||||
>AO public</span>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-2 px-4 pt-4 pb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-0.5">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold shrink-0"
|
||||
:style="`background: ${tagBgColor}; color: ${tagTextColor};`"
|
||||
>
|
||||
<span>{{ tagEmoji }}</span>
|
||||
<span>{{ tagLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="plateforme.type === 'appel-offre-public'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium shrink-0"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>AO public</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-base leading-snug" style="color: var(--nav-text);">
|
||||
{{ plateforme.nom }}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
:href="plateforme.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="taff-visit-btn"
|
||||
class="shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
||||
@click.stop
|
||||
title="Visiter le site"
|
||||
>
|
||||
@@ -33,35 +48,42 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : nom -->
|
||||
<div class="taff-card-name">{{ plateforme.nom }}</div>
|
||||
<!-- Description courte -->
|
||||
<p class="px-4 pb-3 text-sm leading-relaxed line-clamp-2" style="color: var(--nav-text-muted);">
|
||||
{{ plateforme.description_courte }}
|
||||
</p>
|
||||
|
||||
<!-- Ligne 3 : axes (icône + score, compacts) -->
|
||||
<div class="taff-card-axes">
|
||||
<template v-for="axe in AXES" :key="axe.id">
|
||||
<!-- Scoring axes -->
|
||||
<div class="px-4 pb-3 flex items-center gap-2 flex-wrap">
|
||||
<template v-for="axe in axes" :key="axe.id">
|
||||
<span
|
||||
v-if="plateforme.scoring[axe.id] !== null"
|
||||
class="taff-axe-chip"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:style="`background: ${axeScoreBg(plateforme.scoring[axe.id])}; color: ${axeScoreText(plateforme.scoring[axe.id])};`"
|
||||
:title="axe.label"
|
||||
>{{ axe.icon }} {{ plateforme.scoring[axe.id] }}</span>
|
||||
>
|
||||
<span>{{ axe.icon }}</span>
|
||||
<span>{{ plateforme.scoring[axe.id] }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 4 : description (3 lignes max, lisible) -->
|
||||
<p class="taff-card-desc">{{ plateforme.description_courte }}</p>
|
||||
|
||||
<!-- Ligne 5 : secteurs + coût -->
|
||||
<div class="taff-card-footer">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span
|
||||
v-for="s in plateforme.secteurs_servis.slice(0, 3)"
|
||||
:key="s"
|
||||
class="taff-secteur-chip"
|
||||
>{{ SECTEUR_LABELS[s] ?? s }}</span>
|
||||
<span v-if="plateforme.secteurs_servis.length > 3" class="taff-more">+{{ plateforme.secteurs_servis.length - 3 }}</span>
|
||||
</div>
|
||||
<span class="taff-cout">{{ COUT_LABELS[plateforme.cout_entree] ?? plateforme.cout_entree }}</span>
|
||||
<!-- Footer: secteurs + coût -->
|
||||
<div class="px-4 pb-3 flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-for="s in plateforme.secteurs_servis.slice(0, 3)"
|
||||
:key="s"
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs"
|
||||
style="background: var(--nav-bg); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt);"
|
||||
>{{ secteurLabel(s) }}</span>
|
||||
<span
|
||||
v-if="plateforme.secteurs_servis.length > 3"
|
||||
class="text-xs"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>+{{ plateforme.secteurs_servis.length - 3 }}</span>
|
||||
<span class="ml-auto text-xs font-medium" style="color: var(--nav-text-muted);">
|
||||
{{ coutLabel(plateforme.cout_entree) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -72,21 +94,27 @@ import type { PlateformeTaff } from '~/types/plateforme-taff'
|
||||
const props = defineProps<{ plateforme: PlateformeTaff }>()
|
||||
defineEmits<{ open: [p: PlateformeTaff] }>()
|
||||
|
||||
const AXES = [
|
||||
const axes = [
|
||||
{ id: 'remuneration' as const, icon: '🪙', label: 'Rémunération' },
|
||||
{ id: 'transparence' as const, icon: '🔍', label: 'Transparence' },
|
||||
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
|
||||
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
|
||||
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
|
||||
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
|
||||
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
|
||||
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
|
||||
]
|
||||
|
||||
const TAG_CONFIG = {
|
||||
'recommande': { emoji: '✅', label: 'Recommandé AEP', accent: '#5a7a4a', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
|
||||
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', accent: '#c4a472', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a' },
|
||||
'a-eviter': { emoji: '❌', label: 'À éviter', accent: '#a85d3e', bg: 'rgba(168,93,62,0.12)', text: '#7a3322' },
|
||||
'recommande': { emoji: '✅', label: 'Recommandé AEP', accent: '#5a7a4a', bg: 'rgba(90,122,74,0.12)', text: '#3d5534', border: 'rgba(90,122,74,0.25)' },
|
||||
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', accent: '#c4a472', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a', border: 'rgba(196,164,114,0.35)' },
|
||||
'a-eviter': { emoji: '❌', label: 'À éviter', accent: '#a85d3e', bg: 'rgba(168,93,62,0.12)', text: '#7a3322', border: 'rgba(168,93,62,0.25)' },
|
||||
}
|
||||
|
||||
const tagConfig = computed(() => TAG_CONFIG[props.plateforme.scoring.tag_global] ?? TAG_CONFIG['sous-reserve'])
|
||||
const tagEmoji = computed(() => tagConfig.value.emoji)
|
||||
const tagLabel = computed(() => tagConfig.value.label)
|
||||
const tagAccentColor = computed(() => tagConfig.value.accent)
|
||||
const tagBgColor = computed(() => tagConfig.value.bg)
|
||||
const tagTextColor = computed(() => tagConfig.value.text)
|
||||
const tagBorderColor = computed(() => tagConfig.value.border)
|
||||
|
||||
function axeScoreBg(score: string | null) {
|
||||
if (score === '✅') return 'rgba(90,122,74,0.12)'
|
||||
@@ -94,6 +122,7 @@ function axeScoreBg(score: string | null) {
|
||||
if (score === '❌') return 'rgba(168,93,62,0.12)'
|
||||
return 'var(--nav-bg-alt)'
|
||||
}
|
||||
|
||||
function axeScoreText(score: string | null) {
|
||||
if (score === '✅') return '#3d5534'
|
||||
if (score === '⚠️') return '#7a5f2a'
|
||||
@@ -102,143 +131,24 @@ function axeScoreText(score: string | null) {
|
||||
}
|
||||
|
||||
const SECTEUR_LABELS: Record<string, string> = {
|
||||
'renovation': 'Rénovation', 'construction-neuve': 'Neuf', 'urbanisme': 'Urbanisme',
|
||||
'architecture-interieure': 'Archi intérieure', 'paysage': 'Paysage',
|
||||
'mar-conseil': 'MAR/Conseil', 'transversal': 'Transversal',
|
||||
'renovation': 'Rénovation',
|
||||
'construction-neuve': 'Neuf',
|
||||
'urbanisme': 'Urbanisme',
|
||||
'architecture-interieure': 'Archi intérieure',
|
||||
'paysage': 'Paysage',
|
||||
'mar-conseil': 'MAR/Conseil',
|
||||
'transversal': 'Transversal',
|
||||
}
|
||||
|
||||
function secteurLabel(s: string) { return SECTEUR_LABELS[s] ?? s }
|
||||
|
||||
const COUT_LABELS: Record<string, string> = {
|
||||
'gratuit': 'Gratuit', 'freemium': 'Freemium', 'abonnement': 'Abonnement',
|
||||
'lead-paye': 'Lead payant', 'commission': 'Commission',
|
||||
'gratuit': 'Gratuit',
|
||||
'freemium': 'Freemium',
|
||||
'abonnement': 'Abonnement',
|
||||
'lead-paye': 'Lead payant',
|
||||
'commission': 'Commission',
|
||||
}
|
||||
|
||||
function coutLabel(c: string) { return COUT_LABELS[c] ?? c }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.taff-card {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-left: 4px solid;
|
||||
background: var(--nav-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.taff-card:hover { box-shadow: 0 4px 16px rgba(26,34,56,0.1); }
|
||||
.taff-card:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
|
||||
|
||||
.taff-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.taff-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.taff-badge-ao {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.taff-visit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.taff-visit-btn:hover { opacity: 0.7; }
|
||||
|
||||
.taff-card-name {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.taff-card-axes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 1rem 0.875rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.taff-axe-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.taff-card-desc {
|
||||
padding: 0 1rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: var(--nav-text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.taff-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.taff-secteur-chip {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text-muted);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.taff-more {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.taff-cout {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="url ? 'a' : 'div'"
|
||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="simu-feature"
|
||||
:class="{ 'simu-feature--link': !!url }"
|
||||
>
|
||||
<div class="simu-feature__inner">
|
||||
<div class="simu-feature__left">
|
||||
<span class="simu-feature__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<div class="simu-feature__body">
|
||||
<div class="simu-feature__header">
|
||||
<h3 class="simu-feature__titre">{{ titre }}</h3>
|
||||
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
|
||||
</div>
|
||||
<p class="simu-feature__desc">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
icon?: string
|
||||
titre: string
|
||||
url?: string | null
|
||||
description?: string
|
||||
cta?: string
|
||||
tag?: string
|
||||
}>()
|
||||
|
||||
const tagLabels: Record<string, string> = {
|
||||
'outil-aep': 'Outil AEP',
|
||||
'inspiration-externe': 'Inspiration externe',
|
||||
'disponible': 'Disponible',
|
||||
'recommande': 'Recommandé',
|
||||
'a-venir': 'À venir',
|
||||
}
|
||||
|
||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simu-feature {
|
||||
display: block;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: box-shadow 0.2s, border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.simu-feature--link:hover {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--nav-primary-solid);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.simu-feature__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.simu-feature__left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simu-feature__icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.simu-feature__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simu-feature__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.simu-feature__titre {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.simu-feature__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.simu-feature__badge--inspiration-externe {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.simu-feature__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.simu-feature__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simu-feature--link:hover .simu-feature__cta {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
:global(.dark) .simu-feature__badge {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .simu-feature__badge--inspiration-externe {
|
||||
background: #78350f;
|
||||
color: #fde68a;
|
||||
}
|
||||
</style>
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<ul class="tree-ascii" :class="{ 'tree-ascii--root': depth === 0 }" :style="{ '--depth': depth }">
|
||||
<li
|
||||
v-for="(node, i) in tree.children"
|
||||
:key="i"
|
||||
class="tree-ascii__node"
|
||||
>
|
||||
<!-- Nœud avec enfants : bouton toggle -->
|
||||
<template v-if="node.children && node.children.length">
|
||||
<button
|
||||
class="tree-ascii__branch"
|
||||
:aria-expanded="!!open[i]"
|
||||
@click="toggle(i)"
|
||||
>
|
||||
<span class="tree-ascii__chevron" aria-hidden="true">{{ open[i] ? '▼' : '▶' }}</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span class="tree-ascii__count">({{ node.children.length }})</span>
|
||||
</button>
|
||||
<TreeASCII
|
||||
v-if="open[i]"
|
||||
:tree="node"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Feuille avec URL : lien cliquable -->
|
||||
<template v-else-if="node.url">
|
||||
<a
|
||||
:href="node.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tree-ascii__leaf tree-ascii__leaf--link"
|
||||
>
|
||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
||||
<svg class="tree-ascii__ext" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Feuille sans URL -->
|
||||
<template v-else>
|
||||
<span class="tree-ascii__leaf">
|
||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
url?: string
|
||||
desc?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
export interface TreeData {
|
||||
name?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tree: TreeData
|
||||
depth?: number
|
||||
}>(), {
|
||||
depth: 0
|
||||
})
|
||||
|
||||
// Toutes les branches fermées par défaut
|
||||
const open = ref<Record<number, boolean>>({})
|
||||
|
||||
function toggle(i: number) {
|
||||
open.value[i] = !open.value[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-ascii {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
padding-left: calc(var(--depth, 0) * 1.25rem + 0.5rem);
|
||||
}
|
||||
|
||||
.tree-ascii--root {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__node {
|
||||
margin: 2px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Bouton branche (nœud avec enfants) */
|
||||
.tree-ascii__branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--nav-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tree-ascii__branch:hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.tree-ascii__chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--nav-text-muted);
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Feuille */
|
||||
.tree-ascii__leaf {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link {
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link:hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tree-ascii__prefix {
|
||||
color: var(--nav-text-muted);
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link .tree-ascii__label {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tree-ascii__desc {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.tree-ascii__ext {
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tree-ascii__desc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,450 +0,0 @@
|
||||
<template>
|
||||
<div ref="container" class="codev-graph-wrap">
|
||||
|
||||
<!-- Placeholder si aucune fiche -->
|
||||
<div v-if="fiches.length === 0" class="empty-state">
|
||||
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
|
||||
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche →</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- SVG D3 -->
|
||||
<svg v-else ref="svgEl" class="codev-svg">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow-solution"
|
||||
viewBox="0 0 10 10"
|
||||
refX="18"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as d3 from 'd3'
|
||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||
|
||||
// ── Props / Emits ──────────────────────────────────────────────────────────
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
fiches: CodevFiche[]
|
||||
matches?: CodevMatch[]
|
||||
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
||||
showLabels?: boolean
|
||||
}>(), {
|
||||
matches: () => [],
|
||||
mode: 'none',
|
||||
showLabels: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-fiche': [id: number]
|
||||
}>()
|
||||
|
||||
// ── Refs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null)
|
||||
const svgEl = ref<SVGSVGElement | null>(null)
|
||||
const width = ref(800)
|
||||
const height = ref(600)
|
||||
|
||||
// ── State interne ──────────────────────────────────────────────────────────
|
||||
|
||||
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
||||
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||||
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||||
|
||||
const isMobile = computed(() => width.value < 600)
|
||||
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncate(str: string, max = 10): string {
|
||||
if (!str) return ''
|
||||
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
||||
}
|
||||
|
||||
function buildNodes(): SimNode[] {
|
||||
return props.fiches.map(f => ({
|
||||
id: f.id,
|
||||
nom: f.nom,
|
||||
offre: f.offre,
|
||||
besoin: f.besoin,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLinks(nodes: SimNode[]): SimLink[] {
|
||||
if (!props.matches || props.matches.length === 0) return []
|
||||
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
||||
return props.matches
|
||||
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
|
||||
.map(m => ({
|
||||
source: nodeById.get(m.fromId)!,
|
||||
target: nodeById.get(m.toId)!,
|
||||
score: m.score,
|
||||
mode: m.mode,
|
||||
}))
|
||||
}
|
||||
|
||||
function linkColor(mode: string): string {
|
||||
if (mode === 'solution') return '#22c55e'
|
||||
if (mode === 'alliance') return '#f97316'
|
||||
if (mode === 'surprise') return '#3b82f6'
|
||||
return '#ccc'
|
||||
}
|
||||
|
||||
// ── Drag handler ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
|
||||
return d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) sim.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) sim.alphaTarget(0)
|
||||
d.fx = null
|
||||
d.fy = null
|
||||
})
|
||||
}
|
||||
|
||||
// ── Initialisation SVG ─────────────────────────────────────────────────────
|
||||
|
||||
function initSvg() {
|
||||
if (!svgEl.value) return
|
||||
|
||||
svgRoot = d3.select(svgEl.value)
|
||||
.attr('width', width.value)
|
||||
.attr('height', height.value)
|
||||
|
||||
svgRoot.selectAll('*').remove()
|
||||
|
||||
gLinks = svgRoot.append('g').attr('class', 'links')
|
||||
gNodes = svgRoot.append('g').attr('class', 'nodes')
|
||||
}
|
||||
|
||||
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
|
||||
|
||||
let currentNodes: SimNode[] = []
|
||||
let currentLinks: SimLink[] = []
|
||||
|
||||
function rebuildLinks() {
|
||||
currentLinks = buildLinks(currentNodes)
|
||||
if (!gLinks || !simulation) return
|
||||
|
||||
// .join() moderne D3 pour garantir le re-rendu complet
|
||||
gLinks
|
||||
.selectAll<SVGLineElement, SimLink>('line')
|
||||
.data(currentLinks)
|
||||
.join(
|
||||
enter => enter.append('line'),
|
||||
update => update,
|
||||
exit => exit.remove()
|
||||
)
|
||||
.attr('stroke', d => linkColor(d.mode))
|
||||
.attr('stroke-width', d => 1 + d.score * 3)
|
||||
.attr('stroke-opacity', 0.7)
|
||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||
}
|
||||
|
||||
// ── Rendu complet ──────────────────────────────────────────────────────────
|
||||
|
||||
function render() {
|
||||
if (!svgEl.value || props.fiches.length === 0) return
|
||||
|
||||
initSvg()
|
||||
|
||||
currentNodes = buildNodes()
|
||||
currentLinks = buildLinks(currentNodes)
|
||||
|
||||
const r = nodeRadius.value
|
||||
const fontSize = isMobile.value ? 10 : 12
|
||||
|
||||
// Liens
|
||||
gLinks!
|
||||
.selectAll<SVGLineElement, SimLink>('line')
|
||||
.data(currentLinks)
|
||||
.join('line')
|
||||
.attr('stroke', d => linkColor(d.mode))
|
||||
.attr('stroke-width', d => 1 + d.score * 3)
|
||||
.attr('stroke-opacity', 0.7)
|
||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||
|
||||
// Noeuds = groupe <g> par personne
|
||||
const nodeGroups = gNodes!
|
||||
.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.data(currentNodes, d => String(d.id))
|
||||
.join('g')
|
||||
.attr('class', 'node')
|
||||
.style('cursor', 'pointer')
|
||||
.call(makeDrag(simulation!) as any)
|
||||
.on('click', (_event, d) => emit('select-fiche', d.id))
|
||||
|
||||
// Cercle principal
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', r)
|
||||
.attr('fill', '#ffffff')
|
||||
.attr('stroke', '#1B4436')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
// Label nom
|
||||
nodeGroups.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'central')
|
||||
.attr('font-size', fontSize)
|
||||
.attr('font-weight', '700')
|
||||
.attr('fill', '#1a1a2e')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => truncate(d.nom, 10))
|
||||
|
||||
// Pastille offre (haut-droite, vert)
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('cx', r * 0.65)
|
||||
.attr('cy', -r * 0.65)
|
||||
.attr('fill', '#22c55e')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
// Pastille besoin (bas-droite, bleu)
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('cx', r * 0.65)
|
||||
.attr('cy', r * 0.65)
|
||||
.attr('fill', '#3b82f6')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
|
||||
// Tooltip SVG natif <title>
|
||||
nodeGroups.append('title')
|
||||
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
||||
|
||||
// Groupe label bulle (affiche si showLabels)
|
||||
const labelGroups = nodeGroups.append('g')
|
||||
.attr('class', 'label-bubble')
|
||||
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
||||
|
||||
// Fond bulle besoin (dessous du noeud)
|
||||
labelGroups.append('rect')
|
||||
.attr('class', 'bubble-besoin-bg')
|
||||
.attr('x', -(r + 50))
|
||||
.attr('y', r + 4)
|
||||
.attr('width', 100)
|
||||
.attr('height', 28)
|
||||
.attr('rx', 6)
|
||||
.attr('fill', '#eff6ff')
|
||||
.attr('stroke', '#3b82f6')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// Texte besoin
|
||||
labelGroups.append('text')
|
||||
.attr('class', 'bubble-besoin-txt')
|
||||
.attr('x', -(r) + 50)
|
||||
.attr('y', r + 22)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#1e40af')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => truncate(d.besoin, 18))
|
||||
|
||||
// Fond bulle offre (dessus du noeud)
|
||||
labelGroups.append('rect')
|
||||
.attr('class', 'bubble-offre-bg')
|
||||
.attr('x', -(r + 50))
|
||||
.attr('y', -(r + 32))
|
||||
.attr('width', 100)
|
||||
.attr('height', 28)
|
||||
.attr('rx', 6)
|
||||
.attr('fill', '#f0fdf4')
|
||||
.attr('stroke', '#22c55e')
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// Texte offre
|
||||
labelGroups.append('text')
|
||||
.attr('class', 'bubble-offre-txt')
|
||||
.attr('x', -(r) + 50)
|
||||
.attr('y', -(r + 14))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#166534')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => truncate(d.offre, 18))
|
||||
|
||||
// Simulation
|
||||
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||
.id(d => d.id)
|
||||
.distance(120)
|
||||
.strength(0.3))
|
||||
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
||||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
||||
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
||||
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
||||
.alphaDecay(0.02)
|
||||
.on('tick', tick)
|
||||
|
||||
// Re-bind drag avec la nouvelle simulation
|
||||
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.call(makeDrag(simulation) as any)
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const r = nodeRadius.value
|
||||
if (!gLinks || !gNodes) return
|
||||
|
||||
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
||||
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
||||
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
||||
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
||||
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
||||
|
||||
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.attr('transform', d => {
|
||||
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
||||
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
||||
return `translate(${x},${y})`
|
||||
})
|
||||
}
|
||||
|
||||
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
||||
|
||||
watch(() => [props.matches, props.mode] as const, () => {
|
||||
if (!simulation) return
|
||||
rebuildLinks()
|
||||
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||
.id(d => String(d.id))
|
||||
.distance(120)
|
||||
.strength(0.5)
|
||||
simulation.force('link', newForce)
|
||||
simulation.alpha(0.8).restart()
|
||||
}, { deep: true })
|
||||
|
||||
// ── Watch showLabels ──────────────────────────────────────────────────────
|
||||
|
||||
watch(() => props.showLabels, (val) => {
|
||||
if (!svgEl.value) return
|
||||
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
||||
})
|
||||
|
||||
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
||||
|
||||
watch(() => props.fiches, () => {
|
||||
if (simulation) {
|
||||
simulation.stop()
|
||||
simulation = null
|
||||
}
|
||||
render()
|
||||
}, { deep: true })
|
||||
|
||||
// ── ResizeObserver ─────────────────────────────────────────────────────────
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!container.value) return
|
||||
width.value = container.value.clientWidth || 800
|
||||
height.value = container.value.clientHeight || 600
|
||||
|
||||
render()
|
||||
|
||||
ro = new ResizeObserver(() => {
|
||||
if (!container.value) return
|
||||
width.value = container.value.clientWidth || 800
|
||||
height.value = container.value.clientHeight || 600
|
||||
if (svgRoot) {
|
||||
svgRoot.attr('width', width.value).attr('height', height.value)
|
||||
}
|
||||
if (simulation) {
|
||||
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||
simulation.alpha(0.3).restart()
|
||||
}
|
||||
})
|
||||
ro.observe(container.value!)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (simulation) simulation.stop()
|
||||
if (ro) ro.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codev-graph-wrap {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
min-height: 320px;
|
||||
position: relative;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codev-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Etat vide ── */
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-size: 1.125rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-primary-solid, #1B4436);
|
||||
text-decoration: none;
|
||||
border: 1.5px solid var(--nav-primary-solid, #1B4436);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.empty-link:hover {
|
||||
background: var(--nav-primary-solid, #1B4436);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.codev-graph-wrap {
|
||||
height: 65vh;
|
||||
min-height: 260px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Convertit du Markdown Mistral en HTML avec inline styles.
|
||||
* Inline styles = zéro dépendance CSS, fonctionne dans tout contexte Vue (scoped, v-html, etc.)
|
||||
*/
|
||||
export function useMarkdown() {
|
||||
const S = {
|
||||
p: 'style="margin:0 0 0.45em;line-height:1.6;"',
|
||||
strong: 'style="font-weight:700;"',
|
||||
em: 'style="font-style:italic;"',
|
||||
h2: 'style="font-weight:700;display:block;margin-bottom:0.2em;"',
|
||||
h3: 'style="font-weight:700;display:block;font-size:0.95em;margin-bottom:0.15em;"',
|
||||
ul: 'style="margin:0.3em 0 0.3em 1.2em;padding:0;list-style:disc;"',
|
||||
li: 'style="margin-bottom:0.15em;"',
|
||||
a: 'style="text-decoration:underline;opacity:0.85;"',
|
||||
}
|
||||
|
||||
function render(text: string): string {
|
||||
if (!text) return ''
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, `<strong ${S.h3}>$1</strong>`)
|
||||
.replace(/^## (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/^# (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||
.replace(/\*\*(.+?)\*\*/g, `<strong ${S.strong}>$1</strong>`)
|
||||
.replace(/\*(.+?)\*/g, `<em ${S.em}>$1</em>`)
|
||||
.replace(/^[-•]\s+(.+)$/gm, `<li ${S.li}>$1</li>`)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank" rel="noopener" ${S.a}>$1</a>`)
|
||||
|
||||
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul ${S.ul}>${m}</ul>`)
|
||||
html = html.replace(/\n{2,}/g, `</p><p ${S.p}>`)
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
return `<p ${S.p}>${html}</p>`
|
||||
}
|
||||
|
||||
return { render }
|
||||
}
|
||||
@@ -19,10 +19,6 @@ export default defineNuxtConfig({
|
||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||
resendApiKey: process.env.RESEND_API_KEY,
|
||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||
},
|
||||
|
||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
</NuxtLink>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION INTRO - À propos d'AEP
|
||||
SECTION 1 - Mission AEP
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||
<section class="section-mission">
|
||||
<h1>À propos d'AEP</h1>
|
||||
<h1>Architecture d'Écologie Politique</h1>
|
||||
<p class="mission-text">
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -205,14 +209,11 @@ useHead({ title: 'À propos - AEP' })
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg);
|
||||
padding: 1.5rem 1rem 5rem;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.apropos-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||
@@ -321,16 +322,13 @@ useHead({ title: 'À propos - AEP' })
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-detail {
|
||||
font-size: 0.775rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
.badge-label { white-space: nowrap; }
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 559px) {
|
||||
|
||||
@@ -157,20 +157,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer desktop -->
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgsLegacy"
|
||||
:selectedId="selectedIdLegacyNum"
|
||||
@select-org="() => {}"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden" style="background: var(--nav-bg);">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgsLegacy"
|
||||
:selectedId="selectedIdLegacyNum"
|
||||
@select-org="() => {}"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder
|
||||
@highlightOrgs="() => {}"
|
||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||
/></div>
|
||||
|
||||
<!-- Vue graphique desktop -->
|
||||
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
|
||||
@@ -196,7 +201,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
@@ -212,13 +217,6 @@
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'outremer'"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'graphe'"
|
||||
>Graphe</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
@@ -255,25 +253,8 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Vue graphique mobile -->
|
||||
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<GraphView
|
||||
:data="bifurcationData"
|
||||
:allHashtags="allHashtags"
|
||||
:active="mobileMapView === 'graphe'"
|
||||
@select-structure="onSelectStructureMobile"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement du graphe…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
||||
<!-- Bottom sheet swipable -->
|
||||
<ClientOnly>
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Bandeau intention mobile -->
|
||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||
@@ -398,41 +379,12 @@
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||
<ChatbotReseaux
|
||||
<ChatbotSheet
|
||||
:modelValue="chatbotOpen"
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
@highlightOrgs="() => {}"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
||||
<button
|
||||
class="reseaux-info-btn"
|
||||
type="button"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos des réseaux AEP cartographiés"
|
||||
title="À propos de cette carte"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
title="Réseaux AEP — l'architecture qui s'engage"
|
||||
ctaLabel="Explorer les 120 réseaux"
|
||||
storageKey="aep_reseaux_seen"
|
||||
>
|
||||
<p class="mission-text">
|
||||
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
</p>
|
||||
</MissionPopup>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -459,17 +411,8 @@ const hoveredId = ref<string | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<string | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Filtres
|
||||
const search = ref('')
|
||||
@@ -577,29 +520,3 @@ function onSelectStructureMobile(id: string) {
|
||||
|
||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reseaux-info-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.reseaux-info-btn { bottom: 16px; left: 340px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
<template>
|
||||
<div class="codev-carto">
|
||||
|
||||
<header class="carto-header">
|
||||
<h1>Carto entraide</h1>
|
||||
<p class="carto-subtitle">
|
||||
<template v-if="pending">Chargement...</template>
|
||||
<template v-else>
|
||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
||||
</template>
|
||||
</p>
|
||||
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
||||
</header>
|
||||
|
||||
<div class="codev-tabs">
|
||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'carto'">
|
||||
<div class="show-labels-bar">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: showLabels }"
|
||||
@click="showLabels = !showLabels"
|
||||
>
|
||||
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
:show-labels="showLabels"
|
||||
@select-fiche="onSelectFiche"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
</div>
|
||||
|
||||
<!-- Boutons matching -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - compétence</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partagés</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||
|
||||
<div v-if="fiches.length === 0" class="list-empty">
|
||||
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="annuaire-scroll">
|
||||
<table class="annuaire-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-nom">Prénom</th>
|
||||
<th class="col-besoin">Besoin</th>
|
||||
<th class="col-offre">Ce que j'offre</th>
|
||||
<th v-if="isAdmin" class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
||||
<td class="col-nom">{{ f.nom }}</td>
|
||||
<td class="col-besoin">{{ f.besoin }}</td>
|
||||
<td class="col-offre">{{ f.offre }}</td>
|
||||
<td v-if="isAdmin" class="col-actions">
|
||||
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
||||
</div>
|
||||
|
||||
<!-- FAB ajouter une fiche -->
|
||||
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
||||
+
|
||||
</NuxtLink>
|
||||
|
||||
<Transition name="sheet">
|
||||
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
||||
<div class="sheet-content">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
||||
<div class="sheet-section">
|
||||
<span class="sheet-label">Besoin</span>
|
||||
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
||||
</div>
|
||||
<div class="sheet-section">
|
||||
<span class="sheet-label">Ce que j'apporte</span>
|
||||
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
||||
</div>
|
||||
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
||||
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
||||
</div>
|
||||
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
||||
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||
import { computeMatches } from '~/utils/codev/matching'
|
||||
|
||||
useHead({ title: 'Carto - Co-developpement' })
|
||||
|
||||
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
||||
const fiches = computed(() => data.value?.list ?? [])
|
||||
|
||||
const matches = ref<CodevMatch[]>([])
|
||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||
const showLabels = ref(false)
|
||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||
const selectedFiche = ref<CodevFiche | null>(null)
|
||||
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
||||
|
||||
const isAdmin = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
||||
isAdmin.value = r.admin
|
||||
} catch { isAdmin.value = false }
|
||||
})
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
solution: 'Solution',
|
||||
alliance: 'Alliance',
|
||||
surprise: 'Surprise',
|
||||
}
|
||||
|
||||
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
||||
mode.value = newMode
|
||||
if (newMode === 'none') {
|
||||
matches.value = []
|
||||
} else {
|
||||
matches.value = computeMatches(fiches.value, newMode)
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectFiche(id: number) {
|
||||
if (isMobileView) {
|
||||
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
|
||||
} else {
|
||||
navigateTo(`/codev/fiche?id=${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFiche(id: number) {
|
||||
if (!confirm('Supprimer la fiche ?')) return
|
||||
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codev-carto {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
gap: 1rem;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── En-tete ── */
|
||||
|
||||
.carto-header {
|
||||
text-align: center;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.carto-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.carto-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Fallback ── */
|
||||
|
||||
.graph-fallback {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
background: var(--nav-bg-alt, #f3f4f6);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ── Bandeau mode actif ── */
|
||||
|
||||
.mode-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #166534;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-clear {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
background: transparent;
|
||||
border: 1px solid #166534;
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-clear:hover {
|
||||
background: #166534;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Boutons matching ── */
|
||||
|
||||
.matching-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 0 -1rem -2rem;
|
||||
}
|
||||
|
||||
.matching-controls button {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #d0d4dc;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.matching-controls button .hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.matching-controls button.active {
|
||||
background: var(--mode-color, #1B4436);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.matching-controls button.active .hint {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.matching-controls button.reset {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-color: #d0d4dc;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.matching-controls button.reset:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.matching-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin: 0 -0.75rem -1.5rem;
|
||||
}
|
||||
|
||||
.matching-controls button.reset {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Toggle besoins/offres ── */
|
||||
|
||||
.show-labels-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.show-labels-bar button {
|
||||
border: 1px solid #d0d4dc;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.show-labels-bar button.active {
|
||||
background: #1B4436;
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── FAB ajouter ── */
|
||||
|
||||
.fab-add {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 16px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #1B4436;
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
transition: transform 0.15s, opacity 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fab-add:hover {
|
||||
transform: scale(1.08);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
|
||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
|
||||
/* ── List view ── */
|
||||
|
||||
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
||||
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
|
||||
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
|
||||
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
|
||||
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
|
||||
|
||||
/* ── Bottom sheet ── */
|
||||
|
||||
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
|
||||
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
|
||||
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
|
||||
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
||||
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
||||
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
|
||||
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
|
||||
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
|
||||
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
|
||||
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
||||
|
||||
/* ── QR link ── */
|
||||
|
||||
.qr-link {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.qr-link:hover { color: #6b7280; }
|
||||
|
||||
/* ── Annuaire ── */
|
||||
|
||||
.annuaire-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.annuaire-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.annuaire-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 480px;
|
||||
}
|
||||
|
||||
.annuaire-table thead tr {
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.annuaire-table th {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.annuaire-table td {
|
||||
padding: 12px 14px;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.annuaire-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.annuaire-row:hover { background: #f9fafb; }
|
||||
.annuaire-row:last-child td { border-bottom: none; }
|
||||
|
||||
.col-nom {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: #ffffff;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e !important;
|
||||
white-space: nowrap;
|
||||
min-width: 80px;
|
||||
border-right: 2px solid #e5e7eb;
|
||||
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||
|
||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||
.col-offre { min-width: 200px; max-width: 260px; }
|
||||
|
||||
.annuaire-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.col-actions { width: 40px; text-align: center; }
|
||||
.delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #ef4444;
|
||||
font-size: 1rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.delete-btn:hover { background: #fef2f2; }
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.codev-carto {
|
||||
padding: 1rem 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.carto-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,406 +0,0 @@
|
||||
<template>
|
||||
<div class="codev-demo">
|
||||
|
||||
<header class="demo-header">
|
||||
<span class="demo-badge">DEMO</span>
|
||||
<h1>Co-developpement - exemple</h1>
|
||||
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
||||
</header>
|
||||
|
||||
<div class="codev-tabs">
|
||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'carto'">
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
</div>
|
||||
|
||||
<!-- Boutons matching -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - offre</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partages</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||
<div class="annuaire-scroll">
|
||||
<table class="annuaire-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-nom">Prénom</th>
|
||||
<th class="col-besoin">Besoin</th>
|
||||
<th class="col-offre">Ce que j'offre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
||||
<td class="col-nom">{{ f.nom }}</td>
|
||||
<td class="col-besoin">{{ f.besoin }}</td>
|
||||
<td class="col-offre">{{ f.offre }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||
import { computeMatches } from '~/utils/codev/matching'
|
||||
|
||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||
|
||||
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
|
||||
//
|
||||
// Solution (scoreDirect besoinA vs offreB) :
|
||||
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
||||
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
||||
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
||||
//
|
||||
// Alliance (besoins similaires) :
|
||||
// Lea + Maya (coaching, lancer, offre) ✓
|
||||
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
||||
// Sami + Kenji (vendre, formations) ✓
|
||||
//
|
||||
// Surprise (offres similaires) :
|
||||
// Lea + Zoe (facilitation, groupes) ✓
|
||||
// Tom + Roman (architecture) ✓
|
||||
// Ines + Nael (marketing, formations) ✓
|
||||
|
||||
const FICHES_DEMO: CodevFiche[] = [
|
||||
{
|
||||
id: 1, nom: 'Lea',
|
||||
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
||||
offre: 'Facilitation de groupes et animation de cercles de parole',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2, nom: 'Sami',
|
||||
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
||||
offre: 'Developpement web sur mesure, creation de sites et applications',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 3, nom: 'Ines',
|
||||
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
||||
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:02:00Z',
|
||||
},
|
||||
{
|
||||
id: 4, nom: 'Tom',
|
||||
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
||||
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:03:00Z',
|
||||
},
|
||||
{
|
||||
id: 5, nom: 'Maya',
|
||||
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
||||
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:04:00Z',
|
||||
},
|
||||
{
|
||||
id: 6, nom: 'Kenji',
|
||||
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
||||
offre: 'Photographie professionnelle et direction artistique editoriale',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 7, nom: 'Zoe',
|
||||
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
||||
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:06:00Z',
|
||||
},
|
||||
{
|
||||
id: 8, nom: 'Nael',
|
||||
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
||||
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:07:00Z',
|
||||
},
|
||||
{
|
||||
id: 9, nom: 'Eva',
|
||||
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
||||
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:08:00Z',
|
||||
},
|
||||
{
|
||||
id: 10, nom: 'Roman',
|
||||
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
||||
offre: 'Architecture technique et plans pour renovation energetique',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:09:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const fiches = ref(FICHES_DEMO)
|
||||
const matches = ref<CodevMatch[]>([])
|
||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
solution: 'Solution',
|
||||
alliance: 'Alliance',
|
||||
surprise: 'Surprise',
|
||||
}
|
||||
|
||||
useHead({ title: 'Demo - Co-developpement' })
|
||||
|
||||
function setMode(newMode: typeof mode.value) {
|
||||
mode.value = newMode
|
||||
if (newMode === 'none') {
|
||||
matches.value = []
|
||||
} else {
|
||||
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codev-demo {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
gap: 1rem;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── En-tete ── */
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: inline-block;
|
||||
background: #f97316;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.demo-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Fallback ── */
|
||||
|
||||
.graph-fallback {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
background: var(--nav-bg-alt, #f3f4f6);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
|
||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
|
||||
/* ── Annuaire ── */
|
||||
|
||||
.annuaire-wrap { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.annuaire-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||
.annuaire-table { width: 100%; border-collapse: collapse; min-width: 480px; }
|
||||
.annuaire-table thead tr { background: #f9fafb; border-bottom: 2px solid #e5e7eb; }
|
||||
.annuaire-table th { padding: 10px 14px; text-align: left; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; white-space: nowrap; }
|
||||
.annuaire-table td { padding: 12px 14px; font-size: 0.875rem; color: #374151; vertical-align: top; border-bottom: 1px solid #f3f4f6; line-height: 1.5; }
|
||||
.annuaire-row { transition: background 0.12s; }
|
||||
.annuaire-row:hover { background: #f9fafb; }
|
||||
.annuaire-row:last-child td { border-bottom: none; }
|
||||
.col-nom { position: sticky; left: 0; z-index: 2; background: #ffffff; font-weight: 600; color: #1a1a2e !important; white-space: nowrap; min-width: 80px; border-right: 2px solid #e5e7eb; box-shadow: 2px 0 6px rgba(0,0,0,0.06); }
|
||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||
.col-offre { min-width: 200px; max-width: 260px; }
|
||||
|
||||
/* ── Bandeau mode actif ── */
|
||||
|
||||
.mode-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #166534;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-clear {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
background: transparent;
|
||||
border: 1px solid #166534;
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-clear:hover {
|
||||
background: #166534;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Boutons matching ── */
|
||||
|
||||
.matching-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 0 -1rem -2rem;
|
||||
}
|
||||
|
||||
.matching-controls button {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid #d0d4dc;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.matching-controls button .hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.matching-controls button.active {
|
||||
background: var(--mode-color, #1B4436);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.matching-controls button.active .hint {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.matching-controls button.reset {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-color: #d0d4dc;
|
||||
color: #374151;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.matching-controls button.reset:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.matching-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin: 0 -0.75rem -1.5rem;
|
||||
}
|
||||
|
||||
.matching-controls button.reset {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.codev-demo {
|
||||
padding: 1rem 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.demo-header h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,415 +0,0 @@
|
||||
<template>
|
||||
<div class="fiche-page">
|
||||
<div class="fiche-inner">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="fiche-header">
|
||||
<NuxtLink to="/codev/carto" class="back-link">← Retour à la carte</NuxtLink>
|
||||
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
|
||||
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form class="fiche-form" @submit.prevent="submit" novalidate>
|
||||
|
||||
<!-- Nom -->
|
||||
<div class="field-group">
|
||||
<label for="nom">
|
||||
Prénom <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nom"
|
||||
v-model="form.nom"
|
||||
type="text"
|
||||
placeholder="Ex : Camille"
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="50"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Besoin -->
|
||||
<div class="field-group">
|
||||
<div class="label-row">
|
||||
<label for="besoin">
|
||||
Mon besoin actuel <span class="required">*</span>
|
||||
</label>
|
||||
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
|
||||
</div>
|
||||
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
|
||||
<summary class="sr-only">Aide</summary>
|
||||
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
|
||||
</details>
|
||||
<textarea
|
||||
id="besoin"
|
||||
v-model="form.besoin"
|
||||
rows="3"
|
||||
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
|
||||
required
|
||||
minlength="5"
|
||||
maxlength="300"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
|
||||
{{ form.besoin.length }}/300
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Offre -->
|
||||
<div class="field-group">
|
||||
<div class="label-row">
|
||||
<label for="offre">
|
||||
Ce que j'offre a la communaute <span class="required">*</span>
|
||||
</label>
|
||||
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
|
||||
</div>
|
||||
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
|
||||
<summary class="sr-only">Aide</summary>
|
||||
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
|
||||
</details>
|
||||
<textarea
|
||||
id="offre"
|
||||
v-model="form.offre"
|
||||
rows="3"
|
||||
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
|
||||
required
|
||||
minlength="5"
|
||||
maxlength="300"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
|
||||
{{ form.offre.length }}/300
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hashtags -->
|
||||
<div class="field-group">
|
||||
<label for="hashtags">
|
||||
Mots-clés
|
||||
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
|
||||
</label>
|
||||
<input
|
||||
id="hashtags"
|
||||
v-model="form.hashtagsRaw"
|
||||
type="text"
|
||||
placeholder="Ex : business, écriture, écologie"
|
||||
maxlength="120"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Erreur serveur -->
|
||||
<div v-if="error" class="server-error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Bouton -->
|
||||
<button type="submit" class="submit-btn" :disabled="loading">
|
||||
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
|
||||
</button>
|
||||
|
||||
<NuxtLink to="/codev/carto" class="skip-link">
|
||||
Voir la carte sans créer de fiche →
|
||||
</NuxtLink>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
||||
const isEdit = computed(() => editId.value !== null)
|
||||
|
||||
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
||||
|
||||
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
try {
|
||||
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
|
||||
form.value.nom = fiche.nom
|
||||
form.value.besoin = fiche.besoin
|
||||
form.value.offre = fiche.offre
|
||||
form.value.hashtagsRaw = fiche.hashtags.join(', ')
|
||||
} catch {
|
||||
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
|
||||
}
|
||||
})
|
||||
|
||||
function toggleTip(field: 'besoin' | 'offre') {
|
||||
activeTip.value = activeTip.value === field ? null : field
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const hashtags = form.value.hashtagsRaw
|
||||
.split(',')
|
||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
const payload = {
|
||||
nom: form.value.nom,
|
||||
besoin: form.value.besoin,
|
||||
offre: form.value.offre,
|
||||
hashtags,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
||||
} else {
|
||||
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
||||
}
|
||||
await navigateTo('/codev/carto')
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ── */
|
||||
|
||||
.fiche-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.fiche-inner {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* ── En-tête ── */
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
text-decoration: none;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--nav-primary-solid, #1B4436);
|
||||
}
|
||||
|
||||
.fiche-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.fiche-lead {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Formulaire ── */
|
||||
|
||||
.fiche-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Champ ── */
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-hint {
|
||||
font-weight: 400;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.field-group input[type="text"],
|
||||
.field-group input[type="password"],
|
||||
.field-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid var(--border-color, #d0d4dc);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
background: var(--nav-surface, #ffffff);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-group input:focus,
|
||||
.field-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--nav-primary-solid, #1B4436);
|
||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
||||
}
|
||||
|
||||
.field-group input:disabled,
|
||||
.field-group textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.char-warn {
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
/* ── Tooltip ── */
|
||||
|
||||
.tooltip-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--nav-surface, #ffffff);
|
||||
border: 1px solid var(--border-color, #d0d4dc);
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.tooltip-trigger:hover {
|
||||
border-color: var(--nav-primary-solid, #1B4436);
|
||||
color: var(--nav-primary-solid, #1B4436);
|
||||
}
|
||||
|
||||
.tooltip-block {
|
||||
background: var(--nav-surface, #ffffff);
|
||||
border: 1px solid var(--border-color, #d0d4dc);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tooltip-block p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Erreur serveur ── */
|
||||
|
||||
.server-error {
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: #fdf0ee;
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* ── Bouton ── */
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--nav-primary-solid, #1B4436);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 0.825rem;
|
||||
color: var(--nav-text-muted, #9ca3af);
|
||||
text-decoration: none;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.fiche-page {
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div class="lock-page">
|
||||
<div class="lock-inner">
|
||||
|
||||
<div class="lock-header">
|
||||
<h1>Co-développement</h1>
|
||||
<p class="lock-subtitle">Entraide entre pairs</p>
|
||||
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
||||
</div>
|
||||
|
||||
<form class="lock-form" @submit.prevent="submit" novalidate>
|
||||
|
||||
<div class="field-group">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="lock-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="lock-error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="lock-btn" :disabled="loading || !password">
|
||||
{{ loading ? 'Vérification...' : 'Entrer' }}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="lock-footer">
|
||||
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple →</NuxtLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
useHead({ title: 'Co-développement — Entraide entre pairs' })
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch('/api/codev/auth', {
|
||||
method: 'POST',
|
||||
body: { password: password.value },
|
||||
})
|
||||
await navigateTo('/codev/fiche')
|
||||
} catch (e: any) {
|
||||
error.value = e?.statusMessage || 'Mauvais mot de passe'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ── */
|
||||
|
||||
.lock-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.lock-inner {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* ── En-tête ── */
|
||||
|
||||
.lock-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lock-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.lock-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.lock-intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Formulaire ── */
|
||||
|
||||
.lock-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lock-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--border-color, #d0d4dc);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
color: var(--nav-text, #1a1a2e);
|
||||
background: var(--nav-surface, #ffffff);
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lock-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nav-primary-solid, #1B4436);
|
||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
||||
}
|
||||
|
||||
.lock-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Erreur ── */
|
||||
|
||||
.lock-error {
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #fdf0ee;
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #c0392b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Bouton ── */
|
||||
|
||||
.lock-btn {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--nav-primary-solid, #1B4436);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.lock-btn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.lock-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Pied de page ── */
|
||||
|
||||
.lock-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.demo-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.demo-link:hover {
|
||||
color: var(--nav-primary-solid, #1B4436);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.lock-page {
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
align-items: flex-start;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<div class="qr-page">
|
||||
<div class="qr-card">
|
||||
<h1>Co-développement</h1>
|
||||
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
|
||||
|
||||
<img
|
||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||
alt="QR code aep.trans-former.fr/codev"
|
||||
class="qr-img"
|
||||
width="280"
|
||||
height="280"
|
||||
/>
|
||||
|
||||
<p class="qr-url">{{ APP_URL }}</p>
|
||||
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
|
||||
|
||||
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||
download="codev-qr.png"
|
||||
class="qr-download"
|
||||
target="_blank"
|
||||
>
|
||||
Télécharger le QR code
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const APP_URL = 'https://aep.trans-former.fr/codev'
|
||||
useHead({ title: 'QR Code — Co-développement' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg, #fafafa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.qr-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||
text-align: center;
|
||||
}
|
||||
.qr-card h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}
|
||||
.qr-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
.qr-img {
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
.qr-url {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
.qr-password {
|
||||
font-size: 0.95rem;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
.qr-download {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #1B4436;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.qr-download:hover { opacity: 0.88; }
|
||||
</style>
|
||||
221
pages/index.vue
221
pages/index.vue
@@ -37,31 +37,13 @@
|
||||
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
>
|
||||
Mode dev — données seed
|
||||
Mode dev - données seed
|
||||
</div>
|
||||
|
||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
|
||||
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<!-- Barre onglets desktop -->
|
||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'metropole'"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'outremer'"
|
||||
>Outre-mer</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Métropole desktop -->
|
||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Carte Métropole — pleine largeur -->
|
||||
<div class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="relative flex-1" style="min-height: 200px;">
|
||||
<ClientOnly>
|
||||
<NavMap
|
||||
@@ -71,28 +53,38 @@
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer desktop -->
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||
<div
|
||||
class="shrink-0"
|
||||
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="flex items-center justify-center h-full text-sm"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,48 +176,9 @@
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<!-- Filtres ÉCHELLE — chips style FONCTION -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="opt in ECHELLES"
|
||||
:key="opt"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="echelle.includes(opt)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggleEchelle(opt)"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="fonctions.includes(fn)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggleFonction(fn)"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="resetFilters"
|
||||
class="mt-2 text-xs"
|
||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||
>✕ Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Compteur + Liste fiches -->
|
||||
<!-- Liste fiches -->
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||
@@ -234,10 +187,7 @@
|
||||
Chargement des fiches…
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||
Effacer les filtres
|
||||
</button>
|
||||
<p class="text-sm" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
@@ -249,25 +199,7 @@
|
||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||
@click="onSelectOrgMobile(org.Id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
||||
<span
|
||||
v-if="org.echelle"
|
||||
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ org.echelle }}</span>
|
||||
</div>
|
||||
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="fn in fonctionsList(org)"
|
||||
:key="fn"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
||||
{{ org.localisation_ville }}
|
||||
</div>
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,26 +244,6 @@
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
|
||||
<button
|
||||
class="mission-info-btn"
|
||||
type="button"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos de cette carte d'entraide"
|
||||
title="À propos de cette carte"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -358,23 +270,11 @@ const territoireMode = ref<string>(
|
||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||
)
|
||||
|
||||
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const selectedId = ref<number | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_mission_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const prevSelectedId = ref<number | null>(null)
|
||||
|
||||
@@ -382,28 +282,20 @@ function onHighlightOrgs(ids: (number | string)[]) {
|
||||
if (!ids.length) return
|
||||
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||
if (isNaN(firstId)) return
|
||||
|
||||
// Sauvegarde la sélection courante
|
||||
prevSelectedId.value = selectedId.value
|
||||
selectedId.value = firstId
|
||||
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
// Restaure la sélection précédente (ou null)
|
||||
selectedId.value = prevSelectedId.value
|
||||
prevSelectedId.value = null
|
||||
highlightTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||
|
||||
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
||||
const navMapRef = ref<any>(null)
|
||||
const navMapMobileRef = ref<any>(null)
|
||||
|
||||
// Sync URL <-> état filtres
|
||||
function syncUrl() {
|
||||
const q: Record<string, string> = {}
|
||||
if (search.value) q.q = search.value
|
||||
@@ -414,7 +306,6 @@ function syncUrl() {
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||
function storeFiltersForBack() {
|
||||
if (typeof window === 'undefined') return
|
||||
const q: Record<string, string> = {}
|
||||
@@ -435,14 +326,12 @@ function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); store
|
||||
|
||||
function onSelectOrg(id: number) {
|
||||
selectedId.value = selectedId.value === id ? null : id
|
||||
// Desktop : ouvrir le modal fiche
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Tap card mobile → ouvre la fiche détaillée
|
||||
function onSelectOrgMobile(id: number) {
|
||||
selectedId.value = id
|
||||
storeFiltersForBack()
|
||||
@@ -465,7 +354,6 @@ function resetFilters() {
|
||||
router.replace({ query: undefined })
|
||||
}
|
||||
|
||||
// Tagging compact mobile — toggle direct
|
||||
function toggleEchelle(opt: string) {
|
||||
if (echelle.value.includes(opt)) {
|
||||
onEchelle(echelle.value.filter(v => v !== opt))
|
||||
@@ -482,7 +370,6 @@ function toggleFonction(fn: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync recherche depuis app.vue top nav (via URL ?q=)
|
||||
watch(() => route.query.q, (v) => {
|
||||
search.value = (v as string) ?? ''
|
||||
})
|
||||
@@ -493,7 +380,6 @@ const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>
|
||||
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
||||
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
||||
|
||||
// Fiche aléatoire — réagit au ?random=1
|
||||
watch(() => route.query.random, (v) => {
|
||||
if (v === '1' && orgs.value.length > 0) {
|
||||
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
|
||||
@@ -504,27 +390,20 @@ watch(() => route.query.random, (v) => {
|
||||
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||
const filtered = computed<Org[]>(() => {
|
||||
let result = orgs.value
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.nom?.toLowerCase().includes(q) ||
|
||||
o.localisation_ville?.toLowerCase().includes(q)
|
||||
(o) => o.nom?.toLowerCase().includes(q) || o.localisation_ville?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (echelle.value.length) {
|
||||
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
|
||||
}
|
||||
|
||||
if (fonctions.value.length) {
|
||||
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
|
||||
result = result.filter((o) => {
|
||||
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||
return fonctions.value.some((fn) => orgFns.includes(fn))
|
||||
})
|
||||
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
|
||||
const n = fonctions.value.length
|
||||
const score = (o: Org) =>
|
||||
fonctions.value.reduce((s, fn, i) => {
|
||||
@@ -533,11 +412,9 @@ const filtered = computed<Org[]>(() => {
|
||||
}, 0)
|
||||
result = [...result].sort((a, b) => score(b) - score(a))
|
||||
}
|
||||
|
||||
if (territoire.value) {
|
||||
result = result.filter((o) => o.territoire === territoire.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -566,7 +443,6 @@ const outremerCountByDom = computed<Record<string, number>>(() => {
|
||||
return counts
|
||||
})
|
||||
|
||||
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
|
||||
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
|
||||
@@ -597,36 +473,9 @@ const territoireCount = computed<Record<string, number>>(() => {
|
||||
return counts
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function fonctionsList(org: Org): string[] {
|
||||
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
|
||||
}
|
||||
|
||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||
useHead({ title: "AEP - Cartographie de l'écologie politique architecturale" })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mission-info-btn {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
z-index: 1000;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mission-info-btn { bottom: 16px; left: 340px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<div class="manifeste-page">
|
||||
<div class="manifeste-inner">
|
||||
|
||||
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||
|
||||
<h1 class="manifeste-title">Manifeste — Architecture d'Écologie Politique</h1>
|
||||
|
||||
<p class="lede">
|
||||
<em>Un quart des architectes vivent sous le seuil de pauvreté. La moitié de nos heures, non facturées. Nos cotisations, parmi les plus lourdes des professions réglementées. Et le secteur du bâtiment, à lui seul, pèse 34 % des émissions mondiales de gaz à effet de serre.</em>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Quelque chose s'est rompu — pas dans nos vies, dans les cadres qui les contiennent.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Notre profession ne traverse pas une simple crise. Elle reflète l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous voyons.</h2>
|
||||
|
||||
<p>
|
||||
À l'échelle du métier, une profession structurellement sous l'eau, qui absorbe les tensions d'un système extractiviste — et porte la responsabilité quand d'autres captent la valeur.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
À l'échelle des corps, une culture qui rend l'exploitation désirable : métier-passion, modèle starchitecte, isolement libéral, moteur critique délégitimant. Nous tenons. Nous payons.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
À l'échelle du monde, l'effondrement écologique et social qui avance, pendant que notre voix s'efface du débat public. Notre silence le sert.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous refusons.</h2>
|
||||
|
||||
<p class="refus">
|
||||
Nous ne signerons plus pour des projets qui détruisent.<br />
|
||||
Nous n'isolerons plus celles et ceux qui doutent.<br />
|
||||
Nous ne porterons plus seul·es ce qui doit se penser, se faire — et se soigner — ensemble.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="pivot">
|
||||
<strong>Et pourtant, quelque chose tient.</strong>
|
||||
</p>
|
||||
|
||||
<p class="pivot-suite">
|
||||
Pas l'espoir naïf, ni la promesse héroïque. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus économiser sa vie.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Ce que nous tentons.</h2>
|
||||
|
||||
<p>
|
||||
<em>Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un·es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Construire.</em> L'infrastructure collective qui nous a manqué. Cartes d'entraide, communs documentés, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Écologie Politique</strong> : un commun vivant, ouvert, biorégional, ancré.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Pratiquer une médecine du corps social.</em> Diagnostiquer les infrastructures qui défaillent — l'éducation, la justice, la sécurité, l'énergie, la santé, le logement, l'agriculture. Proposer des reconfigurations situées, territoire par territoire. Reprendre le pouvoir par la base. Écrire, lentement, un nouveau contrat social.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<em>Commencer par les marges.</em> Là où le corps social souffre le plus, là où il est le plus prêt à changer. Ne pas décider à la place — faire émerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidité sans le mépris, l'engagement sans la dureté.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Architectes, allié·es, habitant·es.</h2>
|
||||
|
||||
<p>
|
||||
Nous avons un travail à faire ensemble. Lentement, patiemment, par accumulation de petits gestes situés. Pas pour fuir — pour bifurquer.
|
||||
</p>
|
||||
|
||||
<p class="chute">
|
||||
<em>Nos métiers sont des médecines. Reprenons-en le pouls — à mains nues, ensemble.</em>
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="cta-wrap">
|
||||
<a
|
||||
href="https://www.trans-former.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn-blog"
|
||||
>
|
||||
En lire plus — blog AEP →
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Manifeste — AEP',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Manifeste d\'Architecture d\'Écologie Politique — un commun vivant pour bifurquer ensemble.' },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manifeste-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg);
|
||||
padding: 1.5rem 1rem 5rem;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manifeste-inner {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-primary-solid);
|
||||
opacity: 0.7;
|
||||
text-decoration: none;
|
||||
margin-bottom: 2rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover { opacity: 1; }
|
||||
|
||||
.manifeste-title {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.25rem;
|
||||
border-left: 3px solid var(--nav-primary-solid);
|
||||
padding-left: 1rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.975rem;
|
||||
line-height: 1.75;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 2rem 0 1rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.refus {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pivot {
|
||||
font-size: 1.15rem;
|
||||
text-align: center;
|
||||
margin: 2rem 0 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pivot strong {
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.pivot-suite {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chute {
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.cta-wrap {
|
||||
text-align: center;
|
||||
margin: 2rem 0 0;
|
||||
}
|
||||
|
||||
.btn-blog {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-blog:hover { opacity: 0.85; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.manifeste-page { padding: 1rem 0.85rem 4rem; }
|
||||
.manifeste-title { font-size: 1.4rem; }
|
||||
.lede { font-size: 0.95rem; padding-left: 0.85rem; }
|
||||
p { font-size: 0.95rem; }
|
||||
.pivot { font-size: 1.05rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div class="media-page" style="background: var(--nav-bg);">
|
||||
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'visuel' }]"
|
||||
@click="tab = 'visuel'"
|
||||
>
|
||||
📚 bibliothèque des pensées écologiques
|
||||
</button>
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'projets' }]"
|
||||
@click="tab = 'projets'"
|
||||
>
|
||||
📐 Projets
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<MediaTabVisuel v-if="tab === 'visuel'" />
|
||||
<MediaTabProjets v-else-if="tab === 'projets'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tab = ref<'visuel' | 'projets'>(
|
||||
(['visuel', 'projets'].includes(route.query.tab as string)
|
||||
? route.query.tab as 'visuel' | 'projets'
|
||||
: 'visuel')
|
||||
)
|
||||
|
||||
watch(tab, (newTab) => {
|
||||
router.replace({ query: { ...route.query, tab: newTab } })
|
||||
})
|
||||
|
||||
useHead({ title: 'AEP - Media' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
|
||||
.subtab-btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--nav-text-muted);
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.subtab-btn:hover { color: var(--nav-text); }
|
||||
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
|
||||
</style>
|
||||
533
pages/outils.vue
533
pages/outils.vue
@@ -1,533 +0,0 @@
|
||||
<template>
|
||||
<div class="outils-page">
|
||||
|
||||
<!-- ══════════════════════ EN-TÊTE PAGE ══════════════════════ -->
|
||||
<header class="outils-header">
|
||||
<div class="outils-header__inner">
|
||||
<div class="outils-header__icon-wrap" aria-hidden="true">
|
||||
<img src="/icons/outils-wrench.svg" alt="" class="outils-header__icon" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="outils-header__title">Outils</h1>
|
||||
<p class="outils-header__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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="outils-main">
|
||||
|
||||
<!-- ══════════════ SECTION 1 — Simulateurs métier ══════════════ -->
|
||||
<section class="outils-section outils-section--simulateurs" aria-labelledby="sec-simulateurs">
|
||||
<h2 id="sec-simulateurs" class="outils-section__title">
|
||||
<span aria-hidden="true">🧮</span> Simulateurs métier
|
||||
</h2>
|
||||
<p class="outils-section__subtitle">Créés par AEP — outils de calcul situés.</p>
|
||||
|
||||
<div class="simu-grid">
|
||||
<SimulateurFeature
|
||||
v-for="s in outils?.simulateurs"
|
||||
:key="s.id"
|
||||
:icon="s.icon"
|
||||
:titre="s.titre"
|
||||
:url="s.url"
|
||||
:description="s.description"
|
||||
:cta="s.cta"
|
||||
:tag="s.tag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inspirations -->
|
||||
<div v-if="outils?.simulateurs_inspirations?.length" class="simu-inspirations">
|
||||
<p class="simu-inspirations__label">Inspiration externe</p>
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="s in outils.simulateurs_inspirations"
|
||||
:key="s.id"
|
||||
:icon="s.icon"
|
||||
:titre="s.titre"
|
||||
:url="s.url"
|
||||
:description="s.description"
|
||||
:tag="s.tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 2 — Open source recommandés ══════════════ -->
|
||||
<section class="outils-section outils-section--opensource" aria-labelledby="sec-opensource">
|
||||
<h2 id="sec-opensource" class="outils-section__title">
|
||||
<span aria-hidden="true">🔧</span> Outils tech open source
|
||||
</h2>
|
||||
<p class="outils-section__subtitle">Quelques recommandations directes. Le cœur de l'onglet, c'est la section FMHY plus bas.</p>
|
||||
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="outil in outils?.opensource"
|
||||
:key="outil.id"
|
||||
:icon="outil.icon"
|
||||
:titre="outil.titre"
|
||||
:url="outil.url"
|
||||
:description="outil.description"
|
||||
:tag="outil.tag"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 3 — Bifurcation ══════════════ -->
|
||||
<section class="outils-section" aria-labelledby="sec-bifurcation">
|
||||
<h2 id="sec-bifurcation" class="outils-section__title">
|
||||
<span aria-hidden="true">🌿</span> Bifurcation post-études d'archi
|
||||
</h2>
|
||||
<p class="outils-section__desc">
|
||||
{{ outils?.bifurcation?.intro }}
|
||||
</p>
|
||||
|
||||
<!-- 3.1 Vidéos OFQA -->
|
||||
<div class="bifurcation-block">
|
||||
<h3 class="bifurcation-block__title">Série vidéo OFQA / ENSA-PB</h3>
|
||||
<ul class="ofqa-list">
|
||||
<li
|
||||
v-for="ep in outils?.bifurcation?.videos_ofqa"
|
||||
: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>
|
||||
</div>
|
||||
|
||||
<!-- 3.2 Coalition -->
|
||||
<div v-if="outils?.bifurcation?.coalition_ensa_pb" class="bifurcation-block bifurcation-block--coalition">
|
||||
<h3 class="bifurcation-block__title">{{ outils.bifurcation.coalition_ensa_pb.titre }}</h3>
|
||||
<p class="bifurcation-block__desc">{{ outils.bifurcation.coalition_ensa_pb.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 3.3 Ressources externes -->
|
||||
<div v-if="outils?.bifurcation?.ressources_externes?.length" class="bifurcation-block">
|
||||
<h3 class="bifurcation-block__title">Ressources externes</h3>
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="r in outils.bifurcation.ressources_externes"
|
||||
:key="r.id"
|
||||
:icon="r.icon"
|
||||
:titre="r.titre"
|
||||
:url="r.url"
|
||||
:description="r.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
|
||||
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
|
||||
<h2 id="sec-fmhy" class="outils-section__title">
|
||||
<span aria-hidden="true">🌳</span> Bibliothèque de ressources libres
|
||||
</h2>
|
||||
<p class="outils-section__desc">
|
||||
Le vrai trésor de l'onglet Outils. FMHY (Free Media Heck Yeah) est la plus grosse
|
||||
base communautaire d'outils, services et ressources libres/gratuits du web. J'en ai
|
||||
curé ~50 entrées pertinentes pour un architecte : IA, lecture, dev, vie privée,
|
||||
formation, médias. Clique sur les branches pour explorer.
|
||||
</p>
|
||||
|
||||
<div class="fmhy-tree-wrap">
|
||||
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
|
||||
<span>Chargement…</span>
|
||||
</div>
|
||||
<div v-else-if="fmhyError" class="fmhy-error">
|
||||
Impossible de charger les ressources. <a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">Explorer fmhy.net →</a>
|
||||
</div>
|
||||
<TreeASCII v-else-if="fmhyData" :tree="fmhyData" :depth="0" />
|
||||
</div>
|
||||
|
||||
<div class="fmhy-footer">
|
||||
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer" class="fmhy-footer__link">
|
||||
Explorer tout l'arbre → fmhy.net
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 5 — Placeholder login ══════════════ -->
|
||||
<section class="outils-section outils-section--placeholder" aria-labelledby="sec-logiciels">
|
||||
<div class="placeholder-block">
|
||||
<span class="placeholder-block__badge">Bientôt — nécessite un compte</span>
|
||||
<h2 id="sec-logiciels" class="placeholder-block__title">{{ outils?.section_5_placeholder?.titre }}</h2>
|
||||
<p class="placeholder-block__desc">{{ outils?.section_5_placeholder?.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ FOOTER CONTRIBUTION ══════════════ -->
|
||||
<footer class="outils-footer">
|
||||
<p class="outils-footer__text">
|
||||
{{ outils?.footer_contribution }}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Chargement des données
|
||||
const { data: outils } = await useFetch('/data/outils.json')
|
||||
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json')
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Outils — AEP',
|
||||
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout global ──────────────────────────────────────────── */
|
||||
.outils-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
.outils-header {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.outils-header__inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.outils-header__icon-wrap {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 10px;
|
||||
background: var(--nav-bg-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.outils-header__icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.outils-header__title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.outils-header__intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
/* ── Sections ───────────────────────────────────────────────── */
|
||||
.outils-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.outils-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.outils-section__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--nav-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1.5px solid var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.outils-section__subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: -0.5rem 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outils-section__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
/* ── Simulateurs ────────────────────────────────────────────── */
|
||||
.outils-section--simulateurs {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.simu-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.simu-inspirations {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.simu-inspirations__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Cards grid ─────────────────────────────────────────────── */
|
||||
.outil-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Section FMHY ───────────────────────────────────────────── */
|
||||
.outils-section--fmhy {
|
||||
background: var(--nav-bg-alt);
|
||||
border-radius: 14px;
|
||||
padding: 1.75rem;
|
||||
gap: 1.25rem;
|
||||
margin: 0 -0.25rem;
|
||||
}
|
||||
|
||||
.fmhy-tree-wrap {
|
||||
background: var(--nav-surface);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fmhy-loading,
|
||||
.fmhy-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.fmhy-error a {
|
||||
color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.fmhy-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fmhy-footer__link {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fmhy-footer__link:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Bifurcation ────────────────────────────────────────────── */
|
||||
.bifurcation-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bifurcation-block__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bifurcation-block__desc {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.bifurcation-block--coalition {
|
||||
background: var(--nav-bg-alt);
|
||||
border-radius: 8px;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
/* ── Liste OFQA ─────────────────────────────────────────────── */
|
||||
.ofqa-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ofqa-list__item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ofqa-list__link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.84rem;
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ofqa-list__link:not(.ofqa-list__link--disabled):hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ofqa-list__link--disabled {
|
||||
color: var(--nav-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ofqa-list__ep {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text-muted);
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.ofqa-list__titre {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ofqa-list__personnes {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ofqa-list__note {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Section 5 placeholder ──────────────────────────────────── */
|
||||
.outils-section--placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.placeholder-block {
|
||||
border: 1.5px dashed var(--nav-bg-alt);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.placeholder-block__badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-block__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.placeholder-block__desc {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────── */
|
||||
.outils-footer {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outils-footer__text {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.outils-page {
|
||||
padding: 1.25rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.outils-header__inner {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.outils-header__title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.outil-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.outils-section--fmhy {
|
||||
padding: 1.25rem 1rem;
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
|
||||
.fmhy-tree-wrap {
|
||||
padding: 0.875rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,38 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
||||
</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||
Bientôt disponible
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Retour à l'écosystème
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
navigateTo('/media', { redirectCode: 301 })
|
||||
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
||||
</script>
|
||||
|
||||
@@ -6,50 +6,10 @@
|
||||
<div class="taff-header-inner">
|
||||
<h1 class="taff-title">Trouver du taf en archi</h1>
|
||||
<p class="taff-subtitle">
|
||||
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
|
||||
pour les <strong>architectes indépendants</strong> — 70 % de la profession et sa part la plus précaire économiquement.
|
||||
Annuaire critique des plateformes de mise en relation archi ↔ particulier.
|
||||
Évaluées sur 5 axes éthiques — rémunération, transparence, pratiques pro, écologie, qualité du matching.
|
||||
Cible : archi freelance indépendant en France.
|
||||
</p>
|
||||
|
||||
<!-- Pédagogie : ce qu'on évalue -->
|
||||
<details class="taff-pedago" open>
|
||||
<summary class="taff-pedago-summary">
|
||||
<span>Comment on lit cette carte ?</span>
|
||||
<svg class="taff-pedago-chevron" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</summary>
|
||||
|
||||
<div class="taff-pedago-body">
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Deux onglets, deux mondes</h3>
|
||||
<ul>
|
||||
<li><strong>Pour archi indépendants</strong> (B2C) : plateformes privées qui te mettent en relation avec des particuliers (Houzz, Architoo, etc.). Modèle économique = elles te facturent l'accès aux leads.</li>
|
||||
<li><strong>Appels d'offres publics</strong> : marchés publics (BOAMP, JOUE, plateformes territoriales). Procédure réglementée, gros volumes, accès aux MOE publics.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Trois étiquettes, trois niveaux de confiance</h3>
|
||||
<ul>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(90,122,74,0.12); color: #3d5534;">✅ Recommandé</span> validé par AEP — pratiques alignées avec une éthique architecturale (rémunération correcte, transparence, écologie, qualité du matching)</li>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(196,164,114,0.15); color: #7a5f2a;">⚠️ Sous réserve</span> utilisable mais avec vigilance — un ou plusieurs points faibles à connaître avant de t'inscrire</li>
|
||||
<li><span class="taff-pedago-tag" style="background: rgba(168,93,62,0.12); color: #7a3322;">❌ À éviter</span> pratiques contraires à l'intérêt des architectes (commissions abusives, dumping, appâtage, etc.)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="taff-pedago-block">
|
||||
<h3>Cinq axes d'évaluation</h3>
|
||||
<p class="taff-pedago-axes">
|
||||
<strong>Rémunération</strong> (commissions, leads payants) ·
|
||||
<strong>Transparence</strong> (CGV, modèle économique) ·
|
||||
<strong>Pratiques pro</strong> (respect du Code de déontologie) ·
|
||||
<strong>Écologie</strong> (incitation à la réno, matériaux) ·
|
||||
<strong>Qualité du matching</strong> (filtres, pertinence des leads).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="taff-stats">
|
||||
<span class="taff-stat" style="color: #3d5534;">
|
||||
<span class="taff-stat-dot" style="background: #5a7a4a;"></span>
|
||||
@@ -68,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Filtres ─────────────────────────────────────────────────── -->
|
||||
<div class="taff-filters-bar" :class="{ 'taff-filters-bar--collapsed': !filtersExpanded }">
|
||||
<div class="taff-filters-bar">
|
||||
<div class="taff-filters-inner">
|
||||
|
||||
<!-- Onglets B2C / AO publics -->
|
||||
@@ -79,7 +39,7 @@
|
||||
:class="{ 'taff-tab--active': activeTab === 'b2c' }"
|
||||
@click="activeTab = 'b2c'; resetFilters()"
|
||||
>
|
||||
Pour archi indépendants
|
||||
Plateformes B2C
|
||||
<span class="taff-tab-count">{{ b2cCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -91,33 +51,8 @@
|
||||
Appels d'offres publics
|
||||
<span class="taff-tab-count">{{ aoCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Toggle filtres mobile -->
|
||||
<button
|
||||
type="button"
|
||||
class="taff-filters-toggle"
|
||||
:aria-expanded="filtersExpanded"
|
||||
@click="filtersExpanded = !filtersExpanded"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="10" y1="18" x2="14" y2="18"/>
|
||||
</svg>
|
||||
<span>Filtres</span>
|
||||
<span v-if="activeFilterCount" class="taff-filters-toggle-badge">{{ activeFilterCount }}</span>
|
||||
<svg
|
||||
width="11" height="11" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
:style="{ transform: filtersExpanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="taff-filters-collapsible">
|
||||
|
||||
<!-- Filtres tag global -->
|
||||
<div class="taff-filter-group">
|
||||
<button
|
||||
@@ -181,8 +116,6 @@
|
||||
@click="resetFilters"
|
||||
>Effacer</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /.taff-filters-collapsible -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -202,105 +135,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chatbot FAB ───────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<!-- Bouton flottant -->
|
||||
<button
|
||||
v-if="!chatOpen"
|
||||
class="taff-fab"
|
||||
@click="chatOpen = true"
|
||||
aria-label="Ouvrir le guide IA — Quel plateforme me convient ?"
|
||||
title="Guide IA — Je t'aide à choisir"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span class="taff-fab-label">Guide IA</span>
|
||||
</button>
|
||||
|
||||
<!-- Panel chatbot -->
|
||||
<Transition name="taff-chat">
|
||||
<div v-if="chatOpen" class="taff-chat-panel" role="dialog" aria-modal="true" aria-label="Guide IA — Choisir sa plateforme">
|
||||
|
||||
<!-- Header panel -->
|
||||
<div class="taff-chat-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="taff-chat-avatar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold" style="color: var(--nav-text);">Guide AEP</div>
|
||||
<div class="text-xs" style="color: var(--nav-text-muted);">Je t'aide à choisir ta plateforme</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="chatOpen = false" class="taff-chat-close" aria-label="Fermer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="taff-chat-messages" ref="chatMessagesEl">
|
||||
<!-- Message d'accueil -->
|
||||
<div class="taff-msg taff-msg--bot">
|
||||
<p>Dis-moi ta situation : ton secteur, ta zone, ce qui te bloque. Je t'oriente parmi les {{ allPlateformes.length }} plateformes de l'annuaire.</p>
|
||||
<p class="text-xs mt-1.5 opacity-60">Ex : "Je suis en rénovation à Lyon, je cherche des leads sans commission."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages de la conversation -->
|
||||
<template v-for="(msg, i) in chatMessages" :key="i">
|
||||
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
|
||||
<div v-if="msg.role === 'bot'" class="md-content" v-html="renderMd(msg.content)" />
|
||||
<span v-else>{{ msg.content }}</span>
|
||||
</div>
|
||||
<!-- Plateformes recommandées -->
|
||||
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
|
||||
<button
|
||||
v-for="r in msg.recommandations"
|
||||
:key="r.id"
|
||||
class="taff-reco-chip"
|
||||
@click="openModalById(r.id); chatOpen = false"
|
||||
>
|
||||
{{ r.nom }}
|
||||
<span class="taff-reco-arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loader -->
|
||||
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
||||
<span class="taff-typing"><span/><span/><span/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="taff-chat-input-wrap">
|
||||
<textarea
|
||||
v-model="chatInput"
|
||||
class="taff-chat-input"
|
||||
placeholder="Décris ta situation..."
|
||||
rows="2"
|
||||
@keydown.enter.exact.prevent="sendChat"
|
||||
/>
|
||||
<button
|
||||
class="taff-chat-send"
|
||||
:disabled="chatLoading || !chatInput.trim()"
|
||||
@click="sendChat"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Note juridique ────────────────────────────────────────── -->
|
||||
<div class="taff-disclaimer">
|
||||
<p>
|
||||
@@ -314,7 +148,7 @@
|
||||
<Transition name="taff-backdrop">
|
||||
<div
|
||||
v-if="modalPlateforme"
|
||||
class="fixed inset-0 z-[10000]"
|
||||
class="fixed inset-0 z-[1500]"
|
||||
style="background: rgba(26,34,56,0.55);"
|
||||
@click="closeModal"
|
||||
aria-hidden="true"
|
||||
@@ -323,8 +157,8 @@
|
||||
<Transition name="taff-modal">
|
||||
<div
|
||||
v-if="modalPlateforme"
|
||||
class="fixed z-[10001] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="width: min(760px, 92vw); top: 72px; max-height: calc(100vh - 88px); background: var(--nav-bg); border-radius: 16px; box-shadow: 0 16px 64px rgba(26,34,56,0.28); overflow: hidden;"
|
||||
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||
style="width: min(760px, 92vw); max-height: 90vh; background: var(--nav-bg); border-radius: 16px; box-shadow: 0 16px 64px rgba(26,34,56,0.28); overflow: hidden;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="modalPlateforme.nom"
|
||||
@@ -472,21 +306,6 @@ const filterTag = ref('')
|
||||
const filterSecteur = ref('')
|
||||
const search = ref('')
|
||||
const hasFilters = computed(() => !!(filterTag.value || filterSecteur.value || search.value))
|
||||
const activeFilterCount = computed(() => {
|
||||
let n = 0
|
||||
if (filterTag.value) n++
|
||||
if (filterSecteur.value) n++
|
||||
if (search.value) n++
|
||||
return n
|
||||
})
|
||||
|
||||
// Filtres ouverts par défaut sur desktop, repliés sur mobile (init côté client)
|
||||
const filtersExpanded = ref(true)
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
filtersExpanded.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filterTag.value = ''
|
||||
@@ -570,52 +389,6 @@ function axeScoreText(score: string) {
|
||||
const modalPlateforme = ref<PlateformeTaff | null>(null)
|
||||
function openModal(p: PlateformeTaff) { modalPlateforme.value = p }
|
||||
function closeModal() { modalPlateforme.value = null }
|
||||
function openModalById(id: string) {
|
||||
const p = allPlateformes.value.find(p => p.id === id)
|
||||
if (p) modalPlateforme.value = p
|
||||
}
|
||||
|
||||
// Chatbot
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'bot'
|
||||
content: string
|
||||
recommandations?: { id: string; nom: string; raison: string }[]
|
||||
}
|
||||
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
const chatOpen = ref(false)
|
||||
const chatInput = ref('')
|
||||
const chatLoading = ref(false)
|
||||
const chatMessages = ref<ChatMessage[]>([])
|
||||
const chatMessagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
async function sendChat() {
|
||||
const q = chatInput.value.trim()
|
||||
if (!q || chatLoading.value) return
|
||||
chatMessages.value.push({ role: 'user', content: q })
|
||||
chatInput.value = ''
|
||||
chatLoading.value = true
|
||||
await nextTick()
|
||||
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||
try {
|
||||
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
|
||||
'/api/chatbot-taff',
|
||||
{ method: 'POST', body: { question: q } }
|
||||
)
|
||||
chatMessages.value.push({
|
||||
role: 'bot',
|
||||
content: res.reponse_texte,
|
||||
recommandations: res.plateformes_recommandees ?? [],
|
||||
})
|
||||
} catch (e: any) {
|
||||
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
await nextTick()
|
||||
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const TAG_CONFIG: Record<string, { emoji: string; label: string; bg: string; text: string }> = {
|
||||
'recommande': { emoji: '✅', label: 'Recommandé AEP', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
|
||||
@@ -647,147 +420,16 @@ const parsedDescription = computed(() => {
|
||||
<style scoped>
|
||||
.taff-page { max-width: 1280px; margin: 0 auto; padding-bottom: 3rem; }
|
||||
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
|
||||
.taff-header-inner { max-width: 680px; margin: 0 auto; }
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); }
|
||||
.taff-header-inner { max-width: 680px; }
|
||||
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
|
||||
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
|
||||
.taff-cible strong { color: var(--nav-text); font-style: normal; }
|
||||
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; justify-content: center; margin-top: 1rem; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 1rem; }
|
||||
.taff-stats { display: flex; gap: 1.25rem; flex-wrap: wrap; }
|
||||
.taff-stat { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; font-weight: 600; }
|
||||
|
||||
/* Pédagogie repliable */
|
||||
.taff-pedago {
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.taff-pedago-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.taff-pedago-summary::-webkit-details-marker { display: none; }
|
||||
.taff-pedago-chevron {
|
||||
color: var(--nav-text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.taff-pedago[open] .taff-pedago-chevron { transform: rotate(180deg); }
|
||||
.taff-pedago-body {
|
||||
padding: 0 1rem 1rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
}
|
||||
.taff-pedago-block { margin-top: 0.875rem; }
|
||||
.taff-pedago-block h3 {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.taff-pedago-block ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.taff-pedago-block li {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
.taff-pedago-block li strong { font-weight: 700; }
|
||||
.taff-pedago-tag {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.taff-pedago-axes {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.65;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
.taff-pedago-axes strong { font-weight: 700; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taff-pedago-body { padding: 0 0.85rem 0.85rem; }
|
||||
.taff-pedago-block li, .taff-pedago-axes { font-size: 0.82rem; }
|
||||
}
|
||||
.taff-stat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.taff-filters-bar { position: sticky; top: 0; z-index: 100; background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); padding: 0.75rem 1.5rem; box-shadow: 0 2px 8px rgba(26,34,56,0.06); }
|
||||
.taff-filters-inner { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
|
||||
.taff-filters-collapsible { display: contents; }
|
||||
|
||||
/* Toggle filtres : visible uniquement sur mobile */
|
||||
.taff-filters-toggle { display: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.taff-filters-bar { padding: 0.5rem 0.875rem; }
|
||||
.taff-filters-inner { gap: 0.4rem; }
|
||||
.taff-tabs { width: 100%; justify-content: space-between; }
|
||||
.taff-tabs .taff-tab { flex: 1; justify-content: center; }
|
||||
.taff-filters-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-left: none;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-filters-toggle-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9999px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.taff-filters-collapsible {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
max-height: 1000px;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.2s ease;
|
||||
margin-top: 0.4rem;
|
||||
opacity: 1;
|
||||
}
|
||||
.taff-filters-bar--collapsed .taff-filters-collapsible {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.taff-tabs { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid var(--nav-bg-alt); flex-shrink: 0; }
|
||||
.taff-tab { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.875rem; font-size: 0.8125rem; font-weight: 500; color: var(--nav-text-muted); background: var(--nav-bg); border: none; cursor: pointer; transition: background 0.15s; }
|
||||
@@ -806,7 +448,7 @@ const parsedDescription = computed(() => {
|
||||
.taff-search-clear { color: var(--nav-text-muted); background: none; border: none; cursor: pointer; padding: 0; display: flex; }
|
||||
|
||||
.taff-grid-wrap { padding: 1.5rem; }
|
||||
.taff-grid { display: flex; flex-direction: column; gap: 0.75rem; max-width: 720px; margin: 0 auto; }
|
||||
.taff-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.taff-empty { text-align: center; padding: 3rem; }
|
||||
.taff-reset-btn { margin-top: 0.75rem; padding: 0.5rem 1.25rem; border-radius: 8px; background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; border: none; cursor: pointer; }
|
||||
.taff-reset-btn:hover { opacity: 0.7; }
|
||||
@@ -815,202 +457,16 @@ const parsedDescription = computed(() => {
|
||||
|
||||
/* Modal body helpers */
|
||||
.modal-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nav-text-muted); margin-bottom: 0.75rem; }
|
||||
.modal-axes { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.modal-axe { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.875rem; border-radius: 8px; flex: 1 1 130px; min-width: 130px; }
|
||||
.modal-axes { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.5rem; }
|
||||
.modal-axe { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px; }
|
||||
.modal-meta-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
|
||||
.modal-meta-item { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.6rem 0.875rem; border-radius: 8px; background: var(--nav-bg-alt); }
|
||||
.modal-meta-key { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; color: var(--nav-text-muted); }
|
||||
.modal-meta-val { font-size: 0.875rem; font-weight: 500; color: var(--nav-text); }
|
||||
|
||||
/* Transitions modal */
|
||||
/* Transitions */
|
||||
.taff-backdrop-enter-active, .taff-backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.taff-backdrop-enter-from, .taff-backdrop-leave-to { opacity: 0; }
|
||||
.taff-modal-enter-active, .taff-modal-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translateX(-50%) translateY(-12px); }
|
||||
|
||||
/* ── Chatbot FAB ──────────────────────────────────────────────────── */
|
||||
.taff-fab {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(26,34,56,0.3);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.taff-fab:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(26,34,56,0.35); }
|
||||
.taff-fab-label { white-space: nowrap; }
|
||||
|
||||
/* Panel chatbot */
|
||||
.taff-chat-panel {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
width: min(380px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 4rem);
|
||||
background: var(--nav-surface);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 40px rgba(26,34,56,0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.taff-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--nav-primary-solid);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-close {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 8px;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.taff-chat-close:hover { opacity: 0.7; }
|
||||
|
||||
.taff-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.taff-msg {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
max-width: 92%;
|
||||
}
|
||||
.taff-msg--bot {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text);
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.taff-msg--user {
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.taff-chat-recos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
}
|
||||
.taff-reco-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.taff-reco-chip:hover { background: var(--nav-bg-alt); }
|
||||
.taff-reco-arrow { opacity: 0.5; }
|
||||
|
||||
/* Typing indicator */
|
||||
.taff-typing { display: inline-flex; gap: 4px; align-items: center; }
|
||||
.taff-typing span {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--nav-text-muted);
|
||||
animation: taff-bounce 1.2s infinite;
|
||||
}
|
||||
.taff-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.taff-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes taff-bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
40% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
|
||||
.taff-chat-input-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.taff-chat-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--nav-bg);
|
||||
color: var(--nav-text);
|
||||
font-family: var(--nav-font);
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.taff-chat-input::placeholder { color: var(--nav-text-muted); }
|
||||
.taff-chat-input:focus { border-color: var(--nav-primary); }
|
||||
|
||||
.taff-chat-send {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.taff-chat-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.taff-chat-send:not(:disabled):hover { opacity: 0.85; }
|
||||
|
||||
.taff-chat-footer-note {
|
||||
text-align: center;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 0.375rem;
|
||||
background: var(--nav-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Transition panel chatbot */
|
||||
.taff-chat-enter-active, .taff-chat-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.taff-chat-enter-from, .taff-chat-leave-to { opacity: 0; transform: translateY(12px) scale(0.97); }
|
||||
.taff-modal-enter-from, .taff-modal-leave-to { opacity: 0; transform: translate(-50%, calc(-50% + 12px)); }
|
||||
</style>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"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.",
|
||||
"children": [
|
||||
{
|
||||
"name": "IA & Outils cognitifs",
|
||||
"children": [
|
||||
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
|
||||
{ "name": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
|
||||
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, souverain, gratuit." },
|
||||
{ "name": "Perplexity", "url": "https://www.perplexity.ai/", "desc": "Moteur de recherche IA avec sources citées." },
|
||||
{ "name": "Hugging Face", "url": "https://huggingface.co/", "desc": "Hub de modèles open source. Indispensable." },
|
||||
{ "name": "LM Studio", "url": "https://lmstudio.ai/", "desc": "Faire tourner des LLM localement, sans cloud." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"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." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "Productivité & Texte",
|
||||
"children": [
|
||||
{ "name": "Obsidian", "url": "https://obsidian.md/", "desc": "PKM / prise de notes en Markdown. Gratuit pour usage personnel." },
|
||||
{ "name": "Logseq", "url": "https://logseq.com/", "desc": "PKM open source, graphe de connaissances." },
|
||||
{ "name": "Zotero", "url": "https://www.zotero.org/", "desc": "Gestionnaire de références bibliographiques. Indispensable pour la recherche." },
|
||||
{ "name": "Marktext", "url": "https://github.com/marktext/marktext", "desc": "Éditeur Markdown WYSIWYG, open source." },
|
||||
{ "name": "Typst", "url": "https://typst.app/", "desc": "Alternative moderne à LaTeX pour la mise en page de documents." },
|
||||
{ "name": "Pandoc", "url": "https://pandoc.org/", "desc": "Conversion universelle entre formats de documents (MD, DOCX, PDF, HTML...)." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dev & Infrastructure",
|
||||
"children": [
|
||||
{ "name": "VS Code", "url": "https://code.visualstudio.com/", "desc": "Éditeur de code. La référence, gratuit." },
|
||||
{ "name": "Coolify", "url": "https://coolify.io/", "desc": "Self-hosting simplifié. Alternative à Heroku/Vercel sur son propre VPS." },
|
||||
{ "name": "Hetzner Cloud", "url": "https://www.hetzner.com/cloud/", "desc": "VPS européen, tarifs très bas, data centers Allemagne." },
|
||||
{ "name": "Caddy", "url": "https://caddyserver.com/", "desc": "Serveur web avec HTTPS automatique. Plus simple que Nginx." },
|
||||
{ "name": "n8n", "url": "https://n8n.io/", "desc": "Automatisation open source (comme Zapier mais self-hostable)." },
|
||||
{ "name": "Gitea", "url": "https://gitea.io/", "desc": "Hébergement Git self-hosted. Alternative à GitHub." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vie privée & Sécurité",
|
||||
"children": [
|
||||
{ "name": "Bitwarden", "url": "https://bitwarden.com/", "desc": "Gestionnaire de mots de passe open source. Self-hostable." },
|
||||
{ "name": "ProtonMail", "url": "https://proton.me/mail", "desc": "Email chiffré, hébergement Suisse." },
|
||||
{ "name": "Signal", "url": "https://signal.org/", "desc": "Messagerie chiffrée E2E. La référence." },
|
||||
{ "name": "uBlock Origin", "url": "https://ublockorigin.com/", "desc": "Bloqueur de publicités et trackers, le plus efficace." },
|
||||
{ "name": "Mullvad VPN", "url": "https://mullvad.net/", "desc": "VPN respectueux de la vie privée, sans compte email requis." },
|
||||
{ "name": "Privacy Guides", "url": "https://www.privacyguides.org/", "desc": "Recommandations d'outils respectueux de la vie privée, par thème." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Formation & Apprentissage",
|
||||
"children": [
|
||||
{ "name": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "Cours du MIT en libre accès, toutes disciplines." },
|
||||
{ "name": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "Maths, sciences, programmation — gratuit, pédagogie excellente." },
|
||||
{ "name": "YouTube (canaux techniques)", "url": "https://www.youtube.com/", "desc": "Channals : The Coding Train, Fireship, 3Blue1Brown, etc." },
|
||||
{ "name": "freeCodeCamp", "url": "https://www.freecodecamp.org/", "desc": "Apprendre le développement web de zéro, gratuit et certifiant." },
|
||||
{ "name": "Coursera (audit gratuit)", "url": "https://www.coursera.org/", "desc": "Cours universitaires, audit gratuit disponible sur la plupart." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Médias & Audio",
|
||||
"children": [
|
||||
{ "name": "Audacity", "url": "https://www.audacityteam.org/", "desc": "Enregistrement et édition audio. Référence open source." },
|
||||
{ "name": "yt-dlp", "url": "https://github.com/yt-dlp/yt-dlp", "desc": "Télécharger des vidéos/audio depuis YouTube et 1000+ sites." },
|
||||
{ "name": "VLC", "url": "https://www.videolan.org/vlc/", "desc": "Lecteur multimédia universel." },
|
||||
{ "name": "Kdenlive", "url": "https://kdenlive.org/", "desc": "Montage vidéo open source, non linéaire." },
|
||||
{ "name": "OBS Studio", "url": "https://obsproject.com/", "desc": "Enregistrement et streaming vidéo. La référence gratuite." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"simulateurs": [
|
||||
{
|
||||
"id": "autonomie",
|
||||
"icon": "🟢",
|
||||
"titre": "Simulateur Autonomie",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"simulateurs_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"
|
||||
}
|
||||
],
|
||||
"opensource": [
|
||||
{
|
||||
"id": "dictee-universelle-groq",
|
||||
"icon": "🎤",
|
||||
"titre": "Dictée universelle Groq",
|
||||
"url": "https://github.com/Jayjay-nene/dictee-universelle-groq",
|
||||
"description": "Appuie sur une touche, parle, le texte apparaît au curseur avec ponctuation et majuscules auto. Transcription Whisper Groq < 1s. Marche dans toutes les applis Windows. Outil par Jayjay-nene.",
|
||||
"tag": "recommande"
|
||||
},
|
||||
{
|
||||
"id": "atis-voice",
|
||||
"icon": "🎙",
|
||||
"titre": "Atis Voice (text-to-speech)",
|
||||
"url": null,
|
||||
"description": "Pipeline TTS pour transformer un texte en audio.",
|
||||
"tag": "disponible"
|
||||
},
|
||||
{
|
||||
"id": "install-vps",
|
||||
"icon": "🖥",
|
||||
"titre": "Install VPS open source",
|
||||
"url": null,
|
||||
"description": "Setup pas-à-pas pour monter son propre VPS (Hetzner, Coolify, Caddy, Postgres…) en mode reproductible.",
|
||||
"tag": "a-venir"
|
||||
},
|
||||
{
|
||||
"id": "skills-claude-code",
|
||||
"icon": "⚙",
|
||||
"titre": "Skills Claude Code",
|
||||
"url": null,
|
||||
"description": "Skills custom pour booster sa pratique avec un agent IA.",
|
||||
"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": [
|
||||
{ "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": "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."
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"projets": [
|
||||
{
|
||||
"id": "quartier-2030",
|
||||
"titre": "Votre quartier en 2030",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://quartier-2030.firebaseapp.com/",
|
||||
"description": "Exploration prospective confrontant smart city, no future, résilience et deep ecology à l'échelle du quartier. Le travail donne à voir plusieurs futurs urbains contrastés, de l'utopie technologique au retrait radical, en laissant le visiteur naviguer entre les scénarios. Un travail d'orfèvre pour sortir de la pensée linéaire sur la ville.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "seine-nature",
|
||||
"titre": "Seine — nature urbaine",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2019",
|
||||
"ecole": "Inconnu",
|
||||
"url": "http://www.seine.natureurbaine.com/00_index/page_theme/theme.html",
|
||||
"description": "Projet de transformation territoriale collective autour de la Seine, pensé comme une démarche systémique et pluridisciplinaire. L'intervention se concentre sur les marges périurbaines, traitées par une logique d'acupuncture : des micro-interventions précises pour enclencher des dynamiques plus larges. L'approche refuse le grand projet unique au profit d'un réseau de petites transformations.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "tmip",
|
||||
"titre": "TMIP — Transformation de la Maison Individuelle Périurbaine",
|
||||
"auteurs": ["Jules Nény"],
|
||||
"annee": "2019",
|
||||
"ecole": "ENSA Paris-Belleville",
|
||||
"url": "https://issuu.com/transformationresilientes/docs/tmip_archijeunes_cstb_",
|
||||
"description": "Étude de la maison périurbaine sous l'angle des Gilets jaunes : comment ce lieu de vie concentre les tensions entre émancipation individuelle et dépendance structurelle (voiture, énergie, services). Le projet propose un réseau de micro-infrastructures partagées pour transformer ces maisons isolées en systèmes résilients interconnectés. Publié avec ARCHI'JEUNES et le CSTB.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "filiere-bois",
|
||||
"titre": "Enquête sur les paysages forestiers franciliens",
|
||||
"auteurs": ["Quid Architecture"],
|
||||
"annee": "2021",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://www.faireparis.com/fr/projets/faire-2021/enquete-sur-les-paysages-forestiers-franciliens-2159.html",
|
||||
"description": "Projet lauréat FAIRE 2021. Enquête sur les dysfonctionnements de la filière bois en Île-de-France, aux interfaces entre sylviculteurs, scieries, artisans et maîtres d'ouvrage. Le travail cartographie les ruptures de filière et propose des interventions concrètes pour réparer les liens entre forêt et construction. Une démarche systémique rare dans les études architecturales.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "jeu-champagne",
|
||||
"titre": "Jeu de rôle Champagne PFE — Plateau",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://campfe2020.wixsite.com/champagnepfe/plateau",
|
||||
"description": "Dispositif ludique et coopératif développé comme outil de médiation entre acteurs d'un territoire. Le jeu de rôle permet de traverser des problèmes complexes en engageant simultanément des parties prenantes aux intérêts divergents. Une exploration de l'architecture comme processus collectif plutôt que comme objet produit.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "transition-agricole",
|
||||
"titre": "Transition agricole — réinvestissement de fermes traditionnelles",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://www.calameo.com/books/007306483e0b23edb1db7",
|
||||
"description": "Projet sur la transformation de fermes traditionnelles dans une logique agricole moderne et diversifiée. L'étude explore comment l'architecture peut accompagner les transitions d'usage des bâtiments ruraux, en articulant patrimonial et fonctionnel. Voir aussi le projet complémentaire sur la Seine aval : https://www.calameo.com/books/007063623f4d4b800b01d",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
# Icons Credits
|
||||
|
||||
## outils-wrench.svg
|
||||
|
||||
**Source :** Heroicons — `wrench-screwdriver` icon
|
||||
**Licence :** MIT
|
||||
**URL :** https://heroicons.com/
|
||||
**Note :** Fallback Heroicons utilisé car l'API Noun Project a retourné 403 Forbidden au moment du build (2026-05-22). L'icône `wrench-screwdriver` de Heroicons (MIT) est une clé à molette + tournevis, style line art minimaliste, compatible avec l'identité visuelle AEP.
|
||||
|
||||
Si tu veux substituer par une icône Noun Project, utilise l'endpoint :
|
||||
`GET https://api.thenounproject.com/v2/icon?query=wrench&limit=20`
|
||||
avec les credentials OAuth 1.0a stockées dans `_System/API-credentials.md`.
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l5.654-4.654m5.598-2.337 3.07-3.293a2.25 2.25 0 0 0-3.182-3.182l-3.293 3.07M6.75 12.75l-2.25.75.75-2.25 2.25-.75-.75 2.25Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 493 B |
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* POST /api/chatbot-reseaux
|
||||
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Rollup
|
||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface Structure {
|
||||
id: string
|
||||
nom: string
|
||||
url?: string
|
||||
pays?: string
|
||||
ville?: string
|
||||
famille_principale?: string
|
||||
hashtags?: string[]
|
||||
description_courte?: string
|
||||
description_longue?: string
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
|
||||
|
||||
Ton rôle : orienter l'architecte vers les structures les plus pertinentes pour sa situation, à partir des données ci-dessous.
|
||||
|
||||
RÈGLES :
|
||||
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
||||
2. Sois direct et engagé — c'est l'esprit AEP.
|
||||
3. Réponse max 250 mots, en français.
|
||||
4. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||
|
||||
FORMAT :
|
||||
{
|
||||
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||
"fiches_recommandees": [
|
||||
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
|
||||
]
|
||||
}
|
||||
|
||||
STRUCTURES DISPONIBLES :
|
||||
{{STRUCTURES_JSON}}`
|
||||
|
||||
function scoreStructure(s: Structure, keywords: string[]): number {
|
||||
if (keywords.length === 0) return 1
|
||||
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
||||
.filter(Boolean).join(' ').toLowerCase()
|
||||
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
function extractKeywords(q: string): string[] {
|
||||
return q.toLowerCase().replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ').split(/\s+/).filter(w => w.length >= 3).slice(0, 10)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-reseaux', 20)
|
||||
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||
|
||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||
const keywords = extractKeywords(question)
|
||||
|
||||
const top = structures
|
||||
.map(s => ({ s, score: scoreStructure(s, keywords) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map(x => x.s)
|
||||
|
||||
const context = top.map(s => ({
|
||||
id: s.id,
|
||||
nom: s.nom,
|
||||
famille: s.famille_principale ?? '',
|
||||
lieu: [s.ville, s.pays].filter(Boolean).join(', '),
|
||||
tags: (s.hashtags ?? []).slice(0, 5).join(', '),
|
||||
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const mistralApiKey = config.mistralApiKey as string
|
||||
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.mistral.ai/v1/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(mistralRaw)
|
||||
return {
|
||||
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||
fiches_recommandees: (parsed.fiches_recommandees ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
||||
explication: r.explication ?? '',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
||||
}
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* POST /api/chatbot-taff
|
||||
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
||||
*/
|
||||
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import taffData from '../../public/data/plateformes-taff.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface PlateformeMinimal {
|
||||
id: string
|
||||
nom: string
|
||||
type: string
|
||||
description_courte: string
|
||||
scoring: {
|
||||
remuneration: string | null
|
||||
transparence: string | null
|
||||
pratiques: string | null
|
||||
ecologie: string | null
|
||||
matching: string | null
|
||||
tag_global: string
|
||||
justification_tag: string
|
||||
}
|
||||
secteurs_servis: string[]
|
||||
cout_entree: string
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes indépendants français. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
|
||||
|
||||
Ton rôle : aider l'architecte à choisir LA ou LES plateformes adaptées à sa situation, en t'appuyant exclusivement sur les données ci-dessous.
|
||||
|
||||
RÈGLES :
|
||||
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous.
|
||||
2. Sois direct et opinionné — c'est ça la valeur d'AEP.
|
||||
3. Si une plateforme est ❌ "À éviter", signale-le clairement.
|
||||
4. Réponse max 250 mots, en français, ton conseiller pair.
|
||||
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||
|
||||
FORMAT :
|
||||
{
|
||||
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||
"plateformes_recommandees": [
|
||||
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
|
||||
]
|
||||
}
|
||||
|
||||
PLATEFORMES DISPONIBLES :
|
||||
{{PLATEFORMES_JSON}}`
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-taff', 20)
|
||||
if (!allowed) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Limite de 20 questions par jour atteinte.' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
if (!question || question.length < 5) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||
}
|
||||
|
||||
// Données bundlées statiquement à la compilation (import JSON)
|
||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type,
|
||||
description_courte: p.description_courte,
|
||||
scoring: p.scoring,
|
||||
secteurs_servis: p.secteurs_servis,
|
||||
cout_entree: p.cout_entree,
|
||||
}))
|
||||
|
||||
const context = plateformes.map(p => ({
|
||||
id: p.id,
|
||||
nom: p.nom,
|
||||
type: p.type === 'b2c-mise-en-relation' ? 'B2C' : 'Appels offres publics',
|
||||
tag: p.scoring.tag_global,
|
||||
resume: p.description_courte,
|
||||
secteurs: p.secteurs_servis.join(', '),
|
||||
cout: p.cout_entree,
|
||||
justification: p.scoring.justification_tag,
|
||||
}))
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const mistralApiKey = config.mistralApiKey as string
|
||||
if (!mistralApiKey) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.mistral.ai/v1/chat/completions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(mistralRaw)
|
||||
return {
|
||||
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||
plateformes_recommandees: (parsed.plateformes_recommandees ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
||||
raison: r.raison ?? '',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
||||
}
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const AuthSchema = z.object({
|
||||
password: z.string().min(1).max(100),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const parsed = AuthSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const expected = config.codevPassword || 'merci'
|
||||
|
||||
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
|
||||
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
|
||||
|
||||
if (!isAdmin && !isUser) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
|
||||
}
|
||||
|
||||
// Cookie session (user + admin)
|
||||
setCookie(event, 'codev_session', 'ok', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24, // 24h
|
||||
path: '/',
|
||||
})
|
||||
|
||||
// Cookie admin si mot de passe admin
|
||||
if (isAdmin) {
|
||||
setCookie(event, 'codev_admin', 'ok', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24, // 24h
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
return { status: 200, ok: true, admin: isAdmin }
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { CodevFiche } from '~/types/codev'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
|
||||
const config = useRuntimeConfig()
|
||||
const tableId = config.codevTableId
|
||||
|
||||
if (!tableId) {
|
||||
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
|
||||
}
|
||||
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
|
||||
|
||||
const data: any = await $fetch(url, {
|
||||
headers: { 'xc-token': config.nocodbToken },
|
||||
}).catch(() => ({ list: [] }))
|
||||
|
||||
// Mapper chaque record NocoDB vers CodevFiche
|
||||
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
|
||||
id: r.Id ?? r.id,
|
||||
nom: r.nom || '',
|
||||
besoin: r.besoin || '',
|
||||
offre: r.offre || '',
|
||||
hashtags: (r.hashtags || '')
|
||||
.split(',')
|
||||
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||
.filter(Boolean),
|
||||
created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
|
||||
}))
|
||||
|
||||
return { list }
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const FicheSchema = z.object({
|
||||
nom: z.string().min(2).max(50).trim(),
|
||||
besoin: z.string().min(5).max(300).trim(),
|
||||
offre: z.string().min(5).max(300).trim(),
|
||||
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const parsed = FicheSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation échouée',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const tableId = config.codevTableId
|
||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||
|
||||
const payload = {
|
||||
nom: parsed.data.nom,
|
||||
besoin: parsed.data.besoin,
|
||||
offre: parsed.data.offre,
|
||||
hashtags: parsed.data.hashtags
|
||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(','),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
|
||||
|
||||
let inserted: any
|
||||
try {
|
||||
inserted = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur serveur, réessaie',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
id: inserted?.Id ?? inserted?.id ?? null,
|
||||
}
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Vérif cookie admin
|
||||
const adminCookie = getCookie(event, 'codev_admin')
|
||||
if (adminCookie !== 'ok') {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const tableId = config.codevTableId
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!tableId || !id) {
|
||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||
}
|
||||
|
||||
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Id: Number(id) }),
|
||||
}).catch(() => {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
|
||||
})
|
||||
|
||||
return { status: 200, ok: true }
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { CodevFiche } from '~/types/codev'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<CodevFiche> => {
|
||||
const config = useRuntimeConfig()
|
||||
const tableId = config.codevTableId
|
||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!tableId || !id) {
|
||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||
}
|
||||
|
||||
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||
|
||||
const r: any = await $fetch(url, {
|
||||
headers: { 'xc-token': config.nocodbToken },
|
||||
}).catch(() => null)
|
||||
|
||||
if (!r) {
|
||||
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.Id ?? r.id,
|
||||
nom: r.nom || '',
|
||||
besoin: r.besoin || '',
|
||||
offre: r.offre || '',
|
||||
hashtags: (r.hashtags || '')
|
||||
.split(',')
|
||||
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||
.filter(Boolean),
|
||||
created_at: r.created_at || r.CreatedAt || '',
|
||||
}
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const PatchSchema = z.object({
|
||||
nom: z.string().min(2).max(50).trim(),
|
||||
besoin: z.string().min(5).max(300).trim(),
|
||||
offre: z.string().min(5).max(300).trim(),
|
||||
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const tableId = config.codevTableId
|
||||
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
if (!tableId || !id) {
|
||||
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||
}
|
||||
|
||||
const parsed = PatchSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation echouee',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = {
|
||||
nom: parsed.data.nom,
|
||||
besoin: parsed.data.besoin,
|
||||
offre: parsed.data.offre,
|
||||
hashtags: parsed.data.hashtags
|
||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(','),
|
||||
}
|
||||
|
||||
// NocoDB v1 PATCH par Id
|
||||
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||
|
||||
try {
|
||||
await $fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
|
||||
}
|
||||
|
||||
return { status: 200, ok: true }
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const admin = getCookie(event, 'codev_admin') === 'ok'
|
||||
const session = getCookie(event, 'codev_session') === 'ok'
|
||||
return { admin, session }
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* GET /api/plateformes-taff
|
||||
* Retourne les données TAFF — JSON importé statiquement (bundlé par Rollup).
|
||||
* Utilisé par le chatbot-taff en interne et potentiellement par le front.
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import data from '../../public/data/plateformes-taff.json'
|
||||
|
||||
export default defineEventHandler(() => data)
|
||||
@@ -1,21 +0,0 @@
|
||||
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
|
||||
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const url = getRequestURL(event)
|
||||
const path = url.pathname
|
||||
|
||||
// Seulement les routes sous /codev/
|
||||
if (!path.startsWith('/codev/')) return
|
||||
|
||||
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
|
||||
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
|
||||
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
|
||||
|
||||
// Vérification cookie
|
||||
const session = getCookie(event, 'codev_session')
|
||||
if (session === 'ok') return
|
||||
|
||||
// Non authentifié -> redirect vers /codev (lock screen)
|
||||
return sendRedirect(event, '/codev', 302)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
|
||||
setResponseHeader(event, 'cache-control', 'public, max-age=300')
|
||||
return raw
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface CodevFiche {
|
||||
id: number
|
||||
nom: string
|
||||
besoin: string
|
||||
offre: string
|
||||
hashtags: string[] // parsé depuis CSV NocoDB
|
||||
created_at: string // ISO
|
||||
}
|
||||
|
||||
export interface CodevMatch {
|
||||
fromId: number
|
||||
toId: number
|
||||
score: number // 0-1
|
||||
mode: 'solution' | 'alliance' | 'surprise'
|
||||
// solution : fromId.besoin matche toId.offre (orienté)
|
||||
// alliance : symétrique sur besoin
|
||||
// surprise : symétrique sur offre
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||
|
||||
const STOP_WORDS_FR = new Set([
|
||||
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
|
||||
'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
|
||||
'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
|
||||
'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
|
||||
'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
|
||||
'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
|
||||
'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
|
||||
'est', 'sont', 'etre', 'ai', 'as', 'avoir',
|
||||
'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
|
||||
'me', 'te', 'se', 'lui', 'leur', 'y',
|
||||
])
|
||||
|
||||
function tokenize(text: string): Set<string> {
|
||||
if (!text) return new Set()
|
||||
const tokens = text
|
||||
.toLowerCase()
|
||||
.replace(/[.,;:!?()'"\-/]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
|
||||
return new Set(tokens)
|
||||
}
|
||||
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let inter = 0
|
||||
for (const x of a) if (b.has(x)) inter++
|
||||
const union = a.size + b.size - inter
|
||||
return union === 0 ? 0 : inter / union
|
||||
}
|
||||
|
||||
function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
|
||||
const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
|
||||
const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
|
||||
|
||||
if (tagsA.size > 0 && tagsB.size > 0) {
|
||||
return jaccard(tagsA, tagsB)
|
||||
}
|
||||
return jaccard(tokenize(textA), tokenize(textB))
|
||||
}
|
||||
|
||||
// scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
|
||||
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
|
||||
function scoreDirect(textA: string, textB: string): number {
|
||||
return jaccard(tokenize(textA), tokenize(textB))
|
||||
}
|
||||
|
||||
export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
|
||||
const matches: CodevMatch[] = []
|
||||
for (const a of fiches) {
|
||||
for (const b of fiches) {
|
||||
if (a.id === b.id) continue
|
||||
// Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
|
||||
// On ignore les hashtags pour differencier besoin et offre
|
||||
const s = scoreDirect(a.besoin, b.offre)
|
||||
if (s >= threshold) {
|
||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||
const matches: CodevMatch[] = []
|
||||
for (let i = 0; i < fiches.length; i++) {
|
||||
for (let j = i + 1; j < fiches.length; j++) {
|
||||
const a = fiches[i], b = fiches[j]
|
||||
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
|
||||
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
|
||||
if (s >= threshold) {
|
||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||
const matches: CodevMatch[] = []
|
||||
for (let i = 0; i < fiches.length; i++) {
|
||||
for (let j = i + 1; j < fiches.length; j++) {
|
||||
const a = fiches[i], b = fiches[j]
|
||||
// Surprise : offres similaires
|
||||
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
|
||||
if (s >= threshold) {
|
||||
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
export function computeMatches(
|
||||
fiches: CodevFiche[],
|
||||
mode: 'solution' | 'alliance' | 'surprise',
|
||||
threshold?: number,
|
||||
): CodevMatch[] {
|
||||
switch (mode) {
|
||||
case 'solution': return matchSolution(fiches, threshold)
|
||||
case 'alliance': return matchAlliance(fiches, threshold)
|
||||
case 'surprise': return matchSurprise(fiches, threshold)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user