Compare commits
14 Commits
feat/aep-r
...
feat/propo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf9f721a7 | ||
|
|
e61114d6ec | ||
|
|
c860dabdc0 | ||
|
|
f3ab28bd69 | ||
|
|
cd8fe9e258 | ||
|
|
ea7c8cc91e | ||
|
|
538c490e76 | ||
|
|
d584d04e3d | ||
|
|
bd95c0f00d | ||
|
|
db8f614928 | ||
|
|
59cb81a055 | ||
|
|
91e3466ec6 | ||
|
|
cb75889231 | ||
|
|
422f45116f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
|
.firecrawl/
|
||||||
|
|||||||
29
app.vue
29
app.vue
@@ -22,12 +22,19 @@
|
|||||||
|
|
||||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
<!-- ── 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">
|
<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
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||||
>
|
>
|
||||||
Écosystème Entraide Architecture
|
Écosystème Entraide
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/agences"
|
to="/agences"
|
||||||
@@ -51,12 +58,11 @@
|
|||||||
Codev
|
Codev
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/rag"
|
to="/media"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||||
>
|
>
|
||||||
RAG
|
recherche-média
|
||||||
<span class="nav-tab-badge">en construction</span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -108,9 +114,9 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource -->
|
<!-- Proposer une ressource (5 onglets) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
to="/proposer"
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
>
|
>
|
||||||
@@ -137,13 +143,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : contribuer icône -->
|
<!-- Mobile : proposer icône -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/contribuer"
|
to="/proposer"
|
||||||
class="sm:hidden p-2 rounded-lg"
|
class="sm:hidden p-2 rounded-lg"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
title="Contribuer une fiche"
|
title="Proposer une ressource"
|
||||||
aria-label="Contribuer"
|
aria-label="Proposer"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
@@ -172,6 +178,7 @@
|
|||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
@click="hamburgerOpen = false"
|
@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="/" 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="/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="/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>
|
||||||
|
|||||||
372
components/CartePensees.vue
Normal file
372
components/CartePensees.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<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>
|
||||||
448
components/ChatbotPensees.vue
Normal file
448
components/ChatbotPensees.vue
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chatbot-pensees" :class="{ inline: inline }">
|
||||||
|
<!-- Header compact -->
|
||||||
|
<div class="chatbot-header">
|
||||||
|
<div class="chatbot-header-left">
|
||||||
|
<div class="chatbot-icon">
|
||||||
|
<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">
|
||||||
|
<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="chatbot-title">
|
||||||
|
<template v-if="auteurContext">{{ auteurContext }}</template>
|
||||||
|
<template v-else>Bibliothèque des pensées</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
|
||||||
|
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
||||||
|
</div>
|
||||||
|
<div class="chatbot-header-right">
|
||||||
|
<button
|
||||||
|
v-if="messages.length > 0"
|
||||||
|
class="chatbot-clear-btn"
|
||||||
|
title="Effacer la conversation"
|
||||||
|
@click="clearMessages"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
||||||
|
<option value="pensees">Pensées éco.</option>
|
||||||
|
<option value="both">Pensées + Projets</option>
|
||||||
|
<option value="projets">Projets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone messages -->
|
||||||
|
<div ref="messagesContainer" class="chatbot-messages">
|
||||||
|
<!-- Onboarding -->
|
||||||
|
<div v-if="messages.length === 0" class="chatbot-onboarding">
|
||||||
|
<template v-if="auteurContext">
|
||||||
|
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> — ses livres, ses thèses, ses arguments.</p>
|
||||||
|
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>Bibliothèque des pensées écologiques — <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
|
||||||
|
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
|
||||||
|
<div v-else class="chatbot-bubble assistant">
|
||||||
|
<div class="chatbot-md" v-html="renderMd(msg.content)" />
|
||||||
|
<div v-if="msg.auteur" class="chatbot-auteur-tag">
|
||||||
|
<span>{{ msg.auteur.nom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div v-if="loading" class="chatbot-bubble assistant loading">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur -->
|
||||||
|
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="chatbot-input-row">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
|
||||||
|
class="chatbot-input"
|
||||||
|
@keydown.enter.prevent="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="loading || !inputText.trim()"
|
||||||
|
class="chatbot-send-btn"
|
||||||
|
aria-label="Envoyer"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMarkdown } from '~/composables/useMarkdown'
|
||||||
|
|
||||||
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
auteur?: { slug: string; nom: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
auteurContext: string | null
|
||||||
|
inline?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.auteurContext, () => {
|
||||||
|
messages.value = []
|
||||||
|
errorMsg.value = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
messages.value = []
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const query = inputText.value.trim()
|
||||||
|
if (!query || loading.value) return
|
||||||
|
|
||||||
|
inputText.value = ''
|
||||||
|
errorMsg.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: query })
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{
|
||||||
|
response: string
|
||||||
|
auteur: { slug: string; nom: string } | null
|
||||||
|
}>('/api/chatbot-pensees', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
query,
|
||||||
|
corpus: corpusMode.value,
|
||||||
|
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
messages.value.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: res.response,
|
||||||
|
auteur: res.auteur ?? null,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.statusCode ?? e?.status
|
||||||
|
if (status === 429) {
|
||||||
|
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
|
||||||
|
} else if (status === 503) {
|
||||||
|
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
|
||||||
|
} else if (status === 504) {
|
||||||
|
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
|
||||||
|
} else {
|
||||||
|
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str: string): string {
|
||||||
|
return str.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chatbot-pensees {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.chatbot-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-corpus-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-clear-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-clear-btn:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-corpus-select {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(180, 170, 160, 0.3);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.chatbot-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-onboarding {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-onboarding p { margin: 0 0 6px; }
|
||||||
|
.chatbot-onboarding p:last-child { margin-bottom: 0; }
|
||||||
|
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
|
||||||
|
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
|
||||||
|
|
||||||
|
/* Bulles */
|
||||||
|
.chatbot-bubble {
|
||||||
|
max-width: 88%;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-bubble.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 10px 10px 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-bubble.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 10px 10px 10px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown dans les bulles */
|
||||||
|
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
|
||||||
|
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
|
||||||
|
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
|
||||||
|
:deep(.chatbot-md strong) { font-weight: 700; }
|
||||||
|
:deep(.chatbot-md em) { font-style: italic; }
|
||||||
|
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
|
||||||
|
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
|
||||||
|
|
||||||
|
.chatbot-auteur-tag {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.chatbot-bubble.loading {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--nav-text-muted);
|
||||||
|
animation: blink 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 80%, 100% { opacity: 0.25; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur */
|
||||||
|
.chatbot-error {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: #a85d3e;
|
||||||
|
background: rgba(168, 93, 62, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.chatbot-input-row {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(180, 170, 160, 0.3);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
min-width: 0;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-send-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-send-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-send-btn:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
components/FormEcosysteme.vue
Normal file
184
components/FormEcosysteme.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
|
||||||
|
<input id="eco-nom" v-model="form.nom" type="text" placeholder="Ex : UNSFA, Maison de l'Architecture..." autocomplete="organization" @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
|
||||||
|
<input id="eco-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="eco-desc" v-model="form.description_user" rows="4" placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..." @blur="validateField('description_user')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.echelle }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Échelle <span class="required">*</span> <span class="label-hint">(une seule)</span></legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="opt in ECHELLES" :key="opt" class="radio-label" :class="{ active: form.echelle === opt }">
|
||||||
|
<input type="radio" :value="opt" v-model="form.echelle" name="eco-echelle" @change="validateField('echelle')" />{{ opt }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Fonctions <span class="required">*</span> <span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span></legend>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label v-for="fn in FONCTIONS" :key="fn" class="checkbox-label" :class="{ active: form.fonctions.includes(fn), disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5 }">
|
||||||
|
<input type="checkbox" :value="fn" :checked="form.fonctions.includes(fn)" :disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5" @change="toggleFonction(fn)" />
|
||||||
|
<span class="fn-order" v-if="form.fonctions.includes(fn)">{{ form.fonctions.indexOf(fn) + 1 }}</span>{{ fn }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.territoire }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Territoire <span class="required">*</span></legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="t in TERRITOIRES" :key="t" class="radio-label" :class="{ active: form.territoire === t }">
|
||||||
|
<input type="radio" :value="t" v-model="form.territoire" name="eco-territoire" @change="validateField('territoire')" />{{ t }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
||||||
|
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
|
||||||
|
<input id="eco-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon, Bordeaux..." />
|
||||||
|
<span v-if="errors.localisation_ville" class="error-msg" role="alert">{{ errors.localisation_ville }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
|
||||||
|
<input id="eco-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
|
||||||
|
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale'] as const
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
|
||||||
|
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
|
||||||
|
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', url: '', description_user: '', echelle: '' as string,
|
||||||
|
fonctions: [] as string[], territoire: '' as string,
|
||||||
|
localisation_ville: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = Schema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = Schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFonction(fn: string) {
|
||||||
|
const idx = form.fonctions.indexOf(fn)
|
||||||
|
if (idx >= 0) form.fonctions.splice(idx, 1)
|
||||||
|
else if (form.fonctions.length < 5) form.fonctions.push(fn)
|
||||||
|
validateField('fonctions')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
submission_type: 'ecosysteme',
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
echelle: form.echelle,
|
||||||
|
fonctions: form.fonctions,
|
||||||
|
territoire: form.territoire,
|
||||||
|
localisation_ville: form.localisation_ville || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('success')
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './form-styles.css';
|
||||||
|
</style>
|
||||||
134
components/FormJobs.vue
Normal file
134
components/FormJobs.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
|
||||||
|
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
|
||||||
|
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="job-url">Site web <span class="required">*</span></label>
|
||||||
|
<input id="job-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @blur="validateField('description_user')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="job-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="job-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||||
|
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)'),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
type_plateforme: z.string().optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', url: '', description_user: '', type_plateforme: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = Schema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = Schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
submission_type: 'job',
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url,
|
||||||
|
description_user: form.description_user,
|
||||||
|
type_plateforme: form.type_plateforme || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('success')
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './form-styles.css';
|
||||||
|
</style>
|
||||||
134
components/FormOutils.vue
Normal file
134
components/FormOutils.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
|
||||||
|
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
|
||||||
|
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
|
||||||
|
<input id="out-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @blur="validateField('description_user')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="out-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||||
|
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
categorie: z.string().optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', url: '', description_user: '', categorie: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = Schema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = Schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
submission_type: 'outil',
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
categorie: form.categorie || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('success')
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './form-styles.css';
|
||||||
|
</style>
|
||||||
142
components/FormReferences.vue
Normal file
142
components/FormReferences.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.titre }">
|
||||||
|
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
|
||||||
|
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
|
||||||
|
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.auteur }">
|
||||||
|
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
|
||||||
|
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
|
||||||
|
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="ref-url">Lien <span class="label-hint">(optionnel — éditeur, статье, PDF…)</span></label>
|
||||||
|
<input id="ref-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description }">
|
||||||
|
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
|
||||||
|
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
|
||||||
|
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
|
||||||
|
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="ref-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la référence →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||||
|
pertinence_rag: z.string().max(500).optional(),
|
||||||
|
hashtags: z.string().max(300).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
titre: '', auteur: '', url: '', description: '',
|
||||||
|
pertinence_rag: '', hashtags: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = Schema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = Schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit/reference', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
titre: form.titre,
|
||||||
|
auteur: form.auteur,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description: form.description,
|
||||||
|
pertinence_rag: form.pertinence_rag || undefined,
|
||||||
|
hashtags: form.hashtags || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('success')
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './form-styles.css';
|
||||||
|
</style>
|
||||||
158
components/FormReseau.vue
Normal file
158
components/FormReseau.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
|
||||||
|
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @blur="validateField('nom')" />
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.type_principal }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Type <span class="label-hint">(optionnel)</span></legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
|
||||||
|
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||||
|
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @blur="validateField('description_user')" />
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
|
||||||
|
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="res-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||||
|
<input id="res-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||||
|
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
type_principal: z.string().optional(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
|
projet_adresse: z.string().max(200).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '', type_principal: '', url: '', description_user: '',
|
||||||
|
localisation_ville: '', projet_lien: '', projet_adresse: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = Schema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = Schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
submission_type: 'reseau',
|
||||||
|
nom: form.nom,
|
||||||
|
type_principal: form.type_principal || undefined,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
localisation_ville: form.localisation_ville || undefined,
|
||||||
|
projet_lien: form.projet_lien || undefined,
|
||||||
|
projet_adresse: form.projet_adresse || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('success')
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './form-styles.css';
|
||||||
|
</style>
|
||||||
19
components/MediaTabBackend.vue
Normal file
19
components/MediaTabBackend.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
||||||
67
components/MediaTabProjets.vue
Normal file
67
components/MediaTabProjets.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<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>
|
||||||
650
components/MediaTabVisuel.vue
Normal file
650
components/MediaTabVisuel.vue
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
<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: 1 1 0;
|
||||||
|
min-height: 120px;
|
||||||
|
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: 1 1 0;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
.toggle-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
components/OutilCard.vue
Normal file
150
components/OutilCard.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<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>
|
||||||
156
components/SimulateurFeature.vue
Normal file
156
components/SimulateurFeature.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<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>
|
||||||
201
components/TreeASCII.vue
Normal file
201
components/TreeASCII.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<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>
|
||||||
240
components/form-styles.css
Normal file
240
components/form-styles.css
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
.proposer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required { color: #c0392b; }
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="url"],
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
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);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.checkbox-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover:not(.disabled) {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fn-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form-actions { flex-direction: column-reverse; }
|
||||||
|
.btn-primary { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export default defineNuxtConfig({
|
|||||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
||||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||||
|
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
|
||||||
|
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
|
|||||||
@@ -190,12 +190,38 @@
|
|||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 8 - API publique
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="section section-opensource">
|
||||||
|
<h2>API publique</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Les données de ce site internet sont publiques, toutes consultables via API directe, pour que des agents IA, des développeurs, ou d'autres sites puissent interroger la base sans passer par l'interface graphique.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
<strong>Données accessibles :</strong> organisations d'entraide, pratiques d'architecture d'écologie politique (graphe complet + projets), plateformes d'emploi, RAG et ses auteurs, et toutes autres données à venir.
|
||||||
|
</p>
|
||||||
|
<div class="api-copy-box">
|
||||||
|
<code class="api-url">https://api.trans-former.fr</code>
|
||||||
|
<button class="btn-copy" @click="copyApiUrl">{{ copied ? 'Copié !' : 'Copier' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
useHead({ title: 'À propos - AEP' })
|
useHead({ title: 'À propos - AEP' })
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
function copyApiUrl() {
|
||||||
|
navigator.clipboard.writeText('https://api.trans-former.fr')
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -512,6 +538,44 @@ useHead({ title: 'À propos - AEP' })
|
|||||||
background: var(--nav-bg-alt);
|
background: var(--nav-bg-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── API copy box ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.api-copy-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-url {
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--nav-text);
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--nav-primary-solid);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -1,795 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contribuer-page">
|
<div />
|
||||||
<div class="contribuer-inner">
|
|
||||||
<!-- Retour -->
|
|
||||||
<NuxtLink to="/" class="back-link">
|
|
||||||
← Retour à la carte
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- En-tête -->
|
|
||||||
<div class="contribuer-header">
|
|
||||||
<h1>Proposer une ressource</h1>
|
|
||||||
<p class="contribuer-subtitle">
|
|
||||||
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
|
|
||||||
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
|
|
||||||
</p>
|
|
||||||
<p class="contribuer-hint">
|
|
||||||
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien — on extraira les infos du site.
|
|
||||||
Mais une description de toi, c'est toujours plus vivant et plus précis.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message succès -->
|
|
||||||
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
|
||||||
<div class="success-icon">✓</div>
|
|
||||||
<h2>Merci !</h2>
|
|
||||||
<p>Ta fiche est en cours de traitement.</p>
|
|
||||||
<p class="success-detail">
|
|
||||||
Une IA va scraper le site et enrichir la description.
|
|
||||||
Jules (et bientôt une équipe de modération) valide sous 7 jours.
|
|
||||||
</p>
|
|
||||||
<button type="button" class="btn-secondary" @click="reset">
|
|
||||||
Proposer une autre fiche
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formulaire -->
|
|
||||||
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
|
||||||
|
|
||||||
<!-- Nom -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
|
||||||
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
|
||||||
<input
|
|
||||||
id="nom"
|
|
||||||
v-model="form.nom"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : UNSFA, Maison de l'Architecture..."
|
|
||||||
autocomplete="organization"
|
|
||||||
@blur="validateField('nom')"
|
|
||||||
/>
|
|
||||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- URL -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="url">
|
|
||||||
Site web
|
|
||||||
<span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="url"
|
|
||||||
v-model="form.url"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
@blur="validateField('url')"
|
|
||||||
/>
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
|
||||||
<label for="description_user">
|
|
||||||
Description courte <span class="required">*</span>
|
|
||||||
<span class="label-hint">(50 à 500 caractères)</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="description_user"
|
|
||||||
v-model="form.description_user"
|
|
||||||
rows="4"
|
|
||||||
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
|
|
||||||
@blur="validateField('description_user')"
|
|
||||||
/>
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description_user" class="error-msg" role="alert">
|
|
||||||
{{ errors.description_user }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
|
||||||
{{ form.description_user.length }}/500
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Échelle -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.echelle }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>
|
|
||||||
Échelle <span class="required">*</span>
|
|
||||||
<span class="label-hint">(une seule)</span>
|
|
||||||
</legend>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label
|
|
||||||
v-for="opt in ECHELLES"
|
|
||||||
:key="opt"
|
|
||||||
class="radio-label"
|
|
||||||
:class="{ active: form.echelle === opt }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
:value="opt"
|
|
||||||
v-model="form.echelle"
|
|
||||||
name="echelle"
|
|
||||||
@change="validateField('echelle')"
|
|
||||||
/>
|
|
||||||
{{ opt }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fonctions -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>
|
|
||||||
Fonctions <span class="required">*</span>
|
|
||||||
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
|
|
||||||
</legend>
|
|
||||||
<div class="checkbox-grid">
|
|
||||||
<label
|
|
||||||
v-for="fn in FONCTIONS"
|
|
||||||
:key="fn"
|
|
||||||
class="checkbox-label"
|
|
||||||
:class="{
|
|
||||||
active: form.fonctions.includes(fn),
|
|
||||||
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:value="fn"
|
|
||||||
:checked="form.fonctions.includes(fn)"
|
|
||||||
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
|
|
||||||
@change="toggleFonction(fn)"
|
|
||||||
/>
|
|
||||||
<span class="fn-order" v-if="form.fonctions.includes(fn)">
|
|
||||||
{{ form.fonctions.indexOf(fn) + 1 }}
|
|
||||||
</span>
|
|
||||||
{{ fn }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Territoire -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.territoire }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>
|
|
||||||
Territoire <span class="required">*</span>
|
|
||||||
</legend>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label
|
|
||||||
v-for="t in TERRITOIRES"
|
|
||||||
:key="t"
|
|
||||||
class="radio-label"
|
|
||||||
:class="{ active: form.territoire === t }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
:value="t"
|
|
||||||
v-model="form.territoire"
|
|
||||||
name="territoire"
|
|
||||||
@change="validateField('territoire')"
|
|
||||||
/>
|
|
||||||
{{ t }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ville -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
|
||||||
<label for="localisation_ville">
|
|
||||||
Ville principale
|
|
||||||
<span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="localisation_ville"
|
|
||||||
v-model="form.localisation_ville"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : Paris, Lyon, Bordeaux..."
|
|
||||||
/>
|
|
||||||
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
|
|
||||||
{{ errors.localisation_ville }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="submitted_by_email">
|
|
||||||
Ton email
|
|
||||||
<span class="label-hint">(optionnel — pour le suivi de modération)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="submitted_by_email"
|
|
||||||
v-model="form.submitted_by_email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ton@email.fr"
|
|
||||||
autocomplete="email"
|
|
||||||
@blur="validateField('submitted_by_email')"
|
|
||||||
/>
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
|
||||||
{{ errors.submitted_by_email }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Erreur globale -->
|
|
||||||
<div v-if="serverError" class="server-error" role="alert">
|
|
||||||
<strong>Erreur :</strong> {{ serverError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="form-actions">
|
|
||||||
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="submitting"
|
|
||||||
>
|
|
||||||
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="form-note">
|
|
||||||
Ta fiche sera examinée par l'équipe avant publication.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { z } from 'zod'
|
navigateTo('/proposer', { redirectCode: 301 })
|
||||||
|
|
||||||
// ── Constantes ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
|
||||||
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
|
|
||||||
const FONCTIONS = [
|
|
||||||
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
|
||||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
|
||||||
|
|
||||||
const SubmitSchema = z.object({
|
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
|
||||||
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
|
||||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
|
||||||
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
|
|
||||||
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
|
|
||||||
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
|
|
||||||
localisation_ville: z.string().max(100).optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── État du formulaire ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
nom: '',
|
|
||||||
url: '',
|
|
||||||
description_user: '',
|
|
||||||
echelle: '' as typeof ECHELLES[number] | '',
|
|
||||||
fonctions: [] as string[],
|
|
||||||
territoire: '' as typeof TERRITOIRES[number] | '',
|
|
||||||
localisation_ville: '',
|
|
||||||
submitted_by_email: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const success = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
const trackingUrl = ref<string | null>(null)
|
|
||||||
|
|
||||||
// ── Validation champ par champ ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = SubmitSchema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = SubmitSchema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(
|
|
||||||
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
|
||||||
))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
|
|
||||||
|
|
||||||
function toggleFonction(fn: string) {
|
|
||||||
const idx = form.fonctions.indexOf(fn)
|
|
||||||
if (idx >= 0) {
|
|
||||||
form.fonctions.splice(idx, 1)
|
|
||||||
} else if (form.fonctions.length < 5) {
|
|
||||||
form.fonctions.push(fn)
|
|
||||||
}
|
|
||||||
validateField('fonctions')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Soumission ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
|
|
||||||
if (!validateAll()) {
|
|
||||||
// Scroll vers la première erreur
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result: any = await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
nom: form.nom,
|
|
||||||
url: form.url || undefined,
|
|
||||||
description_user: form.description_user,
|
|
||||||
echelle: form.echelle,
|
|
||||||
fonctions: form.fonctions,
|
|
||||||
territoire: form.territoire,
|
|
||||||
localisation_ville: form.localisation_ville || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
trackingUrl.value = result.trackingUrl ?? null
|
|
||||||
success.value = true
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
// Erreurs Zod serveur → mapper sur le formulaire
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => {
|
|
||||||
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
|
||||||
})
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
Object.assign(form, {
|
|
||||||
nom: '', url: '', description_user: '', echelle: '',
|
|
||||||
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
success.value = false
|
|
||||||
serverError.value = ''
|
|
||||||
trackingUrl.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Meta ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
useHead({ title: 'Proposer une ressource — AEP' })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.contribuer-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
padding: 1.5rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contribuer-inner {
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.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: 1.5rem;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.contribuer-header {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contribuer-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contribuer-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contribuer-hint {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
opacity: 0.75;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.success-block {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: rgba(26, 34, 56, 0.1);
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-block h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-block p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-detail {
|
|
||||||
font-size: 0.85rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-tracking {
|
|
||||||
font-size: 0.85rem !important;
|
|
||||||
margin-top: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-link {
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.contribuer-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label,
|
|
||||||
.field-group legend {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group fieldset {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-hint {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input[type="text"],
|
|
||||||
.field-group input[type="url"],
|
|
||||||
.field-group input[type="email"],
|
|
||||||
.field-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
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);
|
|
||||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Erreur champ */
|
|
||||||
|
|
||||||
.field-error input,
|
|
||||||
.field-error textarea {
|
|
||||||
border-color: #c0392b !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-msg {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-warn {
|
|
||||||
color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.radio-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label input[type="radio"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label:hover {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label.active {
|
|
||||||
background: var(--nav-primary);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.checkbox-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
|
||||||
.checkbox-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label:hover:not(.disabled) {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label.active {
|
|
||||||
background: var(--nav-primary);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-order {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--nav-accent);
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.server-error {
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: rgba(26, 34, 56, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-note {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.contribuer-page {
|
|
||||||
padding: 1rem 0.75rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
56
pages/media.vue
Normal file
56
pages/media.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<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
Normal file
533
pages/outils.vue
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
<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>
|
||||||
194
pages/proposer.vue
Normal file
194
pages/proposer.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="proposer-page">
|
||||||
|
<div class="proposer-inner">
|
||||||
|
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
|
||||||
|
<div class="proposer-header">
|
||||||
|
<h1>Proposer une ressource</h1>
|
||||||
|
<p class="proposer-subtitle">
|
||||||
|
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
|
||||||
|
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta proposition est en attente de modération.</p>
|
||||||
|
<p class="success-detail">Jules valide manuellement chaque entrée avant publication.</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">Proposer autre chose</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="tabs-bar">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
type="button"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ 'tab-btn--active': activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
|
||||||
|
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
|
||||||
|
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
|
||||||
|
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||||
|
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
||||||
|
{ id: 'reseau', label: 'Réseau AEP' },
|
||||||
|
{ id: 'jobs', label: 'Jobs' },
|
||||||
|
{ id: 'outils', label: 'Outils' },
|
||||||
|
{ id: 'references', label: 'Références RAG' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const activeTab = ref<string>('ecosysteme')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
function onSuccess() {
|
||||||
|
success.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
success.value = false
|
||||||
|
activeTab.value = 'ecosysteme'
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une ressource — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.proposer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.proposer-inner {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.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: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.back-link:hover { opacity: 1; }
|
||||||
|
.proposer-header { margin-bottom: 1.5rem; }
|
||||||
|
.proposer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.proposer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--nav-text); }
|
||||||
|
.tab-btn--active {
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-bottom-color: var(--nav-primary-solid);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.success-detail { font-size: 0.85rem !important; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.proposer-page { padding: 1rem 0.75rem 3rem; }
|
||||||
|
.tabs-bar { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,38 +1,3 @@
|
|||||||
<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">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
navigateTo('/media', { redirectCode: 301 })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
16873
public/cartes/carte-fracas-bonpote-v2.pdf
Normal file
16873
public/cartes/carte-fracas-bonpote-v2.pdf
Normal file
File diff suppressed because one or more lines are too long
3765
public/data/auteurs-pensees.json
Normal file
3765
public/data/auteurs-pensees.json
Normal file
File diff suppressed because it is too large
Load Diff
102
public/data/fmhy-curated.json
Normal file
102
public/data/fmhy-curated.json
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"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." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
90
public/data/outils.json
Normal file
90
public/data/outils.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
70
public/data/pfe-engages.json
Normal file
70
public/data/pfe-engages.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
public/icons/CREDITS.md
Normal file
12
public/icons/CREDITS.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 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`.
|
||||||
3
public/icons/outils-wrench.svg
Normal file
3
public/icons/outils-wrench.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 493 B |
162
server/api/chatbot-pensees.post.ts
Normal file
162
server/api/chatbot-pensees.post.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot-pensees
|
||||||
|
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
|
||||||
|
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
||||||
|
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (auteur, livre) à chaque assertion importante.
|
||||||
|
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
|
||||||
|
- Ton politique direct, pas de neutralité fade.
|
||||||
|
- Réponse en français, dense, sans délayage.
|
||||||
|
- Distingue les positions selon les écoles quand elles divergent.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
|
||||||
|
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
|
||||||
|
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (nom de projet, document) à chaque assertion importante.
|
||||||
|
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
|
||||||
|
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
|
||||||
|
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
|
||||||
|
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
|
||||||
|
|
||||||
|
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
|
||||||
|
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
|
||||||
|
- Pas d'hallucination, pas d'extrapolation hors corpus.
|
||||||
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
||||||
|
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
||||||
|
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (titre du livre) à chaque assertion.
|
||||||
|
- Pas d'hallucination. Si l'info n'est pas dans le corpus de cet auteur, dis-le.
|
||||||
|
- N'introduis JAMAIS d'autres auteurs sauf si ${nomAuteur} les commente explicitement.
|
||||||
|
- Ton politique direct, pas de neutralité fade.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuteurIngere { id: string; nom: string; ingere: boolean }
|
||||||
|
|
||||||
|
let auteursIngeresCache: AuteurIngere[] | null = null
|
||||||
|
|
||||||
|
function loadAuteursIngeres(): AuteurIngere[] {
|
||||||
|
if (auteursIngeresCache) return auteursIngeresCache
|
||||||
|
try {
|
||||||
|
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||||
|
const raw = readFileSync(jsonPath, 'utf-8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
const list = (data.auteurs ?? [])
|
||||||
|
.filter((a: any) => a.ingere === true)
|
||||||
|
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
|
||||||
|
auteursIngeresCache = list
|
||||||
|
return list
|
||||||
|
} catch {
|
||||||
|
auteursIngeresCache = []
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-pensees', 20)
|
||||||
|
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: string = body.query.trim()
|
||||||
|
const mode: string = body.mode || 'hybrid'
|
||||||
|
const corpus: string = body.corpus || 'both'
|
||||||
|
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
|
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
||||||
|
|
||||||
|
let nomAuteurMatch: string | null = null
|
||||||
|
if (auteurSlug) {
|
||||||
|
const ingeres = loadAuteursIngeres()
|
||||||
|
const auteur = ingeres.find(a => a.id === auteurSlug)
|
||||||
|
nomAuteurMatch = auteur?.nom ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
let systemPreface: string
|
||||||
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
|
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
|
||||||
|
} else if (corpus === 'pensees') {
|
||||||
|
systemPreface = SYSTEM_PREFACE_PENSEES
|
||||||
|
} else if (corpus === 'projets') {
|
||||||
|
systemPreface = SYSTEM_PREFACE_PROJETS
|
||||||
|
} else {
|
||||||
|
systemPreface = SYSTEM_PREFACE_BOTH
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
||||||
|
const ragBody: Record<string, any> = { query: ragQuery, mode }
|
||||||
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
|
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||||
|
ragBody.ll_keywords = [auteurSlug]
|
||||||
|
}
|
||||||
|
|
||||||
|
let ragResponse: any
|
||||||
|
try {
|
||||||
|
ragResponse = await $fetch(`${ragUrl}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: ragBody,
|
||||||
|
timeout: 90000,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.response?.status
|
||||||
|
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
||||||
|
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunksOnTarget = 0
|
||||||
|
let chunksOffTarget = 0
|
||||||
|
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
|
||||||
|
const slugPrefix = `${auteurSlug}__`
|
||||||
|
for (const ref of ragResponse.references) {
|
||||||
|
const fp = (ref.file_path ?? '').toLowerCase()
|
||||||
|
if (!fp) continue
|
||||||
|
if (fp.startsWith(slugPrefix)) chunksOnTarget++
|
||||||
|
else chunksOffTarget++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: ragResponse.response ?? '',
|
||||||
|
mode,
|
||||||
|
corpus,
|
||||||
|
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
||||||
|
auteur_unmatched: auteurSlug && !nomAuteurMatch ? auteurSlug : null,
|
||||||
|
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
||||||
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/submit
|
* POST /api/submit
|
||||||
*
|
*
|
||||||
* Soumission d'une nouvelle organisation par un utilisateur.
|
* Soumission multi-type (ecosysteme / reseau / job / outil).
|
||||||
* - Validation Zod côté serveur
|
* - Validation Zod côté serveur (champs communs + par type)
|
||||||
* - Rate limit Redis : 3 soumissions / IP / jour
|
* - Rate limit Redis : 3 soumissions / IP / jour
|
||||||
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
||||||
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
||||||
@@ -15,62 +15,49 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
// ── Schéma Zod ────────────────────────────────────────────────────────────────
|
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
||||||
|
|
||||||
const FONCTIONS = [
|
const FONCTIONS = [
|
||||||
'Juridique',
|
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||||
'Technique',
|
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
||||||
'Économique',
|
|
||||||
'Administratif',
|
|
||||||
'Chantier',
|
|
||||||
'Comptabilité',
|
|
||||||
'Développement',
|
|
||||||
'Formation',
|
|
||||||
'Gestion d\'agence',
|
|
||||||
'Santé mentale',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
|
||||||
const TERRITOIRES = [
|
const TERRITOIRES = [
|
||||||
'Métropole',
|
'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
|
||||||
'Guadeloupe',
|
|
||||||
'Martinique',
|
|
||||||
'Guyane',
|
|
||||||
'La Réunion',
|
|
||||||
'Mayotte',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||||
|
|
||||||
|
const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||||
|
|
||||||
|
const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||||
|
|
||||||
export const SubmitSchema = z.object({
|
export const SubmitSchema = z.object({
|
||||||
|
// Champs communs
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
url: z
|
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
.string()
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
.url('URL invalide')
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
.optional()
|
submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
|
||||||
.or(z.literal(''))
|
|
||||||
.transform((v) => v || undefined),
|
// Champs écosystème
|
||||||
description_user: z
|
echelle: z.enum(ECHELLES).optional(),
|
||||||
.string()
|
fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
|
||||||
.min(50, 'Minimum 50 caractères')
|
territoire: z.enum(TERRITOIRES).optional(),
|
||||||
.max(500, 'Maximum 500 caractères')
|
localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
|
||||||
.trim(),
|
|
||||||
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
|
// Champs réseau
|
||||||
fonctions: z
|
type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
|
||||||
.array(z.enum(FONCTIONS))
|
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||||
.min(1, 'Sélectionne au moins une fonction')
|
projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
|
||||||
.max(5, 'Maximum 5 fonctions'),
|
|
||||||
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
|
// Champs job
|
||||||
localisation_ville: z
|
type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
|
||||||
.string()
|
|
||||||
.max(100)
|
// Champs outil
|
||||||
.optional()
|
categorie: z.enum(CATEGORIES_OUTIL).optional(),
|
||||||
.transform((v) => v?.trim() || undefined),
|
|
||||||
submitted_by_email: z
|
|
||||||
.string()
|
|
||||||
.email('Email invalide')
|
|
||||||
.optional()
|
|
||||||
.or(z.literal(''))
|
|
||||||
.transform((v) => v || undefined),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type SubmitInput = z.infer<typeof SubmitSchema>
|
export type SubmitInput = z.infer<typeof SubmitSchema>
|
||||||
@@ -148,22 +135,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
const nocoBaseId = config.nocodbBase
|
const nocoBaseId = config.nocodbBase
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
|
submission_type: data.submission_type,
|
||||||
nom: data.nom,
|
nom: data.nom,
|
||||||
url: data.url || null,
|
url: data.url || null,
|
||||||
description_user: data.description_user,
|
description_user: data.description_user,
|
||||||
echelle: data.echelle,
|
|
||||||
tags_fonction: data.fonctions.join(','),
|
|
||||||
territoire: data.territoire,
|
|
||||||
localisation_ville: data.localisation_ville || null,
|
|
||||||
submitted_by_email: data.submitted_by_email || null,
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
moderation_status: 'pending',
|
moderation_status: 'pending',
|
||||||
scrape_status: data.url ? 'pending' : 'no_link',
|
|
||||||
ai_processed: false,
|
ai_processed: false,
|
||||||
submitted_at: new Date().toISOString(),
|
submitted_at: new Date().toISOString(),
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Champs spécifiques selon le type
|
||||||
|
if (data.submission_type === 'ecosysteme') {
|
||||||
|
payload.echelle = data.echelle
|
||||||
|
payload.tags_fonction = data.fonctions?.join(',')
|
||||||
|
payload.territoire = data.territoire
|
||||||
|
payload.localisation_ville = data.localisation_ville || null
|
||||||
|
payload.scrape_status = data.url ? 'pending' : 'no_link'
|
||||||
|
} else if (data.submission_type === 'reseau') {
|
||||||
|
payload.type_principal = data.type_principal
|
||||||
|
payload.projet_lien = data.projet_lien || null
|
||||||
|
payload.projet_adresse = data.projet_adresse || null
|
||||||
|
payload.localisation_ville = data.localisation_ville || null
|
||||||
|
} else if (data.submission_type === 'job') {
|
||||||
|
payload.type_plateforme = data.type_plateforme
|
||||||
|
} else if (data.submission_type === 'outil') {
|
||||||
|
payload.categorie = data.categorie
|
||||||
|
}
|
||||||
|
|
||||||
// NocoDB v1 endpoint
|
// NocoDB v1 endpoint
|
||||||
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
||||||
|
|
||||||
|
|||||||
74
server/api/submit/reference.post.ts
Normal file
74
server/api/submit/reference.post.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
|
const ReferenceSchema = z.object({
|
||||||
|
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
|
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||||
|
pertinence_rag: z.string().max(500, 'Maximum 500 caractères').optional().transform(v => v?.trim() || undefined),
|
||||||
|
hashtags: z.string().max(300, 'Maximum 300 caractères').optional().transform(v => v?.trim() || undefined),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ReferenceInput = z.infer<typeof ReferenceSchema>
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||||
|
|
||||||
|
const allowed = await checkRateLimit(ip, 'submit-reference', 3)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: 'Trop de soumissions. Réessaie demain.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = ReferenceSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation échouée',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
titre: data.titre,
|
||||||
|
auteur: data.auteur,
|
||||||
|
url: data.url || null,
|
||||||
|
description: data.description,
|
||||||
|
pertinence_rag: data.pertinence_rag || null,
|
||||||
|
hashtags: data.hashtags ? data.hashtags.split(',').map(h => h.trim()).filter(Boolean).join(',') : null,
|
||||||
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
|
moderation_status: 'pending',
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
|
||||||
|
|
||||||
|
let insertedRecord: any
|
||||||
|
try {
|
||||||
|
insertedRecord = await $fetch(insertUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[reference] Erreur NocoDB insert:', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
referenceId,
|
||||||
|
message: 'Ta référence est en attente de modération.',
|
||||||
|
}
|
||||||
|
})
|
||||||
10
server/routes/data/auteurs-pensees.json.get.ts
Normal file
10
server/routes/data/auteurs-pensees.json.get.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user