Compare commits
12 Commits
feat/aep-v
...
0099627da4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0099627da4 | ||
|
|
f9960bf8ea | ||
|
|
05bbcc2a02 | ||
|
|
f518318d60 | ||
|
|
0598536244 | ||
|
|
b951fe0b8d | ||
|
|
c8311ce1fb | ||
|
|
142e5cf787 | ||
|
|
606b9f0a47 | ||
|
|
6f7d2450de | ||
|
|
e7c7d302ea | ||
|
|
4ed0a87106 |
20
app.vue
20
app.vue
@@ -39,8 +39,7 @@
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||
>
|
||||
Agences Inspirantes
|
||||
<span class="nav-tab-badge">en construction</span>
|
||||
Réseaux AEP
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/rag"
|
||||
@@ -50,6 +49,20 @@
|
||||
RAG
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
|
||||
@@ -165,8 +178,9 @@
|
||||
@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="/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="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/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>
|
||||
|
||||
@@ -221,7 +221,12 @@ function updateTileTheme(dark: boolean) {
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
initMap()
|
||||
})
|
||||
})
|
||||
|
||||
// Observer les changements de classe dark sur <html>
|
||||
themeObserver = new MutationObserver(() => {
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
<span
|
||||
v-for="fn in orgFonctions(org)"
|
||||
:key="fn"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||
|
||||
@@ -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<SVGLineElement, SimLink>('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<SimNode, SimLink>(currentNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||
@@ -245,6 +295,8 @@ function render() {
|
||||
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
||||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
||||
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
||||
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
||||
.alphaDecay(0.02)
|
||||
.on('tick', tick)
|
||||
|
||||
@@ -254,16 +306,21 @@ function render() {
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const r = nodeRadius.value
|
||||
if (!gLinks || !gNodes) return
|
||||
|
||||
gLinks.selectAll<SVGLineElement, SimLink>('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<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) ─────────────────────────────────────
|
||||
@@ -271,13 +328,21 @@ function tick() {
|
||||
watch(() => [props.matches, props.mode] as const, () => {
|
||||
if (!simulation) return
|
||||
rebuildLinks()
|
||||
simulation.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||
.id(d => d.id)
|
||||
const newForce = d3.forceLink<SimNode, SimLink>(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, () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export default defineNuxtConfig({
|
||||
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: {
|
||||
nocodbUrl: process.env.NOCODB_URL,
|
||||
@@ -14,19 +19,20 @@ 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
|
||||
ssr: true,
|
||||
|
||||
vite: {
|
||||
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
||||
optimizeDeps: {
|
||||
include: ['leaflet', 'leaflet.markercluster'],
|
||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||
},
|
||||
// Éviter l'import SSR de Leaflet qui utilise window
|
||||
ssr: {
|
||||
noExternal: [],
|
||||
},
|
||||
|
||||
@@ -9,68 +9,137 @@
|
||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
||||
</template>
|
||||
</p>
|
||||
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
||||
</header>
|
||||
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
@select-fiche="onSelectFiche"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
<div 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>
|
||||
|
||||
<!-- Boutons matching -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - offre</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partages</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'surprise' }"
|
||||
style="--mode-color: #3b82f6"
|
||||
@click="setMode('surprise')"
|
||||
type="button"
|
||||
>
|
||||
Surprise
|
||||
<span class="hint">offres partagees</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
<div v-if="tab === 'carto'">
|
||||
<div class="show-labels-bar">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: showLabels }"
|
||||
@click="showLabels = !showLabels"
|
||||
>
|
||||
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
:show-labels="showLabels"
|
||||
@select-fiche="onSelectFiche"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
</div>
|
||||
|
||||
<!-- Boutons matching -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - compétence</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partagés</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||
|
||||
<div v-if="fiches.length === 0" class="list-empty">
|
||||
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="annuaire-scroll">
|
||||
<table class="annuaire-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-nom">Prénom</th>
|
||||
<th class="col-besoin">Besoin</th>
|
||||
<th class="col-offre">Ce que j'offre</th>
|
||||
<th v-if="isAdmin" class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
||||
<td class="col-nom">{{ f.nom }}</td>
|
||||
<td class="col-besoin">{{ f.besoin }}</td>
|
||||
<td class="col-offre">{{ f.offre }}</td>
|
||||
<td v-if="isAdmin" class="col-actions">
|
||||
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
||||
</div>
|
||||
|
||||
<!-- FAB ajouter une fiche -->
|
||||
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
||||
+
|
||||
</NuxtLink>
|
||||
|
||||
<Transition name="sheet">
|
||||
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
||||
<div class="sheet-content">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
||||
<div class="sheet-section">
|
||||
<span class="sheet-label">Besoin</span>
|
||||
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
||||
</div>
|
||||
<div class="sheet-section">
|
||||
<span class="sheet-label">Ce que j'apporte</span>
|
||||
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
||||
</div>
|
||||
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
||||
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
||||
</div>
|
||||
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
||||
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,11 +149,24 @@ 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<CodevMatch[]>([])
|
||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||
const showLabels = ref(false)
|
||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||
const selectedFiche = ref<CodevFiche | null>(null)
|
||||
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
||||
|
||||
const isAdmin = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
||||
isAdmin.value = r.admin
|
||||
} catch { isAdmin.value = false }
|
||||
})
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
solution: 'Solution',
|
||||
@@ -102,7 +184,17 @@ 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFiche(id: number) {
|
||||
if (!confirm('Supprimer la fiche ?')) return
|
||||
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
}
|
||||
</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 ── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
@@ -7,63 +7,82 @@
|
||||
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
||||
</header>
|
||||
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
<div 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>
|
||||
|
||||
<!-- 3 boutons matching identiques a carto.vue -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - offre</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partages</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'surprise' }"
|
||||
style="--mode-color: #3b82f6"
|
||||
@click="setMode('surprise')"
|
||||
type="button"
|
||||
>
|
||||
Surprise
|
||||
<span class="hint">offres partagees</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
<div v-if="tab === 'carto'">
|
||||
<ClientOnly>
|
||||
<CodevGraph
|
||||
:fiches="fiches"
|
||||
:matches="matches"
|
||||
:mode="mode"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="graph-fallback">Chargement du graphe...</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Bandeau info mode actif -->
|
||||
<div v-if="mode !== 'none'" class="mode-banner">
|
||||
<span>
|
||||
Mode {{ MODE_LABELS[mode] }} actif -
|
||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||
</span>
|
||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||
</div>
|
||||
|
||||
<!-- Boutons matching -->
|
||||
<div class="matching-controls">
|
||||
<button
|
||||
:class="{ active: mode === 'solution' }"
|
||||
style="--mode-color: #22c55e"
|
||||
@click="setMode('solution')"
|
||||
type="button"
|
||||
>
|
||||
Solution
|
||||
<span class="hint">besoin - offre</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'alliance' }"
|
||||
style="--mode-color: #f97316"
|
||||
@click="setMode('alliance')"
|
||||
type="button"
|
||||
>
|
||||
Alliance
|
||||
<span class="hint">besoins partages</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mode !== 'none'"
|
||||
class="reset"
|
||||
@click="setMode('none')"
|
||||
type="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||
<div class="annuaire-scroll">
|
||||
<table class="annuaire-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-nom">Prénom</th>
|
||||
<th class="col-besoin">Besoin</th>
|
||||
<th class="col-offre">Ce que j'offre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
||||
<td class="col-nom">{{ f.nom }}</td>
|
||||
<td class="col-besoin">{{ f.besoin }}</td>
|
||||
<td class="col-offre">{{ f.offre }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -73,98 +92,95 @@
|
||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||
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)
|
||||
// Sami(besoin formation+vente) -> Ines(offre vente+formation)
|
||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation+tiers-lieu)
|
||||
// Solution (scoreDirect besoinA vs offreB) :
|
||||
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
||||
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
||||
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
||||
//
|
||||
// Alliance : Lea + Maya (hashtag coaching commun dans besoins)
|
||||
// Sami + Kenji (hashtag formation+vente dans besoins)
|
||||
// Tom + Zoe (hashtag tiers-lieu dans besoins)
|
||||
// Alliance (besoins similaires) :
|
||||
// Lea + Maya (coaching, lancer, offre) ✓
|
||||
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
||||
// Sami + Kenji (vendre, formations) ✓
|
||||
//
|
||||
// Surprise : Lea + Zoe (hashtag facilitation dans offres)
|
||||
// Tom + Roman (hashtag archi dans offres)
|
||||
// Surprise (offres similaires) :
|
||||
// Lea + Zoe (facilitation, groupes) ✓
|
||||
// Tom + Roman (architecture) ✓
|
||||
// Ines + Nael (marketing, formations) ✓
|
||||
|
||||
const FICHES_DEMO: CodevFiche[] = [
|
||||
{
|
||||
id: 1,
|
||||
nom: 'Lea',
|
||||
besoin: 'Structurer mon offre de coaching pour la lancer en septembre',
|
||||
offre: 'Animation de groupes, facilitation de cercles de parole',
|
||||
hashtags: ['coaching', 'facilitation'],
|
||||
id: 1, nom: 'Lea',
|
||||
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
||||
offre: 'Facilitation de groupes et animation de cercles de parole',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nom: 'Sami',
|
||||
besoin: 'Comprendre comment vendre une formation en ligne',
|
||||
offre: 'Developpement web, sites Astro et Nuxt',
|
||||
hashtags: ['formation', 'vente'],
|
||||
id: 2, nom: 'Sami',
|
||||
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
||||
offre: 'Developpement web sur mesure, creation de sites et applications',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nom: 'Ines',
|
||||
besoin: 'Aide pour la facilitation de mes ateliers ecriture',
|
||||
offre: 'Vente de formations en ligne, marketing direct',
|
||||
hashtags: ['vente', 'formation'],
|
||||
id: 3, nom: 'Ines',
|
||||
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
||||
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:02:00Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nom: 'Tom',
|
||||
besoin: 'Trouver un associe pour un projet de tiers-lieu',
|
||||
offre: 'Architecture eco-responsable, conception bioclimatique',
|
||||
hashtags: ['tiers-lieu', 'archi'],
|
||||
id: 4, nom: 'Tom',
|
||||
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
||||
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:03:00Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
nom: 'Maya',
|
||||
besoin: 'Structurer mon offre de coaching freelance',
|
||||
offre: 'Coaching de carriere, accompagnement transition pro',
|
||||
hashtags: ['coaching', 'carriere'],
|
||||
id: 5, nom: 'Maya',
|
||||
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
||||
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:04:00Z',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
nom: 'Kenji',
|
||||
besoin: 'Apprendre a vendre mes formations sans me sentir vendeur',
|
||||
offre: 'Photographie, direction artistique de projets editoriaux',
|
||||
hashtags: ['formation', 'vente'],
|
||||
id: 6, nom: 'Kenji',
|
||||
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
||||
offre: 'Photographie professionnelle et direction artistique editoriale',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
nom: 'Zoe',
|
||||
besoin: 'Trouver des associes pour mon projet de tiers-lieu rural',
|
||||
offre: 'Animation et facilitation de collectifs, intelligence collective',
|
||||
hashtags: ['tiers-lieu', 'facilitation'],
|
||||
id: 7, nom: 'Zoe',
|
||||
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
||||
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:06:00Z',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
nom: 'Nael',
|
||||
besoin: 'Construire un site web pour ma formation',
|
||||
offre: 'Strategie marketing, lancement de produits digitaux',
|
||||
hashtags: ['web', 'strategie'],
|
||||
id: 8, nom: 'Nael',
|
||||
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
||||
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:07:00Z',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
nom: 'Eva',
|
||||
besoin: 'Lancer mon offre de coaching avec une page de vente',
|
||||
offre: 'Ecriture longue forme, articles essais et tribunes',
|
||||
hashtags: ['coaching', 'ecriture'],
|
||||
id: 9, nom: 'Eva',
|
||||
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
||||
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:08:00Z',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
nom: 'Roman',
|
||||
besoin: 'Ameliorer mes articles de blog sur la renovation',
|
||||
offre: 'Architecture, plans techniques pour renovation energetique',
|
||||
hashtags: ['archi', 'reno'],
|
||||
id: 10, nom: 'Roman',
|
||||
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
||||
offre: 'Architecture technique et plans pour renovation energetique',
|
||||
hashtags: [],
|
||||
created_at: '2026-05-08T10:09:00Z',
|
||||
},
|
||||
]
|
||||
@@ -186,7 +202,7 @@ function setMode(newMode: typeof mode.value) {
|
||||
if (newMode === 'none') {
|
||||
matches.value = []
|
||||
} else {
|
||||
matches.value = computeMatches(fiches.value, newMode)
|
||||
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
<!-- En-tête -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -105,9 +106,13 @@
|
||||
|
||||
<!-- Bouton -->
|
||||
<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>
|
||||
|
||||
<NuxtLink to="/codev/carto" class="skip-link">
|
||||
Voir la carte sans créer de fiche →
|
||||
</NuxtLink>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@@ -115,12 +120,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
||||
const isEdit = computed(() => editId.value !== null)
|
||||
|
||||
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
||||
|
||||
useHead({ title: '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') {
|
||||
activeTip.value = activeTip.value === field ? null : field
|
||||
@@ -136,15 +158,18 @@ async function submit() {
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
await $fetch('/api/codev/fiches', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
nom: form.value.nom,
|
||||
besoin: form.value.besoin,
|
||||
offre: form.value.offre,
|
||||
hashtags,
|
||||
},
|
||||
})
|
||||
const payload = {
|
||||
nom: form.value.nom,
|
||||
besoin: form.value.besoin,
|
||||
offre: form.value.offre,
|
||||
hashtags,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
||||
} else {
|
||||
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
||||
}
|
||||
await navigateTo('/codev/carto')
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
||||
@@ -173,6 +198,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;
|
||||
@@ -357,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) {
|
||||
|
||||
94
pages/codev/qr.vue
Normal file
94
pages/codev/qr.vue
Normal 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>
|
||||
@@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
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' })
|
||||
}
|
||||
|
||||
// Cookie session (user + admin)
|
||||
setCookie(event, 'codev_session', 'ok', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
@@ -27,5 +31,16 @@ export default defineEventHandler(async (event) => {
|
||||
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 }
|
||||
})
|
||||
|
||||
25
server/api/codev/fiches/[id].delete.ts
Normal file
25
server/api/codev/fiches/[id].delete.ts
Normal 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 }
|
||||
})
|
||||
34
server/api/codev/fiches/[id].get.ts
Normal file
34
server/api/codev/fiches/[id].get.ts
Normal 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 || '',
|
||||
}
|
||||
})
|
||||
59
server/api/codev/fiches/[id].patch.ts
Normal file
59
server/api/codev/fiches/[id].patch.ts
Normal 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 }
|
||||
})
|
||||
5
server/api/codev/me.get.ts
Normal file
5
server/api/codev/me.get.ts
Normal 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 }
|
||||
})
|
||||
@@ -8,8 +8,9 @@ export default defineEventHandler((event) => {
|
||||
// Seulement les routes sous /codev/
|
||||
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/qr' || path.startsWith('/codev/qr/')) return
|
||||
|
||||
// Vérification cookie
|
||||
const session = getCookie(event, 'codev_session')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user