13 Commits

Author SHA1 Message Date
Jules Neny
03127b1648 feat(nav): Réseaux AEP V2 + onglets Métropole/Outremer Carte1 + reorder nav
- pages/agences.vue : carte V2 complète restaurée (517L, 120 structures)
- pages/index.vue : onglets Métropole/Outre-mer + desktopMapView + chatbot outremer
- app.vue : ordre nav → Entraide / Réseaux AEP / Jobs / Codev / RAG (en construction)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:40:22 +02:00
Jules Neny
0099627da4 feat(codev): merge feat/codev-mvp - app entraide co-developpement 2026-05-07 00:33:47 +02:00
Jules Neny
f9960bf8ea feat(taff): onglet 'Jobs' dans la nav → /trouver-du-taf
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:33:16 +02:00
Jules Neny
05bbcc2a02 fix(nav): Réseaux AEP + Leaflet CSS global + double rAF NavMap + chips V2
- app.vue : "Agences Inspirantes" → "Réseaux AEP" (desktop + mobile)
- nuxt.config.ts : Leaflet/MarkerCluster CSS global + Vite cacheDir AppData
- NavMap.vue : double requestAnimationFrame avant initMap (même fix NavMapV2)
- NavSidebar.vue : tags → style chip rounded-full comme V2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:32:50 +02:00
Jules Neny
f518318d60 feat(codev): demo - tabs Carto/Annuaire + Solution+Alliance sans Surprise 2026-05-07 00:29:51 +02:00
Jules Neny
0598536244 fix(codev): réajouter bouton Solution dans carto (Solution + Alliance) 2026-05-07 00:29:03 +02:00
Jules Neny
b951fe0b8d fix(codev): sticky col-nom fond opaque + ombre separation mobile 2026-05-07 00:23:39 +02:00
Jules Neny
c8311ce1fb feat(codev): retire Surprise + QR public + mode admin suppr fiches
- carto.vue : retire bouton Surprise (Alliance seul reste), ajoute isAdmin + deleteFiche + colonne supprimer annuaire
- middleware : /codev/qr exempté d'authentification
- auth.post.ts : détecte mdp admin → pose cookie codev_admin
- DELETE /api/codev/fiches/[id] : vérifie cookie admin avant suppression NocoDB
- GET /api/codev/me : retourne { admin, session }
- nuxt.config.ts : codevAdminPassword ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 00:22:44 +02:00
Jules Neny
142e5cf787 feat(codev): skip fiche + annuaire table sticky + page QR code 2026-05-07 00:04:42 +02:00
Jules Neny
606b9f0a47 feat(codev): tabs Besoins/Competences + retour fiche + panel mobile bottom sheet 2026-05-06 21:29:07 +02:00
Jules Neny
6f7d2450de fix(codev): algo Solution tokenize direct + seuils releves + fiches demo enrichies 2026-05-06 21:28:27 +02:00
Jules Neny
e7c7d302ea fix(codev): boundaries D3 + matching rebuildLinks + couleurs + bulles toggle + FAB + 2026-05-06 17:49:56 +02:00
Jules Neny
4ed0a87106 feat(codev): onglet Codev dans nav desktop + menu mobile 2026-05-06 17:49:27 +02:00
18 changed files with 1456 additions and 293 deletions

20
app.vue
View File

@@ -39,8 +39,21 @@
class="nav-tab" class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/agences' }" :class="{ 'nav-tab--active': route.path === '/agences' }"
> >
Agences Inspirantes Réseaux AEP
<span class="nav-tab-badge">en construction</span> </NuxtLink>
<NuxtLink
to="/trouver-du-taf"
class="nav-tab"
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
>
Jobs
</NuxtLink>
<NuxtLink
to="/codev"
class="nav-tab"
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
>
Codev
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/rag" to="/rag"
@@ -165,8 +178,9 @@
@click="hamburgerOpen = false" @click="hamburgerOpen = false"
> >
<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="color: var(--nav-text);">Agences Inspirantes</NuxtLink> <NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Réseaux AEP</NuxtLink>
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink> <NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div> <div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink> <NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink> <NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>

View File

@@ -221,7 +221,12 @@ function updateTileTheme(dark: boolean) {
let themeObserver: MutationObserver | null = null let themeObserver: MutationObserver | null = null
onMounted(() => { onMounted(() => {
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
requestAnimationFrame(() => {
requestAnimationFrame(() => {
initMap() initMap()
})
})
// Observer les changements de classe dark sur <html> // Observer les changements de classe dark sur <html>
themeObserver = new MutationObserver(() => { themeObserver = new MutationObserver(() => {

View File

@@ -125,8 +125,8 @@
<span <span
v-for="fn in orgFonctions(org)" v-for="fn in orgFonctions(org)"
:key="fn" :key="fn"
class="px-1.5 py-0.5 rounded text-xs" class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);" style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
>{{ fn }}</span> >{{ fn }}</span>
</div> </div>
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);"> <div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">

View File

@@ -37,9 +37,11 @@ const props = withDefaults(defineProps<{
fiches: CodevFiche[] fiches: CodevFiche[]
matches?: CodevMatch[] matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise' mode?: 'none' | 'solution' | 'alliance' | 'surprise'
showLabels?: boolean
}>(), { }>(), {
matches: () => [], matches: () => [],
mode: 'none', mode: 'none',
showLabels: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -146,18 +148,15 @@ function rebuildLinks() {
currentLinks = buildLinks(currentNodes) currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return if (!gLinks || !simulation) return
const linkSel = gLinks // .join() moderne D3 pour garantir le re-rendu complet
gLinks
.selectAll<SVGLineElement, SimLink>('line') .selectAll<SVGLineElement, SimLink>('line')
.data(currentLinks, (d: SimLink) => { .data(currentLinks)
const s = d.source as SimNode .join(
const t = d.target as SimNode enter => enter.append('line'),
return `${s.id}-${t.id}-${d.mode}` update => update,
}) exit => exit.remove()
)
linkSel.exit().remove()
linkSel.enter()
.append('line')
.attr('stroke', d => linkColor(d.mode)) .attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3) .attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7) .attr('stroke-opacity', 0.7)
@@ -223,12 +222,12 @@ function render() {
.attr('stroke', '#fff') .attr('stroke', '#fff')
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
// Pastille besoin (bas-droite, orange) // Pastille besoin (bas-droite, bleu)
nodeGroups.append('circle') nodeGroups.append('circle')
.attr('r', 6) .attr('r', 6)
.attr('cx', r * 0.65) .attr('cx', r * 0.65)
.attr('cy', r * 0.65) .attr('cy', r * 0.65)
.attr('fill', '#f97316') .attr('fill', '#3b82f6')
.attr('stroke', '#fff') .attr('stroke', '#fff')
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
@@ -236,6 +235,57 @@ function render() {
nodeGroups.append('title') nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`) .text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
// Groupe label bulle (affiche si showLabels)
const labelGroups = nodeGroups.append('g')
.attr('class', 'label-bubble')
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
// Fond bulle besoin (dessous du noeud)
labelGroups.append('rect')
.attr('class', 'bubble-besoin-bg')
.attr('x', -(r + 50))
.attr('y', r + 4)
.attr('width', 100)
.attr('height', 28)
.attr('rx', 6)
.attr('fill', '#eff6ff')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 1)
// Texte besoin
labelGroups.append('text')
.attr('class', 'bubble-besoin-txt')
.attr('x', -(r) + 50)
.attr('y', r + 22)
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#1e40af')
.attr('pointer-events', 'none')
.text(d => truncate(d.besoin, 18))
// Fond bulle offre (dessus du noeud)
labelGroups.append('rect')
.attr('class', 'bubble-offre-bg')
.attr('x', -(r + 50))
.attr('y', -(r + 32))
.attr('width', 100)
.attr('height', 28)
.attr('rx', 6)
.attr('fill', '#f0fdf4')
.attr('stroke', '#22c55e')
.attr('stroke-width', 1)
// Texte offre
labelGroups.append('text')
.attr('class', 'bubble-offre-txt')
.attr('x', -(r) + 50)
.attr('y', -(r + 14))
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#166534')
.attr('pointer-events', 'none')
.text(d => truncate(d.offre, 18))
// Simulation // Simulation
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes) simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks) .force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
@@ -245,6 +295,8 @@ function render() {
.force('charge', d3.forceManyBody<SimNode>().strength(-400)) .force('charge', d3.forceManyBody<SimNode>().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2)) .force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide<SimNode>().radius(r + 12)) .force('collide', d3.forceCollide<SimNode>().radius(r + 12))
.force('x', d3.forceX(width.value / 2).strength(0.05))
.force('y', d3.forceY(height.value / 2).strength(0.05))
.alphaDecay(0.02) .alphaDecay(0.02)
.on('tick', tick) .on('tick', tick)
@@ -254,16 +306,21 @@ function render() {
} }
function tick() { function tick() {
const r = nodeRadius.value
if (!gLinks || !gNodes) return if (!gLinks || !gNodes) return
gLinks.selectAll<SVGLineElement, SimLink>('line') gLinks.selectAll<SVGLineElement, SimLink>('line')
.attr('x1', d => (d.source as SimNode).x ?? 0) .attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
.attr('y1', d => (d.source as SimNode).y ?? 0) .attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
.attr('x2', d => (d.target as SimNode).x ?? 0) .attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
.attr('y2', d => (d.target as SimNode).y ?? 0) .attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
gNodes.selectAll<SVGGElement, SimNode>('g.node') gNodes.selectAll<SVGGElement, SimNode>('g.node')
.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`) .attr('transform', d => {
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
return `translate(${x},${y})`
})
} }
// ── Watch matches/mode (hook pour M4) ───────────────────────────────────── // ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
@@ -271,13 +328,21 @@ function tick() {
watch(() => [props.matches, props.mode] as const, () => { watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return if (!simulation) return
rebuildLinks() rebuildLinks()
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks) const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
.id(d => d.id) .id(d => String(d.id))
.distance(120) .distance(120)
.strength(0.3)) .strength(0.5)
simulation.alpha(0.5).restart() simulation.force('link', newForce)
simulation.alpha(0.8).restart()
}, { deep: true }) }, { deep: true })
// ── Watch showLabels ──────────────────────────────────────────────────────
watch(() => props.showLabels, (val) => {
if (!svgEl.value) return
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
})
// ── Watch fiches (re-render si nouvelles fiches) ─────────────────────────── // ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => { watch(() => props.fiches, () => {

View File

@@ -1,6 +1,11 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxtjs/tailwindcss'],
css: ['~/assets/css/main.css'], css: [
'~/assets/css/main.css',
'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css',
'leaflet.markercluster/dist/MarkerCluster.Default.css',
],
runtimeConfig: { runtimeConfig: {
nocodbUrl: process.env.NOCODB_URL, nocodbUrl: process.env.NOCODB_URL,
@@ -17,16 +22,17 @@ export default defineNuxtConfig({
codevTableId: '', // NUXT_CODEV_TABLE_ID codevTableId: '', // NUXT_CODEV_TABLE_ID
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
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
ssr: true, ssr: true,
vite: { vite: {
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
optimizeDeps: { optimizeDeps: {
include: ['leaflet', 'leaflet.markercluster'], include: ['leaflet', 'leaflet.markercluster', 'd3'],
}, },
// Éviter l'import SSR de Leaflet qui utilise window
ssr: { ssr: {
noExternal: [], noExternal: [],
}, },

View File

@@ -1,39 +1,517 @@
<template> <template>
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);"> <div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<!-- SIDEBAR DESKTOP (>= 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
<!-- Filtres familles + hashtags -->
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
<!-- Separateur -->
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- Barre de recherche -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="sidebar-search-label" aria-label="Rechercher une structure">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher une structure..."
class="sidebar-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- Header compteur + reset -->
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>Effacer les filtres</button>
</div>
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
<div class="px-3 py-2 space-y-1.5">
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement...
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<div <div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5" v-for="structure in filtered"
style="background: var(--nav-bg-alt);" :key="structure.id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="onSelectStructure(structure.id)"
@mouseenter="hoveredId = structure.id"
@mouseleave="hoveredId = null"
> >
<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);"> <div class="flex items-start justify-between gap-1.5">
<rect x="3" y="3" width="7" height="7"/> <span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<rect x="14" y="3" width="7" height="7"/> <span
<rect x="14" y="14" width="7" height="7"/> class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
<rect x="3" y="14" width="7" height="7"/> :style="`background: ${familleColor(structure.famille_principale)};`"
</svg> />
</div> </div>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1> <div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);"> <div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété. <span
</p> v-for="tag in structure.hashtags.slice(0, 2)"
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;"> :key="tag"
Bientôt disponible class="text-xs"
</p> style="color: var(--nav-text-muted);"
<NuxtLink >{{ tag }}</span>
to="/" </div>
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80" </div>
style="background: var(--nav-primary); color: var(--nav-text-on-primary);" </div>
</div>
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
>Vue graphique</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMapV2
ref="navMapRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"> Chargement de la carte…
<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>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Carte Outre-mer desktop -->
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Vue graphique desktop -->
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
<div class="flex-1 overflow-hidden relative">
<ClientOnly>
<GraphView
:data="bifurcationData"
:allHashtags="allHashtags"
:active="desktopMapView === 'graphe'"
@select-structure="onSelectStructure"
/>
<template #fallback>
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
Chargement du graphe...
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder
@highlightOrgs="() => {}"
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte mobile Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMapV2
ref="navMapMobileRef"
:structures="metropoleStructures"
:selectedId="selectedId"
@select-structure="onSelectStructureMobile"
/>
<template #fallback>
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
Chargement de la carte…
</div>
</template>
</ClientOnly>
</div>
<!-- Carte mobile Outre-mer -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgsLegacy"
:selectedId="selectedIdLegacyNum"
@select-org="() => {}"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement…
</div>
</template>
</ClientOnly>
</div>
<!-- Bottom sheet swipable -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
</p>
</div>
<!-- Filtres hashtags mobile -->
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<HashtagFilter
:allHashtags="allHashtags"
:selectedHashtags="selectedHashtags"
:selectedFamille="selectedFamille"
@update:selectedHashtags="selectedHashtags = $event"
@update:selectedFamille="selectedFamille = $event"
/>
</div>
<!-- Barre recherche mobile -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une structure">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="search"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
/>
<button
v-if="search"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="search = ''"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-1 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
>Effacer les filtres</button>
</div>
<!-- Liste fiches mobile -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="structure in filtered"
:key="structure.id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === structure.id
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectStructureMobile(structure.id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
<span
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
:style="`background: ${familleColor(structure.famille_principale)};`"
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- MODAL FICHE V2 (desktop) -->
<FicheModalV2
v-model="ficheModalOpen"
:structureId="ficheModalId"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chatbot</span>
</button>
<!-- CHATBOT BOTTOM SHEET (mobile) -->
<ChatbotSheet
:modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="() => {}"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' }) import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
// ── Couleurs familles ──────────────────────────────────────────────────────
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
6: '#6b3fa0',
}
function familleColor(f: number): string {
return FAMILLE_COLORS[f] ?? '#888'
}
// ── État UI ────────────────────────────────────────────────────────────────
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
// Filtres
const search = ref('')
const selectedFamille = ref<number | null>(null)
const selectedHashtags = ref<string[]>([])
// Refs cartes
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// ── Données V2 - JSON statique ─────────────────────────────────────────────
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
const pending = ref(true)
onMounted(async () => {
try {
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
} catch (e) {
console.error('Erreur chargement reseaux-bifurcation.json', e)
} finally {
pending.value = false
}
})
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
// Tous les hashtags uniques triés
const allHashtags = computed<string[]>(() => {
const set = new Set<string>()
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
return Array.from(set).sort()
})
// ── Filtrage ───────────────────────────────────────────────────────────────
const filtered = computed<StructureV2[]>(() => {
let result = structures.value
// Filtre texte
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
s =>
s.nom.toLowerCase().includes(q) ||
s.ville.toLowerCase().includes(q) ||
s.description_courte.toLowerCase().includes(q) ||
s.hashtags.some(h => h.toLowerCase().includes(q))
)
}
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
if (selectedFamille.value !== null) {
if (selectedFamille.value === 6) {
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
} else {
result = result.filter(
s => s.famille_principale === selectedFamille.value ||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
)
}
}
// Filtre hashtags (AND logique si plusieurs)
if (selectedHashtags.value.length) {
result = result.filter(
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
)
}
return result
})
const hasActiveFilters = computed(
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
)
function resetFilters() {
search.value = ''
selectedFamille.value = null
selectedHashtags.value = []
}
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
const selectedIdLegacyNum = computed(() => null)
// ── Sélection ─────────────────────────────────────────────────────────────
function onSelectStructure(id: string) {
selectedId.value = selectedId.value === id ? null : id
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
function onSelectStructureMobile(id: string) {
selectedId.value = id
ficheModalId.value = id
ficheModalOpen.value = true
}
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
</script> </script>

View File

@@ -9,13 +9,31 @@
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail {{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
</template> </template>
</p> </p>
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
</header> </header>
<div class="codev-tabs">
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
</div>
<div v-if="tab === 'carto'">
<div class="show-labels-bar">
<button
type="button"
:class="{ active: showLabels }"
@click="showLabels = !showLabels"
>
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
</button>
</div>
<ClientOnly> <ClientOnly>
<CodevGraph <CodevGraph
:fiches="fiches" :fiches="fiches"
:matches="matches" :matches="matches"
:mode="mode" :mode="mode"
:show-labels="showLabels"
@select-fiche="onSelectFiche" @select-fiche="onSelectFiche"
/> />
<template #fallback> <template #fallback>
@@ -41,7 +59,7 @@
type="button" type="button"
> >
Solution Solution
<span class="hint">besoin - offre</span> <span class="hint">besoin - compétence</span>
</button> </button>
<button <button
:class="{ active: mode === 'alliance' }" :class="{ active: mode === 'alliance' }"
@@ -50,16 +68,7 @@
type="button" type="button"
> >
Alliance Alliance
<span class="hint">besoins partages</span> <span class="hint">besoins partagés</span>
</button>
<button
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button> </button>
<button <button
v-if="mode !== 'none'" v-if="mode !== 'none'"
@@ -70,6 +79,66 @@
Effacer Effacer
</button> </button>
</div> </div>
</div>
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
<div v-if="fiches.length === 0" class="list-empty">
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
</div>
<div v-else class="annuaire-scroll">
<table class="annuaire-table">
<thead>
<tr>
<th class="col-nom">Prénom</th>
<th class="col-besoin">Besoin</th>
<th class="col-offre">Ce que j'offre</th>
<th v-if="isAdmin" class="col-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
<td class="col-nom">{{ f.nom }}</td>
<td class="col-besoin">{{ f.besoin }}</td>
<td class="col-offre">{{ f.offre }}</td>
<td v-if="isAdmin" class="col-actions">
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
</td>
</tr>
</tbody>
</table>
</div>
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
</div>
<!-- FAB ajouter une fiche -->
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
+
</NuxtLink>
<Transition name="sheet">
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
<div class="sheet-content">
<div class="sheet-handle"></div>
<div class="sheet-name">{{ selectedFiche.nom }}</div>
<div class="sheet-section">
<span class="sheet-label">Besoin</span>
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
</div>
<div class="sheet-section">
<span class="sheet-label">Ce que j'apporte</span>
<p class="sheet-text">{{ selectedFiche.offre }}</p>
</div>
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
</div>
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
</div>
</div>
</Transition>
</div> </div>
</template> </template>
@@ -80,11 +149,24 @@ import { computeMatches } from '~/utils/codev/matching'
useHead({ title: 'Carto - Co-developpement' }) useHead({ title: 'Carto - Co-developpement' })
const { data, pending } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches') const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
const fiches = computed(() => data.value?.list ?? []) const fiches = computed(() => data.value?.list ?? [])
const matches = ref<CodevMatch[]>([]) const matches = ref<CodevMatch[]>([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none') const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const showLabels = ref(false)
const tab = ref<'carto' | 'annuaire'>('carto')
const selectedFiche = ref<CodevFiche | null>(null)
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
const isAdmin = ref(false)
onMounted(async () => {
try {
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
isAdmin.value = r.admin
} catch { isAdmin.value = false }
})
const MODE_LABELS: Record<string, string> = { const MODE_LABELS: Record<string, string> = {
solution: 'Solution', solution: 'Solution',
@@ -102,7 +184,17 @@ function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
} }
function onSelectFiche(id: number) { function onSelectFiche(id: number) {
if (isMobileView) {
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
} else {
navigateTo(`/codev/fiche?id=${id}`) navigateTo(`/codev/fiche?id=${id}`)
}
}
async function deleteFiche(id: number) {
if (!confirm('Supprimer la fiche ?')) return
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
await refresh()
} }
</script> </script>
@@ -256,6 +348,194 @@ function onSelectFiche(id: number) {
} }
} }
/* ── Toggle besoins/offres ── */
.show-labels-bar {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.show-labels-bar button {
border: 1px solid #d0d4dc;
border-radius: 8px;
padding: 8px 16px;
background: white;
font-size: 13px;
cursor: pointer;
color: #374151;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.show-labels-bar button.active {
background: #1B4436;
color: white;
border-color: transparent;
}
/* ── FAB ajouter ── */
.fab-add {
position: fixed;
bottom: 80px;
right: 16px;
width: 48px;
height: 48px;
border-radius: 50%;
background: #1B4436;
color: white;
font-size: 28px;
font-weight: 300;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
z-index: 100;
transition: transform 0.15s, opacity 0.15s;
line-height: 1;
}
.fab-add:hover {
transform: scale(1.08);
opacity: 0.92;
}
/* ── Tabs ── */
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
/* ── List view ── */
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
/* ── Bottom sheet ── */
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
/* ── QR link ── */
.qr-link {
font-size: 0.75rem;
color: #9ca3af;
text-decoration: none;
align-self: flex-end;
}
.qr-link:hover { color: #6b7280; }
/* ── Annuaire ── */
.annuaire-wrap {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.annuaire-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.annuaire-table {
width: 100%;
border-collapse: collapse;
min-width: 480px;
}
.annuaire-table thead tr {
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
.annuaire-table th {
padding: 10px 14px;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
white-space: nowrap;
}
.annuaire-table td {
padding: 12px 14px;
font-size: 0.875rem;
color: #374151;
vertical-align: top;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
}
.annuaire-row {
cursor: pointer;
transition: background 0.12s;
}
.annuaire-row:hover { background: #f9fafb; }
.annuaire-row:last-child td { border-bottom: none; }
.col-nom {
position: sticky;
left: 0;
z-index: 2;
background: #ffffff;
font-weight: 600;
color: #1a1a2e !important;
white-space: nowrap;
min-width: 80px;
border-right: 2px solid #e5e7eb;
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
}
.annuaire-row:hover .col-nom { background: #f9fafb; }
thead tr .col-nom { background: #f9fafb; z-index: 3; }
.col-besoin { min-width: 200px; max-width: 260px; }
.col-offre { min-width: 200px; max-width: 260px; }
.annuaire-hint {
font-size: 0.75rem;
color: #9ca3af;
text-align: center;
margin: 0;
}
.col-actions { width: 40px; text-align: center; }
.delete-btn {
background: transparent;
border: none;
cursor: pointer;
color: #ef4444;
font-size: 1rem;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.1s;
}
.delete-btn:hover { background: #fef2f2; }
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 600px) { @media (max-width: 600px) {

View File

@@ -7,6 +7,12 @@
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p> <p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
</header> </header>
<div class="codev-tabs">
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
</div>
<div v-if="tab === 'carto'">
<ClientOnly> <ClientOnly>
<CodevGraph <CodevGraph
:fiches="fiches" :fiches="fiches"
@@ -27,7 +33,7 @@
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button> <button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
</div> </div>
<!-- 3 boutons matching identiques a carto.vue --> <!-- Boutons matching -->
<div class="matching-controls"> <div class="matching-controls">
<button <button
:class="{ active: mode === 'solution' }" :class="{ active: mode === 'solution' }"
@@ -47,15 +53,6 @@
Alliance Alliance
<span class="hint">besoins partages</span> <span class="hint">besoins partages</span>
</button> </button>
<button
:class="{ active: mode === 'surprise' }"
style="--mode-color: #3b82f6"
@click="setMode('surprise')"
type="button"
>
Surprise
<span class="hint">offres partagees</span>
</button>
<button <button
v-if="mode !== 'none'" v-if="mode !== 'none'"
class="reset" class="reset"
@@ -65,6 +62,28 @@
Effacer Effacer
</button> </button>
</div> </div>
</div>
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
<div class="annuaire-scroll">
<table class="annuaire-table">
<thead>
<tr>
<th class="col-nom">Prénom</th>
<th class="col-besoin">Besoin</th>
<th class="col-offre">Ce que j'offre</th>
</tr>
</thead>
<tbody>
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
<td class="col-nom">{{ f.nom }}</td>
<td class="col-besoin">{{ f.besoin }}</td>
<td class="col-offre">{{ f.offre }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</template> </template>
@@ -73,98 +92,95 @@
import type { CodevFiche, CodevMatch } from '~/types/codev' import type { CodevFiche, CodevMatch } from '~/types/codev'
import { computeMatches } from '~/utils/codev/matching' import { computeMatches } from '~/utils/codev/matching'
// 10 fiches factices - hashtags alignes pour demontrer les 3 modes : const tab = ref<'carto' | 'annuaire'>('carto')
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
// //
// Solution : Lea(besoin coaching) -> Maya(offre coaching) // Solution (scoreDirect besoinA vs offreB) :
// Sami(besoin formation+vente) -> Ines(offre vente+formation) // Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu) // Nael(besoin site web formation) -> Sami(offre developpement web) ✓
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
// //
// Alliance : Lea + Maya (hashtag coaching commun dans besoins) // Alliance (besoins similaires) :
// Sami + Kenji (hashtag formation+vente dans besoins) // Lea + Maya (coaching, lancer, offre) ✓
// Tom + Zoe (hashtag tiers-lieu dans besoins) // Tom + Zoe (tiers-lieu, co-creer) ✓
// Sami + Kenji (vendre, formations) ✓
// //
// Surprise : Lea + Zoe (hashtag facilitation dans offres) // Surprise (offres similaires) :
// Tom + Roman (hashtag archi dans offres) // Lea + Zoe (facilitation, groupes)
// Tom + Roman (architecture) ✓
// Ines + Nael (marketing, formations) ✓
const FICHES_DEMO: CodevFiche[] = [ const FICHES_DEMO: CodevFiche[] = [
{ {
id: 1, id: 1, nom: 'Lea',
nom: 'Lea', besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
besoin: 'Structurer mon offre de coaching pour la lancer en septembre', offre: 'Facilitation de groupes et animation de cercles de parole',
offre: 'Animation de groupes, facilitation de cercles de parole', hashtags: [],
hashtags: ['coaching', 'facilitation'],
created_at: '2026-05-08T10:00:00Z', created_at: '2026-05-08T10:00:00Z',
}, },
{ {
id: 2, id: 2, nom: 'Sami',
nom: 'Sami', besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
besoin: 'Comprendre comment vendre une formation en ligne', offre: 'Developpement web sur mesure, creation de sites et applications',
offre: 'Developpement web, sites Astro et Nuxt', hashtags: [],
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:01:00Z', created_at: '2026-05-08T10:01:00Z',
}, },
{ {
id: 3, id: 3, nom: 'Ines',
nom: 'Ines', besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
besoin: 'Aide pour la facilitation de mes ateliers ecriture', offre: 'Vente de formations en ligne et marketing pour formateurs',
offre: 'Vente de formations en ligne, marketing direct', hashtags: [],
hashtags: ['vente', 'formation'],
created_at: '2026-05-08T10:02:00Z', created_at: '2026-05-08T10:02:00Z',
}, },
{ {
id: 4, id: 4, nom: 'Tom',
nom: 'Tom', besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
besoin: 'Trouver un associe pour un projet de tiers-lieu', offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
offre: 'Architecture eco-responsable, conception bioclimatique', hashtags: [],
hashtags: ['tiers-lieu', 'archi'],
created_at: '2026-05-08T10:03:00Z', created_at: '2026-05-08T10:03:00Z',
}, },
{ {
id: 5, id: 5, nom: 'Maya',
nom: 'Maya', besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
besoin: 'Structurer mon offre de coaching freelance', offre: 'Accompagnement coaching de carriere et transitions professionnelles',
offre: 'Coaching de carriere, accompagnement transition pro', hashtags: [],
hashtags: ['coaching', 'carriere'],
created_at: '2026-05-08T10:04:00Z', created_at: '2026-05-08T10:04:00Z',
}, },
{ {
id: 6, id: 6, nom: 'Kenji',
nom: 'Kenji', besoin: 'Apprendre a vendre mes formations sans pression commerciale',
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur', offre: 'Photographie professionnelle et direction artistique editoriale',
offre: 'Photographie, direction artistique de projets editoriaux', hashtags: [],
hashtags: ['formation', 'vente'],
created_at: '2026-05-08T10:05:00Z', created_at: '2026-05-08T10:05:00Z',
}, },
{ {
id: 7, id: 7, nom: 'Zoe',
nom: 'Zoe', besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural', offre: 'Facilitation de collectifs et animation en intelligence collective',
offre: 'Animation et facilitation de collectifs, intelligence collective', hashtags: [],
hashtags: ['tiers-lieu', 'facilitation'],
created_at: '2026-05-08T10:06:00Z', created_at: '2026-05-08T10:06:00Z',
}, },
{ {
id: 8, id: 8, nom: 'Nael',
nom: 'Nael', besoin: 'Creer un site web pour presenter et vendre ma formation',
besoin: 'Construire un site web pour ma formation', offre: 'Strategie marketing digital et lancement de produits en ligne',
offre: 'Strategie marketing, lancement de produits digitaux', hashtags: [],
hashtags: ['web', 'strategie'],
created_at: '2026-05-08T10:07:00Z', created_at: '2026-05-08T10:07:00Z',
}, },
{ {
id: 9, id: 9, nom: 'Eva',
nom: 'Eva', besoin: 'Lancer mon coaching avec une page de vente qui convertit',
besoin: 'Lancer mon offre de coaching avec une page de vente', offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
offre: 'Ecriture longue forme, articles essais et tribunes', hashtags: [],
hashtags: ['coaching', 'ecriture'],
created_at: '2026-05-08T10:08:00Z', created_at: '2026-05-08T10:08:00Z',
}, },
{ {
id: 10, id: 10, nom: 'Roman',
nom: 'Roman', besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
besoin: 'Ameliorer mes articles de blog sur la renovation', offre: 'Architecture technique et plans pour renovation energetique',
offre: 'Architecture, plans techniques pour renovation energetique', hashtags: [],
hashtags: ['archi', 'reno'],
created_at: '2026-05-08T10:09:00Z', created_at: '2026-05-08T10:09:00Z',
}, },
] ]
@@ -186,7 +202,7 @@ function setMode(newMode: typeof mode.value) {
if (newMode === 'none') { if (newMode === 'none') {
matches.value = [] matches.value = []
} else { } else {
matches.value = computeMatches(fiches.value, newMode) matches.value = computeMatches(fiches.value, newMode, 0.12)
} }
} }
</script> </script>

View File

@@ -4,7 +4,8 @@
<!-- En-tête --> <!-- En-tête -->
<div class="fiche-header"> <div class="fiche-header">
<h1>Ma fiche</h1> <NuxtLink to="/codev/carto" class="back-link"> Retour à la carte</NuxtLink>
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p> <p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
</div> </div>
@@ -105,9 +106,13 @@
<!-- Bouton --> <!-- Bouton -->
<button type="submit" class="submit-btn" :disabled="loading"> <button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }} {{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
</button> </button>
<NuxtLink to="/codev/carto" class="skip-link">
Voir la carte sans créer de fiche →
</NuxtLink>
</form> </form>
</div> </div>
@@ -115,12 +120,29 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute()
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
const isEdit = computed(() => editId.value !== null)
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' }) const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const activeTip = ref<'besoin' | 'offre' | null>(null) const activeTip = ref<'besoin' | 'offre' | null>(null)
useHead({ title: 'Ma fiche — Co-développement' }) useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
onMounted(async () => {
if (!isEdit.value) return
try {
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
form.value.nom = fiche.nom
form.value.besoin = fiche.besoin
form.value.offre = fiche.offre
form.value.hashtagsRaw = fiche.hashtags.join(', ')
} catch {
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
}
})
function toggleTip(field: 'besoin' | 'offre') { function toggleTip(field: 'besoin' | 'offre') {
activeTip.value = activeTip.value === field ? null : field activeTip.value = activeTip.value === field ? null : field
@@ -136,15 +158,18 @@ async function submit() {
.filter(Boolean) .filter(Boolean)
.slice(0, 3) .slice(0, 3)
await $fetch('/api/codev/fiches', { const payload = {
method: 'POST',
body: {
nom: form.value.nom, nom: form.value.nom,
besoin: form.value.besoin, besoin: form.value.besoin,
offre: form.value.offre, offre: form.value.offre,
hashtags, hashtags,
}, }
})
if (isEdit.value) {
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
} else {
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
}
await navigateTo('/codev/carto') await navigateTo('/codev/carto')
} catch (e: any) { } catch (e: any) {
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie' error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
@@ -173,6 +198,18 @@ async function submit() {
/* ── En-tête ── */ /* ── En-tête ── */
.back-link {
display: inline-block;
font-size: 0.875rem;
color: var(--nav-text-muted, #6b7280);
text-decoration: none;
margin-bottom: 0.75rem;
}
.back-link:hover {
color: var(--nav-primary-solid, #1B4436);
}
.fiche-header h1 { .fiche-header h1 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
@@ -357,6 +394,17 @@ async function submit() {
cursor: not-allowed; cursor: not-allowed;
} }
.skip-link {
display: block;
text-align: center;
font-size: 0.825rem;
color: var(--nav-text-muted, #9ca3af);
text-decoration: none;
margin-top: 0.5rem;
padding: 0.5rem;
}
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 480px) { @media (max-width: 480px) {

94
pages/codev/qr.vue Normal file
View File

@@ -0,0 +1,94 @@
<template>
<div class="qr-page">
<div class="qr-card">
<h1>Co-développement</h1>
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
alt="QR code aep.trans-former.fr/codev"
class="qr-img"
width="280"
height="280"
/>
<p class="qr-url">{{ APP_URL }}</p>
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
download="codev-qr.png"
class="qr-download"
target="_blank"
>
Télécharger le QR code
</a>
</div>
</div>
</template>
<script setup lang="ts">
const APP_URL = 'https://aep.trans-former.fr/codev'
useHead({ title: 'QR Code — Co-développement' })
</script>
<style scoped>
.qr-page {
min-height: 100vh;
background: var(--nav-bg, #fafafa);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.qr-card {
background: white;
border-radius: 16px;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
text-align: center;
}
.qr-card h1 {
font-size: 1.25rem;
font-weight: 700;
color: #1a1a2e;
margin: 0;
}
.qr-subtitle {
font-size: 0.9rem;
color: #6b7280;
margin: 0;
}
.qr-img {
border-radius: 8px;
border: 2px solid #e5e7eb;
}
.qr-url {
font-size: 0.8rem;
color: #9ca3af;
margin: 0;
font-family: monospace;
}
.qr-password {
font-size: 0.95rem;
color: #374151;
margin: 0;
}
.qr-download {
display: inline-block;
padding: 10px 20px;
background: #1B4436;
color: white;
border-radius: 8px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 600;
transition: opacity 0.15s;
}
.qr-download:hover { opacity: 0.88; }
</style>

View File

@@ -40,10 +40,28 @@
Mode dev données seed Mode dev données seed
</div> </div>
<!-- VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas --> <!-- VUE DESKTOP : Onglets Métropole / Outre-mer -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden"> <div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur --> <!-- Barre onglets desktop -->
<div class="flex flex-col flex-1 overflow-hidden"> <div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
>Outre-mer</button>
</div>
<!-- Carte Métropole desktop -->
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;"> <div class="relative flex-1" style="min-height: 200px;">
<ClientOnly> <ClientOnly>
<NavMap <NavMap
@@ -53,23 +71,16 @@
@select-org="onSelectOrg" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div <div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte</div>
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" /> <ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div> </div>
<!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe --> <!-- Carte Outre-mer desktop -->
<div <div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
class="shrink-0" <div class="flex-1 overflow-y-auto">
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<ClientOnly> <ClientOnly>
<OutremerMap <OutremerMap
:orgs="outremerOrgs" :orgs="outremerOrgs"
@@ -77,15 +88,12 @@
@select-org="onSelectOrg" @select-org="onSelectOrg"
/> />
<template #fallback> <template #fallback>
<div <div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement</div>
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
</template> </template>
</ClientOnly> </ClientOnly>
</div> </div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
</div> </div>
<!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable --> <!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
@@ -330,6 +338,7 @@ const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole' (route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
) )
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false) const chatbotOpen = ref(false)
const ficheModalOpen = ref(false) const ficheModalOpen = ref(false)

View File

@@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const expected = config.codevPassword || 'merci' const expected = config.codevPassword || 'merci'
if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) { const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
if (!isAdmin && !isUser) {
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' }) throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
} }
// Cookie session (user + admin)
setCookie(event, 'codev_session', 'ok', { setCookie(event, 'codev_session', 'ok', {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
@@ -27,5 +31,16 @@ export default defineEventHandler(async (event) => {
path: '/', path: '/',
}) })
return { status: 200, ok: true } // Cookie admin si mot de passe admin
if (isAdmin) {
setCookie(event, 'codev_admin', 'ok', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24h
path: '/',
})
}
return { status: 200, ok: true, admin: isAdmin }
}) })

View File

@@ -0,0 +1,25 @@
export default defineEventHandler(async (event) => {
// Vérif cookie admin
const adminCookie = getCookie(event, 'codev_admin')
if (adminCookie !== 'ok') {
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
}
const config = useRuntimeConfig()
const tableId = config.codevTableId
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
method: 'DELETE',
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: Number(id) }),
}).catch(() => {
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
})
return { status: 200, ok: true }
})

View File

@@ -0,0 +1,34 @@
import type { CodevFiche } from '~/types/codev'
export default defineEventHandler(async (event): Promise<CodevFiche> => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
const r: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => null)
if (!r) {
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
}
return {
id: r.Id ?? r.id,
nom: r.nom || '',
besoin: r.besoin || '',
offre: r.offre || '',
hashtags: (r.hashtags || '')
.split(',')
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean),
created_at: r.created_at || r.CreatedAt || '',
}
})

View File

@@ -0,0 +1,59 @@
import { z } from 'zod'
const PatchSchema = z.object({
nom: z.string().min(2).max(50).trim(),
besoin: z.string().min(5).max(300).trim(),
offre: z.string().min(5).max(300).trim(),
hashtags: z.array(z.string().max(30)).max(3).default([]),
})
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const tableId = config.codevTableId
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
const id = getRouterParam(event, 'id')
const body = await readBody(event)
if (!tableId || !id) {
throw createError({ statusCode: 400, message: 'Parametre manquant' })
}
const parsed = PatchSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation echouee',
data: parsed.error.flatten().fieldErrors,
})
}
const payload = {
nom: parsed.data.nom,
besoin: parsed.data.besoin,
offre: parsed.data.offre,
hashtags: parsed.data.hashtags
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
.filter(Boolean)
.slice(0, 3)
.join(','),
}
// NocoDB v1 PATCH par Id
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
try {
await $fetch(url, {
method: 'PATCH',
headers: {
'xc-token': config.nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
}
return { status: 200, ok: true }
})

View File

@@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
const admin = getCookie(event, 'codev_admin') === 'ok'
const session = getCookie(event, 'codev_session') === 'ok'
return { admin, session }
})

View File

@@ -8,8 +8,9 @@ export default defineEventHandler((event) => {
// Seulement les routes sous /codev/ // Seulement les routes sous /codev/
if (!path.startsWith('/codev/')) return if (!path.startsWith('/codev/')) return
// Routes publiques : /codev/demo (et sous-routes éventuelles) // Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
// Vérification cookie // Vérification cookie
const session = getCookie(event, 'codev_session') const session = getCookie(event, 'codev_session')

View File

@@ -41,15 +41,21 @@ function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: str
return jaccard(tokenize(textA), tokenize(textB)) return jaccard(tokenize(textA), tokenize(textB))
} }
const THRESHOLD = 0.15 // scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
function scoreDirect(textA: string, textB: string): number {
return jaccard(tokenize(textA), tokenize(textB))
}
export function matchSolution(fiches: CodevFiche[]): CodevMatch[] { export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
const matches: CodevMatch[] = [] const matches: CodevMatch[] = []
for (const a of fiches) { for (const a of fiches) {
for (const b of fiches) { for (const b of fiches) {
if (a.id === b.id) continue if (a.id === b.id) continue
const s = score(a.besoin, a.hashtags, b.offre, b.hashtags) // Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
if (s >= THRESHOLD) { // On ignore les hashtags pour differencier besoin et offre
const s = scoreDirect(a.besoin, b.offre)
if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' }) matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
} }
} }
@@ -57,13 +63,14 @@ export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
return matches return matches
} }
export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] { export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = [] const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) { for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) { for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j] const a = fiches[i], b = fiches[j]
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags) const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
if (s >= THRESHOLD) { if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' }) matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
} }
} }
@@ -71,13 +78,14 @@ export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
return matches return matches
} }
export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] { export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = [] const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) { for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) { for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j] const a = fiches[i], b = fiches[j]
// Surprise : offres similaires
const s = score(a.offre, a.hashtags, b.offre, b.hashtags) const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
if (s >= THRESHOLD) { if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' }) matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
} }
} }
@@ -88,10 +96,11 @@ export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
export function computeMatches( export function computeMatches(
fiches: CodevFiche[], fiches: CodevFiche[],
mode: 'solution' | 'alliance' | 'surprise', mode: 'solution' | 'alliance' | 'surprise',
threshold?: number,
): CodevMatch[] { ): CodevMatch[] {
switch (mode) { switch (mode) {
case 'solution': return matchSolution(fiches) case 'solution': return matchSolution(fiches, threshold)
case 'alliance': return matchAlliance(fiches) case 'alliance': return matchAlliance(fiches, threshold)
case 'surprise': return matchSurprise(fiches) case 'surprise': return matchSurprise(fiches, threshold)
} }
} }