GrateWizard bloc dédié, messagerie libre, page numérique 3 piliers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- GrateWizard : lancement URL simple (plus de popup embed), bloc
  dédié violet sur la home entre axes et événement
- Messagerie : plus de champs obligatoires, plus de champ email
  séparé, hint email dans le message, remerciement onboarding
- Page /numerique : 3 piliers (Logiciel libre, WoT, Cloud libre)
  avec projets associés, remplace les extraits livre hors-sujet
- Admin : carte Messages ajoutée au dashboard
- Safelist icônes complétée

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-03-05 03:06:48 +01:00
parent 3a5c40a886
commit c564e7be5f
9 changed files with 279 additions and 119 deletions

View File

@@ -40,8 +40,28 @@
</div>
</UiScrollReveal>
<!-- Bloc GrateWizard -->
<UiScrollReveal v-if="gw" :delay="250">
<button class="gw-block" @click="launchGW">
<div class="gw-icon">
<div class="i-lucide-sparkles h-7 w-7" />
</div>
<div class="gw-text">
<h2 class="font-display text-xl font-bold text-white sm:text-2xl">
{{ gw.title }}
</h2>
<p class="text-white/55 text-sm sm:text-base mt-0.5">
{{ gw.subtitle }}
</p>
</div>
<div class="gw-arrow">
<div class="i-lucide-arrow-up-right h-5 w-5" />
</div>
</button>
</UiScrollReveal>
<!-- Bloc Événement -->
<UiScrollReveal v-if="evenement" :delay="300">
<UiScrollReveal v-if="evenement" :delay="350">
<NuxtLink :to="evenement.to" class="event-block">
<div class="event-content">
<div class="event-icon">
@@ -76,6 +96,7 @@ const { data: content } = await usePageContent('home')
const { launch } = useGrateWizard()
const axes = computed(() => (content.value as any)?.axes)
const gw = computed(() => (content.value as any)?.gratewizard)
const evenement = computed(() => (content.value as any)?.evenement)
function launchGW() {
@@ -135,11 +156,84 @@ function launchGW() {
flex-shrink: 0;
}
/* GrateWizard block */
.gw-block {
display: flex;
align-items: center;
gap: 1.25rem;
width: 100%;
padding: 1.75rem 2rem;
border-radius: 1rem;
border: none;
background: linear-gradient(135deg, hsl(280 50% 20% / 0.35), hsl(260 40% 15% / 0.25));
box-shadow: 0 0 40px hsl(280 60% 50% / 0.06), inset 0 1px 0 hsl(280 60% 70% / 0.08);
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
color: inherit;
}
.gw-block:hover {
background: linear-gradient(135deg, hsl(280 50% 24% / 0.45), hsl(260 40% 18% / 0.35));
box-shadow: 0 0 60px hsl(280 60% 50% / 0.12), inset 0 1px 0 hsl(280 60% 70% / 0.12);
transform: translateY(-2px);
}
.gw-block:active {
transform: translateY(0);
}
.gw-icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 0.875rem;
background: hsl(280 60% 55% / 0.18);
color: hsl(280 60% 72%);
flex-shrink: 0;
box-shadow: 0 0 20px hsl(280 60% 50% / 0.15);
}
.gw-text {
flex: 1;
min-width: 0;
}
.gw-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(280 60% 55% / 0.1);
color: hsl(280 60% 65%);
flex-shrink: 0;
transition: all 0.2s;
}
.gw-block:hover .gw-arrow {
background: hsl(280 60% 55% / 0.2);
color: hsl(280 60% 80%);
transform: translate(2px, -2px);
}
@media (max-width: 640px) {
.event-block {
flex-direction: column;
align-items: flex-start;
padding: 1.5rem;
}
.gw-block {
padding: 1.25rem;
gap: 1rem;
}
.gw-arrow {
display: none;
}
}
</style>

View File

@@ -7,40 +7,36 @@
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3>
<form v-if="!submitted" class="space-y-3" @submit.prevent="send">
<div class="grid gap-3 sm:grid-cols-2">
<input
v-model="form.author"
type="text"
placeholder="Votre nom *"
required
class="msg-input"
/>
<input
v-model="form.email"
type="email"
placeholder="Email (optionnel)"
class="msg-input"
/>
</div>
<input
v-model="form.author"
type="text"
placeholder="Votre nom"
class="msg-input"
/>
<p class="text-white/30 text-xs -mt-1 px-1">Pour recevoir une réponse, laissez votre e-mail dans le message.</p>
<textarea
v-model="form.text"
placeholder="Votre message *"
required
placeholder="Votre message"
rows="3"
class="msg-input resize-none"
/>
<div class="flex justify-end">
<button type="submit" class="btn-primary text-sm" :disabled="sending">
<button type="submit" class="btn-primary text-sm" :disabled="sending || !canSend">
<div v-if="sending" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
Envoyer
</button>
</div>
</form>
<div v-else class="text-center py-4">
<div class="i-lucide-check-circle h-8 w-8 text-green-400 mx-auto mb-2" />
<p class="text-white/80">Merci pour votre message !</p>
<p class="text-white/40 text-sm mt-1">Il sera visible après modération.</p>
<div v-else class="text-center py-6">
<div class="i-lucide-heart-handshake h-10 w-10 text-primary mx-auto mb-3" />
<p class="text-white/90 font-display text-lg font-semibold">Merci pour votre message !</p>
<p class="text-white/55 text-sm mt-2 max-w-md mx-auto leading-relaxed">
Il sera lu et traité dans un délai... humainement raisonnable.
</p>
<p class="text-primary/60 text-xs mt-4 italic">
Chaque message est un premier pas dans l'aventure.
</p>
</div>
</div>
</UiScrollReveal>
@@ -72,11 +68,14 @@
<script setup lang="ts">
const { data: messages } = await useFetch('/api/messages')
const form = reactive({ author: '', email: '', text: '' })
const form = reactive({ author: '', text: '' })
const sending = ref(false)
const submitted = ref(false)
const canSend = computed(() => form.text.trim().length > 0)
async function send() {
if (!canSend.value) return
sending.value = true
try {
await $fetch('/api/messages', { method: 'POST', body: form })

View File

@@ -1,19 +1,10 @@
export function useGrateWizard() {
const appConfig = useAppConfig()
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
const { url } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
function launch(e?: Event) {
const w = popup.width
const h = popup.height
const left = Math.round((window.screen.width - w) / 2)
const top = Math.round((window.screen.height - h) / 2)
const embedUrl = `${url}?embed=true&hideTabBar=true&tab=mn`
const win = window.open(
embedUrl,
'grateWizard',
`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes`,
)
if (win) e?.preventDefault()
window.open(url, '_blank', 'noopener,noreferrer')
e?.preventDefault()
}
return { url, launch }

View File

@@ -27,6 +27,12 @@
<p class="text-sm text-white/50">Métadonnées des pistes</p>
</NuxtLink>
<NuxtLink to="/admin/messages" class="dash-card">
<div class="i-lucide-message-square h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Messages</h2>
<p class="text-sm text-white/50">Modération des messages visiteurs</p>
</NuxtLink>
<NuxtLink to="/admin/media" class="dash-card">
<div class="i-lucide-image h-8 w-8 text-accent mb-2" />
<h2 class="text-lg font-semibold text-white">Médias</h2>

View File

@@ -1,75 +1,52 @@
<template>
<div class="relative overflow-hidden section-padding">
<!-- Shadok jardinier: character with watering can and plant -->
<!-- Shadok jardinier -->
<svg class="shadok-jardinier" viewBox="0 0 240 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="110" cy="160" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="110" cy="96" r="25" fill="currentColor" opacity="0.8"/>
<!-- Straw hat -->
<ellipse cx="110" cy="78" rx="35" ry="8" fill="currentColor" opacity="0.4"/>
<path d="M85 78 Q110 60 135 78" fill="currentColor" opacity="0.35"/>
<!-- Eyes (focused, looking down at plant) -->
<circle cx="102" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="120" cy="94" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="103" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="121" cy="95" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Smile -->
<path d="M103 106 Q110 111 118 106" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm holding watering can -->
<line x1="70" y1="150" x2="40" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Watering can -->
<rect x="20" y="165" width="30" height="20" rx="3" fill="currentColor" opacity="0.4"/>
<line x1="20" y1="168" x2="10" y2="160" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Water drops -->
<circle cx="12" cy="165" r="1.5" fill="currentColor" opacity="0.25"/>
<circle cx="8" cy="170" r="1.5" fill="currentColor" opacity="0.2"/>
<circle cx="15" cy="172" r="1.5" fill="currentColor" opacity="0.2"/>
<!-- Other arm -->
<line x1="150" y1="150" x2="170" y2="180" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="95" y1="205" x2="85" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="125" y1="205" x2="135" y2="260" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Plant -->
<line x1="180" y1="220" x2="180" y2="180" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<path d="M180 195 Q195 185 190 175" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 205 Q165 195 168 185" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35"/>
<path d="M180 185 Q192 172 188 165" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Pot -->
<path d="M170 220 L175 240 L185 240 L190 220 Z" fill="currentColor" opacity="0.35"/>
</svg>
<!-- Shadok bâtisseur: character with trowel building a wall -->
<!-- Shadok bâtisseur -->
<svg class="shadok-batisseur" viewBox="0 0 260 300" fill="none" aria-hidden="true">
<!-- Body -->
<ellipse cx="130" cy="150" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
<!-- Head -->
<circle cx="130" cy="86" r="25" fill="currentColor" opacity="0.8"/>
<!-- Hard hat -->
<ellipse cx="130" cy="68" rx="28" ry="6" fill="currentColor" opacity="0.4"/>
<rect x="108" y="60" width="44" height="10" rx="3" fill="currentColor" opacity="0.35"/>
<!-- Eyes (determined) -->
<circle cx="122" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="140" cy="84" r="4" fill="currentColor" opacity="0.2"/>
<circle cx="123" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<circle cx="141" cy="83" r="1.8" fill="currentColor" opacity="0.5"/>
<!-- Grin -->
<path d="M123 96 Q130 101 138 96" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.3"/>
<!-- Arm with trowel -->
<line x1="170" y1="140" x2="210" y2="120" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Trowel -->
<polygon points="210,115 230,110 225,120 210,122" fill="currentColor" opacity="0.45"/>
<line x1="210" y1="118" x2="200" y2="125" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" opacity="0.4"/>
<!-- Other arm -->
<line x1="90" y1="145" x2="65" y2="170" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Legs -->
<line x1="115" y1="195" x2="105" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<line x1="145" y1="195" x2="155" y2="255" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" opacity="0.6"/>
<!-- Wall (bricks) -->
<rect x="40" y="200" width="50" height="16" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="45" y="183" width="40" height="16" rx="1" fill="currentColor" opacity="0.28"/>
<rect x="50" y="166" width="30" height="16" rx="1" fill="currentColor" opacity="0.25"/>
<!-- Brick lines -->
<line x1="65" y1="200" x2="65" y2="216" stroke="currentColor" stroke-width="1" opacity="0.15"/>
<line x1="55" y1="183" x2="55" y2="199" stroke="currentColor" stroke-width="1" opacity="0.15"/>
</svg>
@@ -80,29 +57,49 @@
<h1 class="page-title font-display font-bold tracking-tight text-white">
{{ content?.title }}
</h1>
<p class="mt-4 mx-auto max-w-2xl text-white/60">
<p class="mt-4 mx-auto max-w-2xl text-white/60 leading-relaxed">
{{ content?.description }}
</p>
</header>
<div class="mx-auto max-w-3xl flex flex-col gap-6">
<div
v-for="(extract, i) in content?.extracts"
:key="i"
class="card-surface"
v-for="pillar in content?.pillars"
:key="pillar.id"
class="pillar-card"
>
<p class="mb-2 font-mono text-xs tracking-widest text-accent uppercase">
{{ extract.chapter }}
</p>
<blockquote class="border-l-2 border-primary/30 pl-4 text-white/70 italic leading-relaxed whitespace-pre-line">
{{ extract.text }}
</blockquote>
<div class="mt-4">
<div class="pillar-header">
<div class="pillar-icon">
<div :class="`i-lucide-${pillar.icon}`" class="h-5 w-5" />
</div>
<h2 class="font-display text-xl font-bold text-white">
{{ pillar.label }}
</h2>
<span v-if="pillar.gestation" class="gestation-badge">
<div class="i-lucide-flask-conical h-3 w-3" />
En gestation
</span>
</div>
<p class="text-white/65 leading-relaxed whitespace-pre-line mt-3">{{ pillar.text }}</p>
<!-- Project card -->
<div v-if="pillar.project" class="project-card mt-4">
<div class="project-icon">
<div class="i-lucide-rocket h-4 w-4" />
</div>
<div>
<span class="font-display font-semibold text-white text-sm">{{ pillar.project.name }}</span>
<span class="text-white/45 text-sm ml-2">{{ pillar.project.text }}</span>
</div>
</div>
<div v-if="pillar.to" class="mt-4">
<NuxtLink
:to="`/modele-eco/${extract.chapterSlug}`"
:to="pillar.to"
class="inline-flex items-center gap-1 text-sm text-primary hover:text-primary/80 transition-colors"
>
Lire le chapitre
En savoir plus
<div class="i-lucide-arrow-right h-3.5 w-3.5" />
</NuxtLink>
</div>
@@ -129,6 +126,73 @@ useHead({
font-size: clamp(2rem, 5vw, 2.75rem);
}
.pillar-card {
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-text) / 0.08);
background: hsl(var(--color-surface));
transition: border-color 0.2s;
}
.pillar-card:hover {
border-color: hsl(var(--color-primary) / 0.2);
}
.pillar-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pillar-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
box-shadow: 0 0 12px hsl(var(--color-primary) / 0.12);
flex-shrink: 0;
}
.gestation-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-accent) / 0.12);
color: hsl(var(--color-accent));
font-size: 0.7rem;
font-weight: 500;
font-family: var(--font-mono);
}
.project-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-bg) / 0.5);
border: 1px solid hsl(var(--color-primary) / 0.1);
}
.project-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
flex-shrink: 0;
}
.shadok-jardinier {
position: absolute;
left: 2%;

View File

@@ -25,6 +25,8 @@ export default defineNuxtConfig({
'i-lucide-droplets', 'i-lucide-calendar-heart',
// Action icons
'i-lucide-play', 'i-lucide-book-open', 'i-lucide-sparkles',
'i-lucide-heart-handshake', 'i-lucide-arrow-up-right',
'i-lucide-rocket', 'i-lucide-flask-conical', 'i-lucide-arrow-right',
// Decision page
'i-lucide-vote', 'i-lucide-scroll-text', 'i-lucide-git-branch',
],

View File

@@ -1,8 +1,8 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{ author: string; email?: string; text: string }>(event)
if (!body.author?.trim() || !body.text?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Nom et message requis' })
if (!body.text?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Message requis' })
}
const data = await readYaml<{ messages: any[] }>('messages.yml')

View File

@@ -79,10 +79,6 @@ axes:
- id: open-pdf
label: Lecture du livre
icon: book-open
- id: launch-gratewizard
label: grateWizard
icon: sparkles
secondary: true
- label: Productions collectives
description: Une plateforme pour faciliter la création d'équipes et la réalisation de productions à l'échelle des bassins de vie.
to: /gestation/productions-collectives
@@ -102,6 +98,12 @@ axes:
gestation: true
icon: droplets
gratewizard:
title: grateWizard
subtitle: Le compagnon interactif du livre et du projet
description: Explorez le modèle économique, posez vos questions, suivez le fil du raisonnement.
icon: sparkles
evenement:
title: Le librodrome,
subtitle: c'est également un événement.

View File

@@ -1,46 +1,48 @@
kicker: Autonomie numérique
title: Le code source
description: Des passages du livre qui éclairent la démarche d'autonomie numérique — maîtriser le code source, c'est maîtriser l'outil.
description: "Maîtriser le code source, c'est maîtriser l'outil. L'autonomie numérique est le socle : sans elle, pas de monnaie libre, pas de décision souveraine."
meta:
title: Autonomie numérique
extracts:
- chapter: Introduction
chapterSlug: 01-introduction
pillars:
- id: logiciel-libre
label: Logiciel libre
icon: code-2
text: >
Ben. Pour l'autonomie.
...C'est tout — sauf un repli.
Balkanisation ? que nenni !
Réfuter l'autonomie... c'est fallacieux,
c'est nous bannir en tant qu'adultes, bien vivants,
restez là sans mot dire,
"restez des enfants !"...
mmh, suspect et sans avenir.
- chapter: Introduction
chapterSlug: 01-introduction
Le logiciel libre n'est pas qu'une question technique.
C'est la condition d'existence d'outils qui nous appartiennent —
que l'on peut auditer, modifier, partager.
Sans logiciel libre, toute promesse de souveraineté numérique est creuse.
project:
name: wishBounty
text: Application pour le financement fléché des développements libres.
gestation: true
to: /gestation/logiciel-libre
- id: authentification-wot
label: "Authentification — WoT"
icon: share-2
text: >
Ne plus subir les agendas. Créer les nôtr'.
On manque de repères ?... Entre autres, ...
Faut les trouver,...
En produisant, les inventer.
- chapter: "Raison d'être d'une monnaie"
chapterSlug: 04-monnaie
Une toile de confiance décentralisée, sans autorité centrale.
Chaque identité est certifiée par ses pairs — pas par un serveur,
pas par une entreprise. C'est le fondement de la monnaie libre Ğ1
et de toute gouvernance entre égaux.
project:
name: trustWallet
text: Gestionnaire de confiances.
gestation: true
to: /gestation/authentification-wot
- id: cloud-libre
label: Cloud libre
icon: cloud
text: >
Même accès pour tous.
Même pouvoir de création.
Ce n'est plus "Que la dette soit".
C'est "Que l'équilibre soit".
- chapter: "Créer une économie ?"
chapterSlug: 06-economie
text: >
Le D.U, c'est une mesure.
Pour ne plus obliger. Pour ne plus devoir.
Je donne à mon économie, j'alimente le réservoir.
Je compte sur les autres, sur mon économie,
Pour y trouver ma pleine mesure.
- chapter: "Et maintenant ?… action ?"
chapterSlug: 10-maintenant
text: >
Mais la monnaie n'est pas la richesse.
C'est juste le mètre... pas le tissu.
C'est le baromètre... pas le climat.
Ne confondons pas la carte et le territoire.
Héberger ses propres services pour ne dépendre de personne.
Serveurs, noms de domaine, infrastructure.
Un bouquet de services complet — Drive, Visio, Forum, Wiki, CMS —
et demain, une IA frugale localisée.
project:
name: Bouquet de services
text: "Drive, Visio, Forum, Wiki, CMS. IA frugale localisée."
gestation: true
to: /gestation/cloud-libre