From 510394269878d27b76ca969cc02feda0fc85fc7f Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 15:56:19 +0200
Subject: [PATCH 01/16] feat(codev): M1 - NocoDB table schema + 3 endpoints API
+ runtimeConfig
---
nuxt.config.ts | 3 ++
server/api/codev/auth.post.ts | 31 ++++++++++++++++
server/api/codev/fiches.get.ts | 31 ++++++++++++++++
server/api/codev/fiches.post.ts | 63 +++++++++++++++++++++++++++++++++
types/codev.ts | 18 ++++++++++
5 files changed, 146 insertions(+)
create mode 100644 server/api/codev/auth.post.ts
create mode 100644 server/api/codev/fiches.get.ts
create mode 100644 server/api/codev/fiches.post.ts
create mode 100644 types/codev.ts
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 9c62d00..f91f43a 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -14,6 +14,9 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
+ codevTableId: '', // NUXT_CODEV_TABLE_ID
+ codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
+ codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
diff --git a/server/api/codev/auth.post.ts b/server/api/codev/auth.post.ts
new file mode 100644
index 0000000..f947db8
--- /dev/null
+++ b/server/api/codev/auth.post.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod'
+
+const AuthSchema = z.object({
+ password: z.string().min(1).max(100),
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const parsed = AuthSchema.safeParse(body)
+
+ if (!parsed.success) {
+ throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
+ }
+
+ const config = useRuntimeConfig()
+ const expected = config.codevPassword || 'merci'
+
+ if (parsed.data.password.trim().toLowerCase() !== expected.trim().toLowerCase()) {
+ throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
+ }
+
+ setCookie(event, 'codev_session', 'ok', {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ maxAge: 60 * 60 * 24, // 24h
+ path: '/',
+ })
+
+ return { status: 200, ok: true }
+})
diff --git a/server/api/codev/fiches.get.ts b/server/api/codev/fiches.get.ts
new file mode 100644
index 0000000..cd65f37
--- /dev/null
+++ b/server/api/codev/fiches.get.ts
@@ -0,0 +1,31 @@
+import type { CodevFiche } from '~/types/codev'
+
+export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
+ const config = useRuntimeConfig()
+ const tableId = config.codevTableId
+
+ if (!tableId) {
+ throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
+ }
+
+ const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
+
+ const data: any = await $fetch(url, {
+ headers: { 'xc-token': config.nocodbToken },
+ }).catch(() => ({ list: [] }))
+
+ // Mapper chaque record NocoDB vers CodevFiche
+ const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
+ id: r.Id ?? r.id,
+ nom: r.nom || '',
+ besoin: r.besoin || '',
+ offre: r.offre || '',
+ hashtags: (r.hashtags || '')
+ .split(',')
+ .map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
+ .filter(Boolean),
+ created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
+ }))
+
+ return { list }
+})
diff --git a/server/api/codev/fiches.post.ts b/server/api/codev/fiches.post.ts
new file mode 100644
index 0000000..be1d3ce
--- /dev/null
+++ b/server/api/codev/fiches.post.ts
@@ -0,0 +1,63 @@
+import { z } from 'zod'
+
+const FicheSchema = z.object({
+ nom: z.string().min(2).max(50).trim(),
+ besoin: z.string().min(5).max(300).trim(),
+ offre: z.string().min(5).max(300).trim(),
+ hashtags: z.array(z.string().max(30)).max(3).default([]),
+})
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const parsed = FicheSchema.safeParse(body)
+
+ if (!parsed.success) {
+ throw createError({
+ statusCode: 422,
+ statusMessage: 'Validation échouée',
+ data: parsed.error.flatten().fieldErrors,
+ })
+ }
+
+ const config = useRuntimeConfig()
+ const tableId = config.codevTableId
+ const baseId = config.codevBaseId || 'pipilvsi7dibo80'
+
+ const payload = {
+ nom: parsed.data.nom,
+ besoin: parsed.data.besoin,
+ offre: parsed.data.offre,
+ hashtags: parsed.data.hashtags
+ .map((h) => h.trim().toLowerCase().replace(/^#/, ''))
+ .filter(Boolean)
+ .slice(0, 3)
+ .join(','),
+ created_at: new Date().toISOString(),
+ }
+
+ // NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
+ const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
+
+ let inserted: any
+ try {
+ inserted = await $fetch(insertUrl, {
+ method: 'POST',
+ headers: {
+ 'xc-token': config.nocodbToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(payload),
+ })
+ } catch (e: any) {
+ console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
+ throw createError({
+ statusCode: 502,
+ statusMessage: 'Erreur serveur, réessaie',
+ })
+ }
+
+ return {
+ status: 201,
+ id: inserted?.Id ?? inserted?.id ?? null,
+ }
+})
diff --git a/types/codev.ts b/types/codev.ts
new file mode 100644
index 0000000..c2e1ef7
--- /dev/null
+++ b/types/codev.ts
@@ -0,0 +1,18 @@
+export interface CodevFiche {
+ id: number
+ nom: string
+ besoin: string
+ offre: string
+ hashtags: string[] // parsé depuis CSV NocoDB
+ created_at: string // ISO
+}
+
+export interface CodevMatch {
+ fromId: number
+ toId: number
+ score: number // 0-1
+ mode: 'solution' | 'alliance' | 'surprise'
+ // solution : fromId.besoin matche toId.offre (orienté)
+ // alliance : symétrique sur besoin
+ // surprise : symétrique sur offre
+}
From 9c4f4b8e87959bb8ee9960cf1ba64c0b7f5f3940 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 15:59:26 +0200
Subject: [PATCH 02/16] feat(codev): M2 - lock screen + fiche form + middleware
auth
Co-Authored-By: Claude Sonnet 4.6
---
pages/codev/fiche.vue | 367 ++++++++++++++++++++++++++++++++
pages/codev/index.vue | 217 +++++++++++++++++++
server/middleware/codev-auth.ts | 20 ++
3 files changed, 604 insertions(+)
create mode 100644 pages/codev/fiche.vue
create mode 100644 pages/codev/index.vue
create mode 100644 server/middleware/codev-auth.ts
diff --git a/pages/codev/fiche.vue b/pages/codev/fiche.vue
new file mode 100644
index 0000000..58367dd
--- /dev/null
+++ b/pages/codev/fiche.vue
@@ -0,0 +1,367 @@
+
+
+
+
+
+
+
diff --git a/pages/codev/index.vue b/pages/codev/index.vue
new file mode 100644
index 0000000..cb8e4b3
--- /dev/null
+++ b/pages/codev/index.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+ {{ loading ? 'Vérification...' : 'Entrer' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/middleware/codev-auth.ts b/server/middleware/codev-auth.ts
new file mode 100644
index 0000000..dfab87b
--- /dev/null
+++ b/server/middleware/codev-auth.ts
@@ -0,0 +1,20 @@
+// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
+// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
+
+export default defineEventHandler((event) => {
+ const url = getRequestURL(event)
+ const path = url.pathname
+
+ // Seulement les routes sous /codev/
+ if (!path.startsWith('/codev/')) return
+
+ // Routes publiques : /codev/demo (et sous-routes éventuelles)
+ if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
+
+ // Vérification cookie
+ const session = getCookie(event, 'codev_session')
+ if (session === 'ok') return
+
+ // Non authentifié -> redirect vers /codev (lock screen)
+ return sendRedirect(event, '/codev', 302)
+})
From 3347b3f8591e345f8ae9bead207c32772b8dbf49 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 16:03:28 +0200
Subject: [PATCH 03/16] feat(codev): M3 - CodevGraph D3 force-directed + page
carto affichage
- Install d3@^7.9.0 (absent du projet, requis pour force simulation)
- components/codev/CodevGraph.vue : simulation forceLink/forceManyBody/forceCenter/forceCollide, drag D3, pastilles offre (vert) + besoin (orange), tooltip SVG natif, ResizeObserver, watch matches/mode pret pour M4, placeholder si 0 fiches
- pages/codev/carto.vue : useFetch /api/codev/fiches, mount CodevGraph, refs matches+mode vides (M4 les remplira)
Co-Authored-By: Claude Sonnet 4.6
---
components/codev/CodevGraph.vue | 372 ++++++++++++++++++++++++++
package-lock.json | 459 ++++++++++++++++++++++++++++++++
package.json | 1 +
pages/codev/carto.vue | 123 +++++++++
4 files changed, 955 insertions(+)
create mode 100644 components/codev/CodevGraph.vue
create mode 100644 pages/codev/carto.vue
diff --git a/components/codev/CodevGraph.vue b/components/codev/CodevGraph.vue
new file mode 100644
index 0000000..97764d3
--- /dev/null
+++ b/components/codev/CodevGraph.vue
@@ -0,0 +1,372 @@
+
+
+
+
+
+
Encore personne. Sois la premiere fiche !
+
Creer ma fiche →
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index af4ed5b..bdf6d04 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
+ "d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
@@ -5312,6 +5313,416 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/db0": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
@@ -5425,6 +5836,15 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
+ "node_modules/delaunator": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -6480,6 +6900,18 @@
"node": ">=16.17.0"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6555,6 +6987,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
@@ -9480,6 +9921,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+ "license": "Unlicense"
+ },
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -9595,6 +10042,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -9633,6 +10086,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
diff --git a/package.json b/package.json
index 19676b2..c42ee54 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@headlessui/vue": "^1.7.23",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
+ "d3": "^7.9.0",
"ioredis": "^5.3.2",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
new file mode 100644
index 0000000..4939cd5
--- /dev/null
+++ b/pages/codev/carto.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+ Chargement du graphe...
+
+
+
+
+
+
Boutons matching - arrivent bientot (M4)
+
+
+
+
+
+
+
+
From d345d7f6f99d9f033a1b54742217b7b4e64b0af2 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 16:07:20 +0200
Subject: [PATCH 04/16] feat(codev): M4 - matching 3 modes + boutons UI +
animation force
Co-Authored-By: Claude Sonnet 4.6
---
components/codev/CodevGraph.vue | 27 +++--
pages/codev/carto.vue | 177 +++++++++++++++++++++++++++++---
utils/codev/matching.ts | 97 +++++++++++++++++
3 files changed, 279 insertions(+), 22 deletions(-)
create mode 100644 utils/codev/matching.ts
diff --git a/components/codev/CodevGraph.vue b/components/codev/CodevGraph.vue
index 97764d3..5d2347c 100644
--- a/components/codev/CodevGraph.vue
+++ b/components/codev/CodevGraph.vue
@@ -8,7 +8,21 @@
-
+
+
+
+
+
+
+
@@ -82,9 +96,9 @@ function buildLinks(nodes: SimNode[]): SimLink[] {
}
function linkColor(mode: string): string {
- if (mode === 'solution') return '#1B4436'
- if (mode === 'alliance') return '#3b82f6'
- if (mode === 'surprise') return '#a855f7'
+ if (mode === 'solution') return '#22c55e'
+ if (mode === 'alliance') return '#f97316'
+ if (mode === 'surprise') return '#3b82f6'
return '#ccc'
}
@@ -132,8 +146,6 @@ function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
- const r = nodeRadius.value
-
const linkSel = gLinks
.selectAll('line')
.data(currentLinks, (d: SimLink) => {
@@ -149,7 +161,7 @@ function rebuildLinks() {
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
- .attr('marker-end', null)
+ .attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
}
// ── Rendu complet ──────────────────────────────────────────────────────────
@@ -173,6 +185,7 @@ function render() {
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
+ .attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
// Noeuds = groupe par personne
const nodeGroups = gNodes!
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index 4939cd5..88e11db 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -6,7 +6,7 @@
Chargement...
- {{ 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
@@ -23,9 +23,52 @@
-
-
-
Boutons matching - arrivent bientot (M4)
+
+
+
+ Mode {{ MODE_LABELS[mode] }} actif -
+ {{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
+
+ Effacer
+
+
+
+
+
+ Solution
+ besoin - offre
+
+
+ Alliance
+ besoins partages
+
+
+ Surprise
+ offres partagees
+
+
+ Effacer
+
@@ -33,18 +76,32 @@
@@ -96,17 +153,107 @@ function onSelectFiche(id: number) {
border-radius: 12px;
}
-/* ── Placeholder matching (M4) ── */
+/* ── Bandeau mode actif ── */
-.matching-controls.placeholder {
- text-align: center;
- padding: 0.75rem;
+.mode-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.5rem 0.875rem;
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 8px;
+ font-size: 0.875rem;
+ color: #166534;
+ flex-wrap: wrap;
}
-.matching-controls.placeholder p {
- color: #999;
- font-size: 0.85rem;
- margin: 0;
+.banner-clear {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: #166534;
+ background: transparent;
+ border: 1px solid #166534;
+ border-radius: 6px;
+ padding: 0.2rem 0.6rem;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.banner-clear:hover {
+ background: #166534;
+ color: #fff;
+}
+
+/* ── Boutons matching ── */
+
+.matching-controls {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(8px);
+ border-top: 1px solid #e5e7eb;
+ margin: 0 -1rem -2rem;
+}
+
+.matching-controls button {
+ flex: 1;
+ padding: 12px 8px;
+ border: 1px solid #d0d4dc;
+ border-radius: 8px;
+ background: white;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.matching-controls button .hint {
+ font-size: 11px;
+ color: #6b7280;
+ font-weight: normal;
+}
+
+.matching-controls button.active {
+ background: var(--mode-color, #1B4436);
+ color: white;
+ border-color: transparent;
+}
+
+.matching-controls button.active .hint {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.matching-controls button.reset {
+ flex: 0 0 auto;
+ padding: 12px 16px;
+ background: #f3f4f6;
+ border-color: #d0d4dc;
+ color: #374151;
+ font-size: 13px;
+}
+
+.matching-controls button.reset:hover {
+ background: #e5e7eb;
+}
+
+@media (max-width: 500px) {
+ .matching-controls {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ margin: 0 -0.75rem -1.5rem;
+ }
+
+ .matching-controls button.reset {
+ grid-column: span 2;
+ }
}
/* ── Mobile ── */
diff --git a/utils/codev/matching.ts b/utils/codev/matching.ts
new file mode 100644
index 0000000..fe3135b
--- /dev/null
+++ b/utils/codev/matching.ts
@@ -0,0 +1,97 @@
+import type { CodevFiche, CodevMatch } from '~/types/codev'
+
+const STOP_WORDS_FR = new Set([
+ 'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
+ 'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
+ 'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
+ 'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
+ 'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
+ 'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
+ 'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
+ 'est', 'sont', 'etre', 'ai', 'as', 'avoir',
+ 'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
+ 'me', 'te', 'se', 'lui', 'leur', 'y',
+])
+
+function tokenize(text: string): Set {
+ if (!text) return new Set()
+ const tokens = text
+ .toLowerCase()
+ .replace(/[.,;:!?()'"\-/]/g, ' ')
+ .split(/\s+/)
+ .filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
+ return new Set(tokens)
+}
+
+function jaccard(a: Set, b: Set): number {
+ if (a.size === 0 || b.size === 0) return 0
+ let inter = 0
+ for (const x of a) if (b.has(x)) inter++
+ const union = a.size + b.size - inter
+ return union === 0 ? 0 : inter / union
+}
+
+function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
+ const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
+ const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
+
+ if (tagsA.size > 0 && tagsB.size > 0) {
+ return jaccard(tagsA, tagsB)
+ }
+ return jaccard(tokenize(textA), tokenize(textB))
+}
+
+const THRESHOLD = 0.15
+
+export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
+ const matches: CodevMatch[] = []
+ for (const a of fiches) {
+ for (const b of fiches) {
+ if (a.id === b.id) continue
+ const s = score(a.besoin, a.hashtags, b.offre, b.hashtags)
+ if (s >= THRESHOLD) {
+ matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
+ }
+ }
+ }
+ return matches
+}
+
+export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
+ const matches: CodevMatch[] = []
+ for (let i = 0; i < fiches.length; i++) {
+ for (let j = i + 1; j < fiches.length; j++) {
+ const a = fiches[i], b = fiches[j]
+ const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
+ if (s >= THRESHOLD) {
+ matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
+ }
+ }
+ }
+ return matches
+}
+
+export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
+ const matches: CodevMatch[] = []
+ for (let i = 0; i < fiches.length; i++) {
+ for (let j = i + 1; j < fiches.length; j++) {
+ const a = fiches[i], b = fiches[j]
+ const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
+ if (s >= THRESHOLD) {
+ matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
+ }
+ }
+ }
+ return matches
+}
+
+export function computeMatches(
+ fiches: CodevFiche[],
+ mode: 'solution' | 'alliance' | 'surprise',
+): CodevMatch[] {
+ switch (mode) {
+ case 'solution': return matchSolution(fiches)
+ case 'alliance': return matchAlliance(fiches)
+ case 'surprise': return matchSurprise(fiches)
+ }
+}
From 825b0ddeb270b8917ebdfde47377c3a722e71353 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 16:11:34 +0200
Subject: [PATCH 05/16] feat(codev): M5 phase 1 - mode demo factice + build
local OK
---
pages/codev/demo.vue | 367 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 367 insertions(+)
create mode 100644 pages/codev/demo.vue
diff --git a/pages/codev/demo.vue b/pages/codev/demo.vue
new file mode 100644
index 0000000..81047b5
--- /dev/null
+++ b/pages/codev/demo.vue
@@ -0,0 +1,367 @@
+
+
+
+
+
+
+
+
+ Chargement du graphe...
+
+
+
+
+
+
+ Mode {{ MODE_LABELS[mode] }} actif -
+ {{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
+
+ Effacer
+
+
+
+
+
+ Solution
+ besoin - offre
+
+
+ Alliance
+ besoins partages
+
+
+ Surprise
+ offres partagees
+
+
+ Effacer
+
+
+
+
+
+
+
+
+
From 4ed0a87106ddef7f67a45016f859714a219895f3 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 17:49:27 +0200
Subject: [PATCH 06/16] feat(codev): onglet Codev dans nav desktop + menu
mobile
---
app.vue | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/app.vue b/app.vue
index ee95740..170aa74 100644
--- a/app.vue
+++ b/app.vue
@@ -50,6 +50,13 @@
RAG
en construction
+
+ Codev
+
@@ -167,6 +174,7 @@
Écosystème Entraide Architecture
Agences Inspirantes
RAG
+ Codev
À propos
Signaler
From e7c7d302ea4c970af73e7aa9d36add5f55c1d594 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 17:49:56 +0200
Subject: [PATCH 07/16] fix(codev): boundaries D3 + matching rebuildLinks +
couleurs + bulles toggle + FAB +
---
components/codev/CodevGraph.vue | 109 ++++++++++++++++++++------
pages/codev/carto.vue | 70 +++++++++++++++++
pages/codev/fiche.vue | 44 ++++++++---
server/api/codev/fiches/[id].get.ts | 34 ++++++++
server/api/codev/fiches/[id].patch.ts | 59 ++++++++++++++
5 files changed, 282 insertions(+), 34 deletions(-)
create mode 100644 server/api/codev/fiches/[id].get.ts
create mode 100644 server/api/codev/fiches/[id].patch.ts
diff --git a/components/codev/CodevGraph.vue b/components/codev/CodevGraph.vue
index 5d2347c..cc4acc8 100644
--- a/components/codev/CodevGraph.vue
+++ b/components/codev/CodevGraph.vue
@@ -37,9 +37,11 @@ const props = withDefaults(defineProps<{
fiches: CodevFiche[]
matches?: CodevMatch[]
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
+ showLabels?: boolean
}>(), {
matches: () => [],
mode: 'none',
+ showLabels: false,
})
const emit = defineEmits<{
@@ -146,18 +148,15 @@ function rebuildLinks() {
currentLinks = buildLinks(currentNodes)
if (!gLinks || !simulation) return
- const linkSel = gLinks
+ // .join() moderne D3 pour garantir le re-rendu complet
+ gLinks
.selectAll('line')
- .data(currentLinks, (d: SimLink) => {
- const s = d.source as SimNode
- const t = d.target as SimNode
- return `${s.id}-${t.id}-${d.mode}`
- })
-
- linkSel.exit().remove()
-
- linkSel.enter()
- .append('line')
+ .data(currentLinks)
+ .join(
+ enter => enter.append('line'),
+ update => update,
+ exit => exit.remove()
+ )
.attr('stroke', d => linkColor(d.mode))
.attr('stroke-width', d => 1 + d.score * 3)
.attr('stroke-opacity', 0.7)
@@ -223,12 +222,12 @@ function render() {
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
- // Pastille besoin (bas-droite, orange)
+ // Pastille besoin (bas-droite, bleu)
nodeGroups.append('circle')
.attr('r', 6)
.attr('cx', r * 0.65)
.attr('cy', r * 0.65)
- .attr('fill', '#f97316')
+ .attr('fill', '#3b82f6')
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
@@ -236,6 +235,57 @@ function render() {
nodeGroups.append('title')
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
+ // Groupe label bulle (affiche si showLabels)
+ const labelGroups = nodeGroups.append('g')
+ .attr('class', 'label-bubble')
+ .attr('visibility', props.showLabels ? 'visible' : 'hidden')
+
+ // Fond bulle besoin (dessous du noeud)
+ labelGroups.append('rect')
+ .attr('class', 'bubble-besoin-bg')
+ .attr('x', -(r + 50))
+ .attr('y', r + 4)
+ .attr('width', 100)
+ .attr('height', 28)
+ .attr('rx', 6)
+ .attr('fill', '#eff6ff')
+ .attr('stroke', '#3b82f6')
+ .attr('stroke-width', 1)
+
+ // Texte besoin
+ labelGroups.append('text')
+ .attr('class', 'bubble-besoin-txt')
+ .attr('x', -(r) + 50)
+ .attr('y', r + 22)
+ .attr('text-anchor', 'middle')
+ .attr('font-size', 9)
+ .attr('fill', '#1e40af')
+ .attr('pointer-events', 'none')
+ .text(d => truncate(d.besoin, 18))
+
+ // Fond bulle offre (dessus du noeud)
+ labelGroups.append('rect')
+ .attr('class', 'bubble-offre-bg')
+ .attr('x', -(r + 50))
+ .attr('y', -(r + 32))
+ .attr('width', 100)
+ .attr('height', 28)
+ .attr('rx', 6)
+ .attr('fill', '#f0fdf4')
+ .attr('stroke', '#22c55e')
+ .attr('stroke-width', 1)
+
+ // Texte offre
+ labelGroups.append('text')
+ .attr('class', 'bubble-offre-txt')
+ .attr('x', -(r) + 50)
+ .attr('y', -(r + 14))
+ .attr('text-anchor', 'middle')
+ .attr('font-size', 9)
+ .attr('fill', '#166534')
+ .attr('pointer-events', 'none')
+ .text(d => truncate(d.offre, 18))
+
// Simulation
simulation = d3.forceSimulation(currentNodes)
.force('link', d3.forceLink(currentLinks)
@@ -245,6 +295,8 @@ function render() {
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
.force('collide', d3.forceCollide().radius(r + 12))
+ .force('x', d3.forceX(width.value / 2).strength(0.05))
+ .force('y', d3.forceY(height.value / 2).strength(0.05))
.alphaDecay(0.02)
.on('tick', tick)
@@ -254,16 +306,21 @@ function render() {
}
function tick() {
+ const r = nodeRadius.value
if (!gLinks || !gNodes) return
gLinks.selectAll('line')
- .attr('x1', d => (d.source as SimNode).x ?? 0)
- .attr('y1', d => (d.source as SimNode).y ?? 0)
- .attr('x2', d => (d.target as SimNode).x ?? 0)
- .attr('y2', d => (d.target as SimNode).y ?? 0)
+ .attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
+ .attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
+ .attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
+ .attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
gNodes.selectAll('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) ─────────────────────────────────────
@@ -271,13 +328,21 @@ function tick() {
watch(() => [props.matches, props.mode] as const, () => {
if (!simulation) return
rebuildLinks()
- simulation.force('link', d3.forceLink(currentLinks)
- .id(d => d.id)
+ const newForce = d3.forceLink(currentLinks)
+ .id(d => String(d.id))
.distance(120)
- .strength(0.3))
- simulation.alpha(0.5).restart()
+ .strength(0.5)
+ simulation.force('link', newForce)
+ simulation.alpha(0.8).restart()
}, { deep: true })
+// ── Watch showLabels ──────────────────────────────────────────────────────
+
+watch(() => props.showLabels, (val) => {
+ if (!svgEl.value) return
+ d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
+})
+
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
watch(() => props.fiches, () => {
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index 88e11db..bb17163 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -11,11 +11,22 @@
+
+
+ {{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
+
+
+
@@ -71,6 +82,11 @@
+
+
+ +
+
+
@@ -85,6 +101,7 @@ const fiches = computed(() => data.value?.list ?? [])
const matches = ref([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
+const showLabels = ref(false)
const MODE_LABELS: Record = {
solution: 'Solution',
@@ -256,6 +273,59 @@ 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;
+}
+
/* ── Mobile ── */
@media (max-width: 600px) {
diff --git a/pages/codev/fiche.vue b/pages/codev/fiche.vue
index 58367dd..64d03be 100644
--- a/pages/codev/fiche.vue
+++ b/pages/codev/fiche.vue
@@ -4,7 +4,7 @@
@@ -105,7 +105,7 @@
- {{ loading ? 'Envoi en cours...' : 'Ajouter ma fiche' }}
+ {{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
@@ -115,12 +115,29 @@
diff --git a/utils/codev/matching.ts b/utils/codev/matching.ts
index fe3135b..d8dc7e9 100644
--- a/utils/codev/matching.ts
+++ b/utils/codev/matching.ts
@@ -41,15 +41,21 @@ function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: str
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[] = []
for (const a of fiches) {
for (const b of fiches) {
if (a.id === b.id) continue
- const s = score(a.besoin, a.hashtags, b.offre, b.hashtags)
- if (s >= THRESHOLD) {
+ // Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
+ // On ignore les hashtags pour differencier besoin et offre
+ const s = scoreDirect(a.besoin, b.offre)
+ if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
}
}
@@ -57,13 +63,14 @@ export function matchSolution(fiches: CodevFiche[]): CodevMatch[] {
return matches
}
-export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
+export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
+ // Alliance : besoins similaires — on compare hashtags si presents, sinon textes
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
- if (s >= THRESHOLD) {
+ if (s >= threshold) {
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
}
}
@@ -71,13 +78,14 @@ export function matchAlliance(fiches: CodevFiche[]): CodevMatch[] {
return matches
}
-export function matchSurprise(fiches: CodevFiche[]): CodevMatch[] {
+export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
const matches: CodevMatch[] = []
for (let i = 0; i < fiches.length; i++) {
for (let j = i + 1; j < fiches.length; j++) {
const a = fiches[i], b = fiches[j]
+ // Surprise : offres similaires
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
- if (s >= THRESHOLD) {
+ if (s >= threshold) {
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(
fiches: CodevFiche[],
mode: 'solution' | 'alliance' | 'surprise',
+ threshold?: number,
): CodevMatch[] {
switch (mode) {
- case 'solution': return matchSolution(fiches)
- case 'alliance': return matchAlliance(fiches)
- case 'surprise': return matchSurprise(fiches)
+ case 'solution': return matchSolution(fiches, threshold)
+ case 'alliance': return matchAlliance(fiches, threshold)
+ case 'surprise': return matchSurprise(fiches, threshold)
}
}
From 606b9f0a472e56fddf5f6186314bbaa012633f18 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Wed, 6 May 2026 21:29:07 +0200
Subject: [PATCH 09/16] feat(codev): tabs Besoins/Competences + retour fiche +
panel mobile bottom sheet
---
pages/codev/carto.vue | 207 +++++++++++++++++++++++++++++-------------
pages/codev/fiche.vue | 13 +++
2 files changed, 155 insertions(+), 65 deletions(-)
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index bb17163..35b497b 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -11,75 +11,92 @@
-
-
- {{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
-
+
+ Carto
+ Besoins
+ Compétences
-
-
-
- Chargement du graphe...
-
-
+
+
+
+ {{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
+
+
-
-
-
- Mode {{ MODE_LABELS[mode] }} actif -
- {{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
-
-
Effacer
+
+
+
+ Chargement du graphe...
+
+
+
+
+
+
+ Mode {{ MODE_LABELS[mode] }} actif -
+ {{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
+
+ Effacer
+
+
+
+
+
+ Alliance
+ besoins partages
+
+
+ Surprise
+ offres partagees
+
+
+ Effacer
+
+
-
-
-
- Solution
- besoin - offre
-
-
- Alliance
- besoins partages
-
-
- Surprise
- offres partagees
-
-
- Effacer
-
+
+
+
{{ f.nom }}
+
{{ f.besoin }}
+
Modifier
+
+
Aucune fiche. Ajouter la mienne
+
+
+
+
+
{{ f.nom }}
+
{{ f.offre }}
+
Modifier
+
+
Aucune fiche. Ajouter la mienne
@@ -87,6 +104,28 @@
+
+
+
+
+
+
{{ selectedFiche.nom }}
+
+
Besoin
+
{{ selectedFiche.besoin }}
+
+
+
Ce que j'apporte
+
{{ selectedFiche.offre }}
+
+
+ #{{ t }}
+
+
Modifier cette fiche
+
Fermer
+
+
+
+
@@ -102,6 +141,9 @@ const fiches = computed(() => data.value?.list ?? [])
const matches = ref
([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const showLabels = ref(false)
+const tab = ref<'carto' | 'besoins' | 'competences'>('carto')
+const selectedFiche = ref(null)
+const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
const MODE_LABELS: Record = {
solution: 'Solution',
@@ -119,7 +161,11 @@ function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
}
function onSelectFiche(id: number) {
- navigateTo(`/codev/fiche?id=${id}`)
+ if (isMobileView) {
+ selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
+ } else {
+ navigateTo(`/codev/fiche?id=${id}`)
+ }
}
@@ -326,6 +372,37 @@ function onSelectFiche(id: number) {
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; }
+
/* ── Mobile ── */
@media (max-width: 600px) {
diff --git a/pages/codev/fiche.vue b/pages/codev/fiche.vue
index 64d03be..0ad5d41 100644
--- a/pages/codev/fiche.vue
+++ b/pages/codev/fiche.vue
@@ -4,6 +4,7 @@
@@ -193,6 +194,18 @@ async function submit() {
/* ── En-tête ── */
+.back-link {
+ display: inline-block;
+ font-size: 0.875rem;
+ color: var(--nav-text-muted, #6b7280);
+ text-decoration: none;
+ margin-bottom: 0.75rem;
+}
+
+.back-link:hover {
+ color: var(--nav-primary-solid, #1B4436);
+}
+
.fiche-header h1 {
font-size: 1.5rem;
font-weight: 700;
From 142e5cf7873812f23d0ac99656b28af04273c5c8 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Thu, 7 May 2026 00:04:42 +0200
Subject: [PATCH 10/16] feat(codev): skip fiche + annuaire table sticky + page
QR code
---
pages/codev/carto.vue | 134 ++++++++++++++++++++++++++++++++++++------
pages/codev/fiche.vue | 15 +++++
pages/codev/qr.vue | 94 +++++++++++++++++++++++++++++
3 files changed, 226 insertions(+), 17 deletions(-)
create mode 100644 pages/codev/qr.vue
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index 35b497b..3529ddf 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -9,12 +9,12 @@
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
+ [ QR ]
Carto
- Besoins
- Compétences
+ Annuaire
@@ -81,22 +81,32 @@
-
-
-
{{ f.nom }}
-
{{ f.besoin }}
-
Modifier
-
-
Aucune fiche. Ajouter la mienne
-
+
-
-
-
{{ f.nom }}
-
{{ f.offre }}
-
Modifier
+
+ Aucune fiche. Ajouter la mienne
-
Aucune fiche. Ajouter la mienne
+
+
+
+
Clique sur une ligne pour modifier la fiche
@@ -141,7 +151,7 @@ const fiches = computed(() => data.value?.list ?? [])
const matches = ref
([])
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
const showLabels = ref(false)
-const tab = ref<'carto' | 'besoins' | 'competences'>('carto')
+const tab = ref<'carto' | 'annuaire'>('carto')
const selectedFiche = ref(null)
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
@@ -403,6 +413,96 @@ function onSelectFiche(id: number) {
.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;
+ background: inherit;
+ z-index: 1;
+ font-weight: 600;
+ color: #1a1a2e !important;
+ white-space: nowrap;
+ min-width: 80px;
+ border-right: 1px solid #e5e7eb;
+}
+
+.annuaire-row:hover .col-nom { background: #f9fafb; }
+thead tr .col-nom { background: #f9fafb; }
+
+.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;
+}
+
/* ── Mobile ── */
@media (max-width: 600px) {
diff --git a/pages/codev/fiche.vue b/pages/codev/fiche.vue
index 0ad5d41..d1a36f1 100644
--- a/pages/codev/fiche.vue
+++ b/pages/codev/fiche.vue
@@ -109,6 +109,10 @@
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
+
+ Voir la carte sans créer de fiche →
+
+
@@ -390,6 +394,17 @@ async function submit() {
cursor: not-allowed;
}
+.skip-link {
+ display: block;
+ text-align: center;
+ font-size: 0.825rem;
+ color: var(--nav-text-muted, #9ca3af);
+ text-decoration: none;
+ margin-top: 0.5rem;
+ padding: 0.5rem;
+}
+.skip-link:hover { color: var(--nav-text, #1a1a2e); }
+
/* ── Responsive ── */
@media (max-width: 480px) {
diff --git a/pages/codev/qr.vue b/pages/codev/qr.vue
new file mode 100644
index 0000000..ebedd8b
--- /dev/null
+++ b/pages/codev/qr.vue
@@ -0,0 +1,94 @@
+
+
+
+
Co-développement
+
Scanne pour rejoindre la session
+
+
+
+
{{ APP_URL }}
+
Mot de passe : merci
+
+
+ Télécharger le QR code
+
+
+
+
+
+
+
+
From c8311ce1fb919f86bcca609fbb4b7dcf7eb58591 Mon Sep 17 00:00:00 2001
From: Jules Neny
Date: Thu, 7 May 2026 00:22:44 +0200
Subject: [PATCH 11/16] feat(codev): retire Surprise + QR public + mode admin
suppr fiches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
nuxt.config.ts | 7 +++--
pages/codev/carto.vue | 43 ++++++++++++++++++++------
server/api/codev/auth.post.ts | 19 ++++++++++--
server/api/codev/fiches/[id].delete.ts | 25 +++++++++++++++
server/api/codev/me.get.ts | 5 +++
server/middleware/codev-auth.ts | 3 +-
6 files changed, 86 insertions(+), 16 deletions(-)
create mode 100644 server/api/codev/fiches/[id].delete.ts
create mode 100644 server/api/codev/me.get.ts
diff --git a/nuxt.config.ts b/nuxt.config.ts
index f91f43a..7c38360 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -14,9 +14,10 @@ export default defineNuxtConfig({
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
- codevTableId: '', // NUXT_CODEV_TABLE_ID
- codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
- codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
+ codevTableId: '', // NUXT_CODEV_TABLE_ID
+ codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
+ codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
+ codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
},
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
diff --git a/pages/codev/carto.vue b/pages/codev/carto.vue
index 3529ddf..4b1bb02 100644
--- a/pages/codev/carto.vue
+++ b/pages/codev/carto.vue
@@ -61,15 +61,6 @@
Alliance
besoins partages
-
- Surprise
- offres partagees
-
Prénom
Besoin
Ce que j'offre
+
@@ -101,6 +93,9 @@
{{ f.nom }}
{{ f.besoin }}
{{ f.offre }}
+
+ ✕
+
@@ -145,7 +140,7 @@ import { computeMatches } from '~/utils/codev/matching'
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 matches = ref([])
@@ -155,6 +150,15 @@ const tab = ref<'carto' | 'annuaire'>('carto')
const selectedFiche = ref(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 = {
solution: 'Solution',
alliance: 'Alliance',
@@ -177,6 +181,12 @@ function onSelectFiche(id: number) {
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()
+}