initiation librodrome
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Admin authentication
|
||||
NUXT_ADMIN_PASSWORD=changeme
|
||||
NUXT_ADMIN_SECRET=change-this-to-a-random-secret-at-least-32-chars
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
29
app/app.config.ts
Normal file
29
app/app.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default defineAppConfig({
|
||||
site: {
|
||||
name: 'Le Librodrome',
|
||||
description: 'Une économie du don — enfin concevable. Un livre et 9 chansons, lecture guidée et écoute libre.',
|
||||
url: 'https://librodrome.org',
|
||||
},
|
||||
header: {
|
||||
height: '4rem',
|
||||
nav: [
|
||||
{ label: 'Accueil', to: '/' },
|
||||
{ label: 'Lire', to: '/lire' },
|
||||
{ label: 'Écouter', to: '/ecouter' },
|
||||
{ label: 'À propos', to: '/a-propos' },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
credits: '© 2026 Le Librodrome — Production collective',
|
||||
links: [
|
||||
{ label: 'Mentions légales', to: '/mentions-legales' },
|
||||
],
|
||||
},
|
||||
gratewizard: {
|
||||
url: '/gratewizard-app/',
|
||||
popup: {
|
||||
width: 420,
|
||||
height: 720,
|
||||
},
|
||||
},
|
||||
})
|
||||
28
app/app.vue
Normal file
28
app/app.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="min-h-dvh">
|
||||
<NuxtLoadingIndicator color="hsl(12, 76%, 48%)" />
|
||||
<NuxtRouteAnnouncer />
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
<ClientOnly>
|
||||
<PlayerPersistent />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => {
|
||||
const siteName = site.value?.identity.name ?? 'Le Librodrome'
|
||||
return title ? `${title} — ${siteName}` : siteName
|
||||
},
|
||||
meta: [
|
||||
{ name: 'description', content: site.value?.identity.description ?? '' },
|
||||
],
|
||||
})
|
||||
</script>
|
||||
166
app/assets/css/animations.css
Normal file
166
app/assets/css/animations.css
Normal file
@@ -0,0 +1,166 @@
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px hsl(12 76% 48% / 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 24px hsl(12 76% 48% / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* Utility classes for animations */
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
animation: fade-in-down 0.6s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.5s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.5s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.4s var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Staggered animation delays */
|
||||
.stagger-1 { animation-delay: 0.1s; }
|
||||
.stagger-2 { animation-delay: 0.2s; }
|
||||
.stagger-3 { animation-delay: 0.3s; }
|
||||
.stagger-4 { animation-delay: 0.4s; }
|
||||
.stagger-5 { animation-delay: 0.5s; }
|
||||
|
||||
/* Scroll reveal base state */
|
||||
.scroll-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.6s var(--ease-out-expo),
|
||||
transform 0.6s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.scroll-reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Book Player 3D animations */
|
||||
@keyframes fade-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes book-open {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotateY(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-turn {
|
||||
0% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateY(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-scale-in {
|
||||
animation: fade-scale-in 0.4s cubic-bezier(0.645, 0.045, 0.355, 1) both;
|
||||
}
|
||||
14
app/assets/css/fonts.css
Normal file
14
app/assets/css/fonts.css
Normal file
@@ -0,0 +1,14 @@
|
||||
/* Font declarations are loaded via Bunny Fonts CDN in nuxt.config.ts */
|
||||
/* This file provides fallback and utility classes */
|
||||
|
||||
.font-display {
|
||||
font-family: 'Syne', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
90
app/assets/css/main.css
Normal file
90
app/assets/css/main.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@import './fonts.css';
|
||||
@import './animations.css';
|
||||
@import './typography.css';
|
||||
|
||||
:root {
|
||||
--color-primary: 12 76% 48%;
|
||||
--color-accent: 36 80% 52%;
|
||||
--color-bg: 20 8% 3.5%;
|
||||
--color-surface: 20 8% 8%;
|
||||
--color-surface-light: 20 8% 13%;
|
||||
--color-text: 0 0% 100%;
|
||||
--color-text-muted: 0 0% 100% / 0.6;
|
||||
|
||||
--header-height: 4rem;
|
||||
--player-height: 5rem;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
--font-display: 'Syne', sans-serif;
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
background-color: hsl(var(--color-bg));
|
||||
color: hsl(var(--color-text));
|
||||
color-scheme: dark;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: hsl(var(--color-primary) / 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--color-text) / 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--color-text) / 0.25);
|
||||
}
|
||||
|
||||
/* Page transitions */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 300ms var(--ease-out-expo),
|
||||
transform 300ms var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
173
app/assets/css/typography.css
Normal file
173
app/assets/css/typography.css
Normal file
@@ -0,0 +1,173 @@
|
||||
/* Prose styles for book chapter reading */
|
||||
.prose {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: hsl(0 0% 100% / 0.90);
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
color: white;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid hsl(12 76% 48% / 0.4);
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: white;
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.25rem, 3vw, 1.625rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: hsl(0 0% 100% / 0.92);
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prose h3::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(36 80% 52%);
|
||||
margin-right: 0.625rem;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -0.1em;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.065rem, 2.5vw, 1.25rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: hsl(0 0% 100% / 0.85);
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.prose h4::before {
|
||||
content: '//';
|
||||
font-family: var(--font-mono);
|
||||
color: hsl(36 80% 52%);
|
||||
margin-right: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Lede paragraph — first p after H2/H3 */
|
||||
.prose h2 + p,
|
||||
.prose h3 + p {
|
||||
font-size: 1.175rem;
|
||||
color: hsl(0 0% 100% / 0.75);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: hsl(12 76% 68%);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: hsl(12 76% 58% / 0.3);
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 0.2s;
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
text-decoration-color: hsl(12 76% 58%);
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 1.5rem;
|
||||
border-left: 3px solid hsl(12 76% 58%);
|
||||
background: hsl(240 10% 8%);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
font-style: italic;
|
||||
color: hsl(0 0% 100% / 0.75);
|
||||
}
|
||||
|
||||
.prose blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
background: hsl(240 10% 12%);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(31 97% 66%);
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: hsl(240 10% 6%);
|
||||
border: 1px solid hsl(0 0% 100% / 0.08);
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: hsl(0 0% 100% / 0.87);
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.prose li::marker {
|
||||
color: hsl(12 76% 58%);
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
margin: 3rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid hsl(0 0% 100% / 0.1);
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose em {
|
||||
color: hsl(0 0% 100% / 0.9);
|
||||
}
|
||||
|
||||
.prose img {
|
||||
border-radius: 0.75rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
117
app/components/admin/AdminFieldList.vue
Normal file
117
app/components/admin/AdminFieldList.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="field-label">{{ label }}</label>
|
||||
<div
|
||||
v-for="(item, i) in modelValue"
|
||||
:key="i"
|
||||
class="list-item"
|
||||
>
|
||||
<slot :item="item" :index="i" :update="(val: any) => updateItem(i, val)" />
|
||||
<button class="list-remove" @click="removeItem(i)" aria-label="Supprimer">
|
||||
<div class="i-lucide-x h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="list-add" @click="addItem">
|
||||
<div class="i-lucide-plus h-4 w-4" />
|
||||
{{ addLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
modelValue: any[]
|
||||
addLabel?: string
|
||||
defaultItem?: () => any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any[]]
|
||||
}>()
|
||||
|
||||
function updateItem(index: number, value: any) {
|
||||
const copy = [...props.modelValue]
|
||||
copy[index] = value
|
||||
emit('update:modelValue', copy)
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
const copy = [...props.modelValue]
|
||||
copy.splice(index, 1)
|
||||
emit('update:modelValue', copy)
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const newItem = props.defaultItem ? props.defaultItem() : {}
|
||||
emit('update:modelValue', [...props.modelValue, newItem])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
}
|
||||
|
||||
.list-item > :deep(*:first-child) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(0 60% 45% / 0.15);
|
||||
color: hsl(0 60% 65%);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.list-remove:hover {
|
||||
background: hsl(0 60% 45% / 0.3);
|
||||
}
|
||||
|
||||
.list-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(20 8% 22%);
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.list-add:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
color: hsl(12 76% 68%);
|
||||
}
|
||||
</style>
|
||||
56
app/components/admin/AdminFieldText.vue
Normal file
56
app/components/admin/AdminFieldText.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label :for="id" class="field-label">{{ label }}</label>
|
||||
<input
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
class="field-input"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
}
|
||||
|
||||
.field-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
60
app/components/admin/AdminFieldTextarea.vue
Normal file
60
app/components/admin/AdminFieldTextarea.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<label :for="id" class="field-label">{{ label }}</label>
|
||||
<textarea
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
class="field-textarea"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
modelValue: string
|
||||
rows?: number
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const id = computed(() => `field-${props.label.toLowerCase().replace(/\s+/g, '-')}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
}
|
||||
|
||||
.field-textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
55
app/components/admin/AdminFormSection.vue
Normal file
55
app/components/admin/AdminFormSection.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<details class="admin-section" :open="open">
|
||||
<summary class="admin-section-header">
|
||||
<div class="i-lucide-chevron-right h-4 w-4 transition-transform section-arrow" />
|
||||
<span>{{ title }}</span>
|
||||
</summary>
|
||||
<div class="admin-section-body">
|
||||
<slot />
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
open?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-section {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background: hsl(20 8% 6%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-section-header:hover {
|
||||
background: hsl(20 8% 8%);
|
||||
}
|
||||
|
||||
.admin-section[open] .section-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.admin-section-body {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
110
app/components/admin/AdminMarkdownEditor.vue
Normal file
110
app/components/admin/AdminMarkdownEditor.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="md-editor">
|
||||
<div class="md-tabs">
|
||||
<button
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
>
|
||||
Édition
|
||||
</button>
|
||||
<button
|
||||
class="md-tab"
|
||||
:class="{ 'md-tab--active': tab === 'preview' }"
|
||||
@click="tab = 'preview'"
|
||||
>
|
||||
Aperçu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="tab === 'edit'"
|
||||
:value="modelValue"
|
||||
class="md-textarea"
|
||||
:rows="rows"
|
||||
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="md-preview prose"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
rows?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const tab = ref<'edit' | 'preview'>('edit')
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
// Simple markdown rendering for preview
|
||||
return props.modelValue
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/^(?!<[hp])(.+)/gm, '<p>$1</p>')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-editor {
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.md-tabs {
|
||||
display: flex;
|
||||
background: hsl(20 8% 6%);
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
}
|
||||
|
||||
.md-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: hsl(20 8% 50%);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.md-tab--active {
|
||||
color: white;
|
||||
background: hsl(20 8% 10%);
|
||||
}
|
||||
|
||||
.md-textarea {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.md-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.md-preview {
|
||||
padding: 1rem;
|
||||
min-height: 20rem;
|
||||
background: hsl(20 8% 4%);
|
||||
}
|
||||
</style>
|
||||
211
app/components/admin/AdminMediaBrowser.vue
Normal file
211
app/components/admin/AdminMediaBrowser.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="media-browser">
|
||||
<!-- Toolbar -->
|
||||
<div class="media-toolbar">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.value"
|
||||
class="filter-btn"
|
||||
:class="{ 'filter-btn--active': filter === t.value }"
|
||||
@click="filter = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs text-white/40">{{ filtered.length }} fichier(s)</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="media-grid">
|
||||
<div
|
||||
v-for="file in filtered"
|
||||
:key="file.path"
|
||||
class="media-card"
|
||||
:class="{ 'media-card--selected': selected === file.path }"
|
||||
@click="selected = selected === file.path ? null : file.path"
|
||||
>
|
||||
<div v-if="file.type === 'image'" class="media-thumb">
|
||||
<img :src="file.path" :alt="file.name" />
|
||||
</div>
|
||||
<div v-else class="media-icon">
|
||||
<div
|
||||
:class="file.type === 'audio' ? 'i-lucide-music' : file.type === 'document' ? 'i-lucide-file-text' : 'i-lucide-file'"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<div class="media-info">
|
||||
<span class="media-name">{{ file.name }}</span>
|
||||
<span class="media-size">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions for selected -->
|
||||
<div v-if="selected" class="media-actions">
|
||||
<code class="text-xs text-accent">{{ selected }}</code>
|
||||
<button class="delete-btn" @click="$emit('delete', selected)">
|
||||
<div class="i-lucide-trash-2 h-4 w-4" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface MediaFile {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
type: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
files: MediaFile[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
delete: [path: string]
|
||||
}>()
|
||||
|
||||
const filter = ref('all')
|
||||
const selected = ref<string | null>(null)
|
||||
|
||||
const types = [
|
||||
{ value: 'all', label: 'Tous' },
|
||||
{ value: 'image', label: 'Images' },
|
||||
{ value: 'audio', label: 'Audio' },
|
||||
{ value: 'document', label: 'Documents' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return props.files
|
||||
return props.files.filter(f => f.type === filter.value)
|
||||
})
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn--active {
|
||||
background: hsl(12 76% 48% / 0.15);
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
color: hsl(12 76% 68%);
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.media-card:hover {
|
||||
border-color: hsl(20 8% 22%);
|
||||
}
|
||||
|
||||
.media-card--selected {
|
||||
border-color: hsl(12 76% 48%);
|
||||
box-shadow: 0 0 0 1px hsl(12 76% 48% / 0.3);
|
||||
}
|
||||
|
||||
.media-thumb {
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background: hsl(20 8% 6%);
|
||||
}
|
||||
|
||||
.media-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 6%);
|
||||
color: hsl(20 8% 40%);
|
||||
}
|
||||
|
||||
.media-info {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-size: 0.72rem;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-size {
|
||||
font-size: 0.65rem;
|
||||
color: hsl(20 8% 40%);
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(0 60% 45% / 0.3);
|
||||
background: hsl(0 60% 45% / 0.1);
|
||||
color: hsl(0 60% 65%);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: hsl(0 60% 45% / 0.2);
|
||||
}
|
||||
</style>
|
||||
107
app/components/admin/AdminMediaUpload.vue
Normal file
107
app/components/admin/AdminMediaUpload.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div
|
||||
class="upload-zone"
|
||||
:class="{ 'upload-zone--active': isDragging }"
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,audio/*,.pdf"
|
||||
class="hidden"
|
||||
@change="handleFiles"
|
||||
/>
|
||||
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<div class="i-lucide-loader-2 h-6 w-6 animate-spin text-primary" />
|
||||
<span>Upload en cours...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-content" @click="fileInput?.click()">
|
||||
<div class="i-lucide-upload h-8 w-8 text-white/30 mb-2" />
|
||||
<p class="text-sm text-white/50">Glissez des fichiers ici ou cliquez pour sélectionner</p>
|
||||
<p class="text-xs text-white/30 mt-1">Images, audio, PDF</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
uploaded: [files: string[]]
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) upload(files)
|
||||
}
|
||||
|
||||
function handleFiles(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files) upload(target.files)
|
||||
}
|
||||
|
||||
async function upload(files: FileList) {
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
|
||||
const result = await $fetch<{ files: string[] }>('/api/admin/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
emit('uploaded', result.files)
|
||||
}
|
||||
finally {
|
||||
uploading.value = false
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-zone {
|
||||
border: 2px dashed hsl(20 8% 22%);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: hsl(12 76% 48% / 0.4);
|
||||
}
|
||||
|
||||
.upload-zone--active {
|
||||
border-color: hsl(12 76% 48%);
|
||||
background: hsl(12 76% 48% / 0.05);
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(20 8% 55%);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
54
app/components/admin/AdminSaveButton.vue
Normal file
54
app/components/admin/AdminSaveButton.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<button
|
||||
class="save-btn"
|
||||
:class="{ 'save-btn--saving': saving, 'save-btn--saved': saved }"
|
||||
:disabled="saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<div v-if="saving" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
|
||||
<div v-else-if="saved" class="i-lucide-check h-4 w-4" />
|
||||
<div v-else class="i-lucide-save h-4 w-4" />
|
||||
{{ saving ? 'Sauvegarde...' : saved ? 'Sauvegardé' : 'Sauvegarder' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
saving?: boolean
|
||||
saved?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.save-btn--saved {
|
||||
background: hsl(140 50% 40%);
|
||||
}
|
||||
</style>
|
||||
148
app/components/admin/AdminSidebar.vue
Normal file
148
app/components/admin/AdminSidebar.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<NuxtLink to="/admin" class="flex items-center gap-2 font-display text-lg font-bold">
|
||||
<div class="i-lucide-settings h-5 w-5 text-primary" />
|
||||
<span class="text-gradient">Admin</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<p class="sidebar-section">Contenu</p>
|
||||
<NuxtLink to="/admin" class="sidebar-link" exact-active-class="sidebar-link--active">
|
||||
<div class="i-lucide-layout-dashboard h-4 w-4" />
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/site" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-globe h-4 w-4" />
|
||||
Site
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/messages" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-message-square h-4 w-4" />
|
||||
Messages
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Pages</p>
|
||||
<NuxtLink to="/admin/pages/home" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-home h-4 w-4" />
|
||||
Accueil
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/lire" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-book-open h-4 w-4" />
|
||||
Lire
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/ecouter" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-headphones h-4 w-4" />
|
||||
Écouter
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-sparkles h-4 w-4" />
|
||||
GrateWizard
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Livre</p>
|
||||
<NuxtLink to="/admin/book" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
Chapitres
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/songs" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-music h-4 w-4" />
|
||||
Chansons
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Médias</p>
|
||||
<NuxtLink to="/admin/media" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-image h-4 w-4" />
|
||||
Navigateur
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<NuxtLink to="/" class="sidebar-link" target="_blank">
|
||||
<div class="i-lucide-external-link h-4 w-4" />
|
||||
Voir le site
|
||||
</NuxtLink>
|
||||
<button class="sidebar-link w-full" @click="logout">
|
||||
<div class="i-lucide-log-out h-4 w-4" />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
async function logout() {
|
||||
await $fetch('/api/admin/auth/logout', { method: 'POST' })
|
||||
navigateTo('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-sidebar {
|
||||
background: hsl(20 8% 5%);
|
||||
border-right: 1px solid hsl(20 8% 14%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: hsl(20 8% 40%);
|
||||
padding: 1rem 0.75rem 0.375rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(20 8% 60%);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-link--active {
|
||||
background: hsl(12 76% 48% / 0.12);
|
||||
color: hsl(12 76% 68%);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid hsl(20 8% 14%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
app/components/book/BookPdfReader.vue
Normal file
144
app/components/book/BookPdfReader.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="pdf-overlay">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="pdf-reader"
|
||||
@keydown.escape="close"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Top bar -->
|
||||
<div class="pdf-bar">
|
||||
<div class="pdf-bar-title">
|
||||
<div class="i-lucide-book-open h-4 w-4 text-accent" />
|
||||
<span>{{ bpContent?.pdf.barTitle }}</span>
|
||||
</div>
|
||||
<button class="pdf-close" @click="close" aria-label="Fermer">
|
||||
<div class="i-lucide-x h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF embed -->
|
||||
<div class="pdf-viewport">
|
||||
<iframe
|
||||
:src="pdfUrl"
|
||||
class="pdf-frame"
|
||||
:title="bpContent?.pdf.iframeTitle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
|
||||
const overlayRef = ref<HTMLElement>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const pdfUrl = '/pdf/une-economie-du-don.pdf'
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
}
|
||||
if (import.meta.client) {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (import.meta.client) document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-reader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: hsl(20 8% 4%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pdf-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: hsl(20 8% 6% / 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid hsl(20 8% 14%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pdf-bar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-family: var(--font-display, 'Syne', sans-serif);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pdf-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(20 8% 10%);
|
||||
color: hsl(20 8% 55%);
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.pdf-close:hover {
|
||||
background: hsl(12 76% 48% / 0.2);
|
||||
color: white;
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
}
|
||||
|
||||
.pdf-viewport {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pdf-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Overlay transitions */
|
||||
.pdf-overlay-enter-active {
|
||||
animation: pdf-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.pdf-overlay-leave-active {
|
||||
animation: pdf-enter 0.3s cubic-bezier(0.7, 0, 0.84, 0) reverse both;
|
||||
}
|
||||
@keyframes pdf-enter {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
1002
app/components/book/BookPlayer.vue
Normal file
1002
app/components/book/BookPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
11
app/components/book/ChapterContent.vue
Normal file
11
app/components/book/ChapterContent.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<article class="prose">
|
||||
<ContentRenderer :value="content" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
content: any
|
||||
}>()
|
||||
</script>
|
||||
62
app/components/book/ChapterHeader.vue
Normal file
62
app/components/book/ChapterHeader.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<header class="mb-8">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="font-mono text-sm text-primary/70">
|
||||
Chapitre {{ order }}
|
||||
</span>
|
||||
<span v-if="readingTime" class="text-xs text-white/30">
|
||||
· {{ readingTime }}
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="chapter-title font-display font-bold leading-tight tracking-tight text-white">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="description" class="mt-3 text-lg text-white/60">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Associated songs badges -->
|
||||
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="i-lucide-music h-3 w-3" />
|
||||
{{ song.title }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
order: number
|
||||
readingTime?: string
|
||||
chapterSlug: string
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
|
||||
function playSong(song: Song) {
|
||||
loadAndPlay(song)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid hsl(12 76% 48% / 0.4);
|
||||
}
|
||||
</style>
|
||||
27
app/components/book/ChapterNav.vue
Normal file
27
app/components/book/ChapterNav.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<nav class="chapter-nav" aria-label="Navigation des chapitres">
|
||||
<h2 class="mb-4 font-display text-sm font-semibold uppercase tracking-wider text-white/40">
|
||||
Chapitres
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="chapter in chapters" :key="chapter.path">
|
||||
<NuxtLink
|
||||
:to="`/lire/${chapter.stem}`"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-white/5"
|
||||
active-class="bg-primary/10 text-primary font-medium"
|
||||
>
|
||||
<span class="font-mono text-xs text-white/30 w-5 text-right">
|
||||
{{ chapter.order }}
|
||||
</span>
|
||||
<span class="truncate">{{ chapter.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: chapters } = await useAsyncData('book-chapters', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
</script>
|
||||
49
app/components/content/AudioInline.vue
Normal file
49
app/components/content/AudioInline.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="song"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary transition-all hover:bg-primary/20 hover:scale-105 active:scale-95 my-2"
|
||||
@click="handlePlay"
|
||||
>
|
||||
<div
|
||||
:class="isCurrentAndPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span>{{ song.title }}</span>
|
||||
<span class="font-mono text-xs text-primary/60">
|
||||
{{ formatDuration(song.duration) }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
song: string
|
||||
}>()
|
||||
|
||||
const store = usePlayerStore()
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay, togglePlayPause } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
|
||||
const song = computed(() => bookData.getSongById(props.song))
|
||||
|
||||
const isCurrentAndPlaying = computed(() =>
|
||||
store.currentSong?.id === props.song && store.isPlaying,
|
||||
)
|
||||
|
||||
function handlePlay() {
|
||||
if (store.currentSong?.id === props.song) {
|
||||
togglePlayPause()
|
||||
}
|
||||
else if (song.value) {
|
||||
loadAndPlay(song.value)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
8
app/components/content/PullQuote.vue
Normal file
8
app/components/content/PullQuote.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<aside class="pull-quote my-8 rounded-xl border-l-4 border-accent bg-surface p-6">
|
||||
<div class="i-lucide-quote mb-2 h-6 w-6 text-accent/40" />
|
||||
<blockquote class="font-display text-lg font-medium leading-relaxed text-white/85 italic">
|
||||
<slot />
|
||||
</blockquote>
|
||||
</aside>
|
||||
</template>
|
||||
92
app/components/home/BookPresentation.vue
Normal file
92
app/components/home/BookPresentation.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<div class="grid items-center gap-12 md:grid-cols-2">
|
||||
<!-- Book cover -->
|
||||
<UiScrollReveal>
|
||||
<div class="book-cover-wrapper">
|
||||
<div class="book-cover-3d">
|
||||
<img
|
||||
:src="content?.book.coverImage"
|
||||
:alt="content?.book.coverAlt"
|
||||
class="book-cover-img"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<UiScrollReveal>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.bookPresentation.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.bookPresentation.title }}
|
||||
</h2>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal
|
||||
v-for="(paragraph, i) in content?.bookPresentation.description"
|
||||
:key="i"
|
||||
:delay="(i + 1) * 100"
|
||||
>
|
||||
<p class="mt-4 text-lg leading-relaxed text-white/60">
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="300">
|
||||
<div class="mt-8">
|
||||
<UiBaseButton :to="content?.bookPresentation.cta.to">
|
||||
{{ content?.bookPresentation.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.book-cover-wrapper {
|
||||
perspective: 800px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.book-cover-3d {
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
box-shadow:
|
||||
0 12px 40px hsl(0 0% 0% / 0.5),
|
||||
0 0 0 1px hsl(20 8% 15%);
|
||||
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
box-shadow 0.5s ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.book-cover-3d:hover {
|
||||
transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
|
||||
box-shadow:
|
||||
12px 16px 48px hsl(0 0% 0% / 0.6),
|
||||
0 0 0 1px hsl(12 76% 48% / 0.2);
|
||||
}
|
||||
|
||||
.book-cover-img {
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
}
|
||||
</style>
|
||||
97
app/components/home/BookSection.vue
Normal file
97
app/components/home/BookSection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<div class="grid items-center gap-12 md:grid-cols-2">
|
||||
<!-- Book cover -->
|
||||
<UiScrollReveal>
|
||||
<div class="book-cover-wrapper">
|
||||
<div class="book-cover-3d">
|
||||
<img
|
||||
:src="content?.book.coverImage"
|
||||
:alt="content?.book.coverAlt"
|
||||
class="book-cover-img"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Content + CTAs -->
|
||||
<div>
|
||||
<UiScrollReveal>
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.book.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.book.title }}
|
||||
</h2>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<p class="mt-4 text-lg leading-relaxed text-white/60">
|
||||
{{ content?.book.description }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<UiBaseButton @click="$emit('open-player')">
|
||||
<div class="i-lucide-play mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.player }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="accent" @click="$emit('open-pdf')">
|
||||
<div class="i-lucide-book-open mr-2 h-5 w-5" />
|
||||
{{ content?.book.cta.pdf }}
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.book-cover-wrapper {
|
||||
perspective: 800px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.book-cover-3d {
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
box-shadow:
|
||||
0 12px 40px hsl(0 0% 0% / 0.5),
|
||||
0 0 0 1px hsl(20 8% 15%);
|
||||
transition: transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
box-shadow 0.5s ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.book-cover-3d:hover {
|
||||
transform: rotateY(-8deg) rotateX(3deg) scale(1.02);
|
||||
box-shadow:
|
||||
12px 16px 48px hsl(0 0% 0% / 0.6),
|
||||
0 0 0 1px hsl(12 76% 48% / 0.2);
|
||||
}
|
||||
|
||||
.book-cover-img {
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
}
|
||||
</style>
|
||||
44
app/components/home/CooperativeVision.vue
Normal file
44
app/components/home/CooperativeVision.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<UiScrollReveal>
|
||||
<div class="i-lucide-users mx-auto h-12 w-12 text-accent/60 mb-6" />
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.cooperative.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.cooperative.title }}
|
||||
</h2>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal
|
||||
v-for="(paragraph, i) in content?.cooperative.description"
|
||||
:key="i"
|
||||
:delay="(i + 1) * 100"
|
||||
>
|
||||
<p class="mt-6 text-lg leading-relaxed text-white/60" :class="{ 'mt-4': i > 0 }">
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="300">
|
||||
<div class="mt-8">
|
||||
<UiBaseButton variant="ghost" :to="content?.cooperative.cta.to">
|
||||
{{ content?.cooperative.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heading-section {
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
}
|
||||
</style>
|
||||
78
app/components/home/GrateWizardTeaser.vue
Normal file
78
app/components/home/GrateWizardTeaser.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<UiScrollReveal>
|
||||
<div class="gw-card">
|
||||
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8">
|
||||
<!-- Icon -->
|
||||
<div class="gw-icon-wrapper">
|
||||
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<span class="inline-block mb-2 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.grateWizardTeaser.kicker }}
|
||||
</span>
|
||||
<h3 class="heading-h3 font-display font-bold text-white">
|
||||
{{ content?.grateWizardTeaser.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-white/60 md:text-base leading-relaxed">
|
||||
{{ content?.grateWizardTeaser.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<UiBaseButton @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
{{ content?.grateWizardTeaser.cta.launch }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" :to="content?.grateWizardTeaser.cta.more.to">
|
||||
{{ content?.grateWizardTeaser.cta.more.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { launch } = useGrateWizard()
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gw-card {
|
||||
border: 1px solid hsl(40 80% 50% / 0.2);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, hsl(40 80% 50% / 0.05), hsl(40 80% 50% / 0.02));
|
||||
box-shadow: 0 0 40px hsl(40 80% 50% / 0.05);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.gw-card:hover {
|
||||
border-color: hsl(40 80% 50% / 0.35);
|
||||
box-shadow: 0 0 60px hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
|
||||
.heading-h3 {
|
||||
font-size: clamp(1.25rem, 3vw, 1.625rem);
|
||||
}
|
||||
|
||||
.gw-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
border: 1px solid hsl(40 80% 50% / 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
57
app/components/home/HeroSection.vue
Normal file
57
app/components/home/HeroSection.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden section-padding">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
|
||||
|
||||
<!-- Content -->
|
||||
<div class="container-content relative z-10 px-4">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<UiScrollReveal>
|
||||
<p class="mb-3 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
{{ content?.hero.kicker }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<h1 class="font-display font-extrabold leading-tight tracking-tight">
|
||||
<span class="hero-title text-gradient">{{ content?.hero.title }}</span>
|
||||
</h1>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<p class="mt-6 text-lg leading-relaxed text-white/60 md:text-xl">
|
||||
{{ content?.hero.subtitle }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="300">
|
||||
<p class="mt-4 text-base leading-relaxed text-white/45">
|
||||
{{ content?.hero.footnote }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="400">
|
||||
<div class="mt-8 flex justify-center">
|
||||
<UiBaseButton variant="ghost" :to="content?.hero.cta.to">
|
||||
{{ content?.hero.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<HomeMessages />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-title {
|
||||
font-size: clamp(2.25rem, 7vw, 4rem);
|
||||
}
|
||||
</style>
|
||||
141
app/components/home/HomeMessages.vue
Normal file
141
app/components/home/HomeMessages.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="mt-16">
|
||||
<!-- Formulaire -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<div class="message-form-card">
|
||||
<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>
|
||||
<textarea
|
||||
v-model="form.text"
|
||||
placeholder="Votre message *"
|
||||
required
|
||||
rows="3"
|
||||
class="msg-input resize-none"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn-primary text-sm" :disabled="sending">
|
||||
<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>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- 2 derniers messages publiés -->
|
||||
<UiScrollReveal v-if="messages?.length" :delay="600">
|
||||
<div class="mt-8 space-y-4">
|
||||
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3>
|
||||
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 text-sm leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<NuxtLink to="/messages" class="btn-ghost text-sm">
|
||||
Voir tous les messages
|
||||
<div class="i-lucide-arrow-right ml-1 h-3.5 w-3.5" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
const form = reactive({ author: '', email: '', text: '' })
|
||||
const sending = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
async function send() {
|
||||
sending.value = true
|
||||
try {
|
||||
await $fetch('/api/messages', { method: 'POST', body: form })
|
||||
submitted.value = true
|
||||
}
|
||||
catch {
|
||||
alert('Erreur lors de l\'envoi du message.')
|
||||
}
|
||||
finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return 'à l\'instant'
|
||||
if (minutes < 60) return `il y a ${minutes} min`
|
||||
if (hours < 24) return `il y a ${hours}h`
|
||||
if (days < 30) return `il y a ${days}j`
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-form-card {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.msg-input::placeholder {
|
||||
color: hsl(20 8% 40%);
|
||||
}
|
||||
|
||||
.msg-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.message-card {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
</style>
|
||||
51
app/components/home/SongsPreview.vue
Normal file
51
app/components/home/SongsPreview.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<section class="section-padding bg-surface-600/50">
|
||||
<div class="container-content">
|
||||
<UiScrollReveal>
|
||||
<div class="text-center mb-12">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.songs.kicker }}</p>
|
||||
<h2 class="heading-section font-display font-bold tracking-tight text-white">
|
||||
{{ content?.songs.title }}
|
||||
</h2>
|
||||
<p class="mt-4 mx-auto max-w-2xl text-white/60">
|
||||
{{ content?.songs.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Featured songs grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<UiScrollReveal
|
||||
v-for="(song, i) in featuredSongs"
|
||||
:key="song.id"
|
||||
:delay="i * 100"
|
||||
>
|
||||
<SongItem :song="song" />
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
|
||||
<UiScrollReveal :delay="400">
|
||||
<div class="mt-10 text-center">
|
||||
<UiBaseButton variant="accent" :to="content?.songs.cta.to">
|
||||
{{ content?.songs.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
|
||||
const featuredSongs = computed(() => bookData.getSongs().slice(0, 6))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.heading-section {
|
||||
font-size: clamp(1.625rem, 4vw, 2.125rem);
|
||||
}
|
||||
</style>
|
||||
74
app/components/layout/NavMobile.vue
Normal file
74
app/components/layout/NavMobile.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="overlay">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
@click="emit('update:open', false)"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-menu">
|
||||
<nav
|
||||
v-if="open"
|
||||
class="fixed inset-y-0 right-0 z-50 w-72 bg-surface-600 border-l border-white/8 p-6 shadow-2xl"
|
||||
aria-label="Menu mobile"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<span class="font-display text-lg font-bold text-gradient">Menu</span>
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
aria-label="Fermer le menu"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
<div class="i-lucide-x h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li v-for="item in nav" :key="item.to">
|
||||
<NuxtLink
|
||||
:to="item.to"
|
||||
class="flex items-center gap-3 rounded-lg px-4 py-3 text-base font-medium transition-colors hover:bg-white/5"
|
||||
active-class="bg-primary/10 text-primary"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
open: boolean
|
||||
nav: { label: string; to: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay-enter-active,
|
||||
.overlay-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.overlay-enter-from,
|
||||
.overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-menu-enter-active,
|
||||
.slide-menu-leave-active {
|
||||
transition: transform 0.3s var(--ease-out-expo);
|
||||
}
|
||||
.slide-menu-enter-from,
|
||||
.slide-menu-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
28
app/components/layout/TheFooter.vue
Normal file
28
app/components/layout/TheFooter.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<footer class="border-t border-white/8 bg-surface-600 pb-[var(--player-height)]">
|
||||
<div class="container-content px-4 py-8">
|
||||
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-between">
|
||||
<!-- Credits -->
|
||||
<p class="text-sm text-white/40">
|
||||
{{ site?.footer.credits }}
|
||||
</p>
|
||||
|
||||
<!-- Links -->
|
||||
<nav class="flex items-center gap-4" aria-label="Liens du pied de page">
|
||||
<NuxtLink
|
||||
v-for="link in site?.footer.links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="text-sm text-white/40 transition-colors hover:text-white/70"
|
||||
>
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
</script>
|
||||
41
app/components/layout/TheHeader.vue
Normal file
41
app/components/layout/TheHeader.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-40 border-b border-white/8 bg-surface-bg/80 backdrop-blur-xl">
|
||||
<div class="container-content flex h-[var(--header-height)] items-center justify-between px-4">
|
||||
<!-- Logo -->
|
||||
<NuxtLink to="/" class="flex items-center gap-2 font-display text-lg font-bold tracking-tight">
|
||||
<div class="i-lucide-book-open h-6 w-6 text-primary" />
|
||||
<span class="text-gradient">{{ site?.identity.name }}</span>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
|
||||
<NuxtLink
|
||||
v-for="item in site?.navigation"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost text-sm"
|
||||
active-class="text-white! bg-white/5"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
class="btn-ghost md:hidden !p-2"
|
||||
aria-label="Menu"
|
||||
@click="isMobileMenuOpen = true"
|
||||
>
|
||||
<div class="i-lucide-menu h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
const isMobileMenuOpen = ref(false)
|
||||
</script>
|
||||
61
app/components/player/PlayerControls.vue
Normal file
61
app/components/player/PlayerControls.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Shuffle -->
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:class="{ 'text-primary!': store.isShuffled }"
|
||||
aria-label="Mélanger"
|
||||
@click="toggleShuffle"
|
||||
>
|
||||
<div class="i-lucide-shuffle h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Previous -->
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:disabled="!store.hasPrev"
|
||||
aria-label="Précédent"
|
||||
@click="playPrev"
|
||||
>
|
||||
<div class="i-lucide-skip-back h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-white text-surface-bg transition-transform hover:scale-110 active:scale-95"
|
||||
:aria-label="store.isPlaying ? 'Pause' : 'Lecture'"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<div :class="store.isPlaying ? 'i-lucide-pause' : 'i-lucide-play'" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Next -->
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:disabled="!store.hasNext"
|
||||
aria-label="Suivant"
|
||||
@click="playNext"
|
||||
>
|
||||
<div class="i-lucide-skip-forward h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Repeat -->
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:class="{ 'text-primary!': store.repeatMode !== 'none' }"
|
||||
aria-label="Répéter"
|
||||
@click="store.toggleRepeat()"
|
||||
>
|
||||
<div
|
||||
:class="store.repeatMode === 'one' ? 'i-lucide-repeat-1' : 'i-lucide-repeat'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = usePlayerStore()
|
||||
const { togglePlayPause, playNext, playPrev } = useAudioPlayer()
|
||||
const { toggleShuffle } = usePlaylist()
|
||||
</script>
|
||||
53
app/components/player/PlayerModeToggle.vue
Normal file
53
app/components/player/PlayerModeToggle.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-full bg-surface-200 p-1">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: store.isGuidedMode }"
|
||||
@click="setMode('guided')"
|
||||
>
|
||||
<div class="i-lucide-book-open h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">Guidé</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: !store.isGuidedMode }"
|
||||
@click="setMode('free')"
|
||||
>
|
||||
<div class="i-lucide-headphones h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">Libre</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PlayerMode } from '~/types/player'
|
||||
|
||||
const store = usePlayerStore()
|
||||
|
||||
function setMode(mode: PlayerMode) {
|
||||
store.setMode(mode)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(0 0% 100% / 0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
color: hsl(0 0% 100% / 0.8);
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: hsl(12 76% 48%);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
159
app/components/player/PlayerPersistent.vue
Normal file
159
app/components/player/PlayerPersistent.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<Transition name="player-slide">
|
||||
<div
|
||||
v-if="store.currentSong"
|
||||
class="player-bar fixed inset-x-0 bottom-0 z-70 border-t border-white/8 bg-surface-600/80 backdrop-blur-xl"
|
||||
>
|
||||
<!-- Expanded panel -->
|
||||
<Transition name="panel-expand">
|
||||
<div v-if="store.isExpanded" class="border-b border-white/8">
|
||||
<div class="container-content grid gap-4 p-4 md:grid-cols-2">
|
||||
<PlayerVisualizer />
|
||||
<PlayerPlaylist />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Progress bar (top of player) -->
|
||||
<PlayerProgress />
|
||||
|
||||
<!-- Main player bar -->
|
||||
<div class="container-content flex items-center gap-4 px-4 py-2">
|
||||
<!-- Track info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<PlayerTrackInfo />
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-4">
|
||||
<PlayerControls />
|
||||
</div>
|
||||
|
||||
<!-- Right section: mode + volume + expand -->
|
||||
<div class="hidden md:flex items-center gap-3 flex-shrink-0">
|
||||
<PlayerModeToggle />
|
||||
|
||||
<!-- Volume -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn-ghost !p-1" @click="toggleMute">
|
||||
<div :class="volumeIcon" class="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="store.volume"
|
||||
class="volume-slider w-20"
|
||||
@input="handleVolumeChange"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Time display -->
|
||||
<span class="font-mono text-xs text-white/40 w-24 text-center">
|
||||
{{ store.formattedCurrentTime }} / {{ store.formattedDuration }}
|
||||
</span>
|
||||
|
||||
<!-- Expand toggle -->
|
||||
<button
|
||||
class="btn-ghost !p-2"
|
||||
:aria-label="store.isExpanded ? 'Réduire' : 'Développer'"
|
||||
@click="store.toggleExpanded()"
|
||||
>
|
||||
<div :class="store.isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = usePlayerStore()
|
||||
const { setVolume } = useAudioPlayer()
|
||||
|
||||
// Initialize media session
|
||||
useMediaSession()
|
||||
|
||||
let previousVolume = 0.8
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (store.volume === 0) return 'i-lucide-volume-x'
|
||||
if (store.volume < 0.3) return 'i-lucide-volume'
|
||||
if (store.volume < 0.7) return 'i-lucide-volume-1'
|
||||
return 'i-lucide-volume-2'
|
||||
})
|
||||
|
||||
function handleVolumeChange(e: Event) {
|
||||
const value = parseFloat((e.target as HTMLInputElement).value)
|
||||
setVolume(value)
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (store.volume > 0) {
|
||||
previousVolume = store.volume
|
||||
setVolume(0)
|
||||
}
|
||||
else {
|
||||
setVolume(previousVolume)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-slide-enter-active,
|
||||
.player-slide-leave-active {
|
||||
transition: transform 0.3s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.player-slide-enter-from,
|
||||
.player-slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.panel-expand-enter-active,
|
||||
.panel-expand-leave-active {
|
||||
transition: all 0.3s var(--ease-out-expo);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-expand-enter-from,
|
||||
.panel-expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.panel-expand-enter-to,
|
||||
.panel-expand-leave-from {
|
||||
max-height: 400px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: hsl(0 0% 100% / 0.15);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
52
app/components/player/PlayerPlaylist.vue
Normal file
52
app/components/player/PlayerPlaylist.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="max-h-80 overflow-y-auto p-4">
|
||||
<h3 class="mb-3 font-display text-sm font-semibold uppercase tracking-wider text-white/50">
|
||||
Playlist
|
||||
</h3>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="song in store.playlist"
|
||||
:key="song.id"
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors hover:bg-white/5"
|
||||
:class="{ 'bg-primary/10 text-primary': song.id === store.currentSong?.id }"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<span class="font-mono text-xs text-white/30 w-6 text-right">
|
||||
{{ store.playlist.indexOf(song) + 1 }}
|
||||
</span>
|
||||
<div
|
||||
v-if="song.id === store.currentSong?.id && store.isPlaying"
|
||||
class="i-lucide-volume-2 h-4 w-4 flex-shrink-0 text-primary"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="i-lucide-music h-4 w-4 flex-shrink-0 text-white/30"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm">{{ song.title }}</p>
|
||||
<p class="truncate text-xs text-white/40">{{ song.artist }}</p>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-white/30">
|
||||
{{ formatDuration(song.duration) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
const store = usePlayerStore()
|
||||
const { playSongFromPlaylist } = usePlaylist()
|
||||
|
||||
function playSong(song: Song) {
|
||||
playSongFromPlaylist(song)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
48
app/components/player/PlayerProgress.vue
Normal file
48
app/components/player/PlayerProgress.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
class="player-progress group relative h-1 w-full cursor-pointer rounded-full bg-white/10 transition-all hover:h-2"
|
||||
@click="handleSeek"
|
||||
@mousedown="startDrag"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-75"
|
||||
:style="{ width: `${store.progress}%` }"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-1/2 h-3 w-3 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||
:style="{ left: `calc(${store.progress}% - 6px)` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = usePlayerStore()
|
||||
const { seek } = useAudioPlayer()
|
||||
|
||||
function handleSeek(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const time = percent * store.duration
|
||||
seek(time)
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
||||
const time = percent * store.duration
|
||||
seek(time)
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}
|
||||
</script>
|
||||
34
app/components/player/PlayerTrackInfo.vue
Normal file
34
app/components/player/PlayerTrackInfo.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div v-if="store.currentSong" class="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
class="h-10 w-10 flex-shrink-0 rounded-lg bg-surface-200 flex items-center justify-center overflow-hidden"
|
||||
:class="{ 'animate-glow-pulse': store.isPlaying }"
|
||||
>
|
||||
<img
|
||||
v-if="store.currentSong.coverImage"
|
||||
:src="store.currentSong.coverImage"
|
||||
:alt="store.currentSong.title"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<div v-else class="i-lucide-music h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-white">
|
||||
{{ store.currentSong.title }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-white/50">
|
||||
{{ store.currentSong.artist }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3 text-white/40">
|
||||
<div class="h-10 w-10 flex-shrink-0 rounded-lg bg-surface-200 flex items-center justify-center">
|
||||
<div class="i-lucide-music h-5 w-5" />
|
||||
</div>
|
||||
<p class="text-sm">Aucune piste sélectionnée</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const store = usePlayerStore()
|
||||
</script>
|
||||
91
app/components/player/PlayerVisualizer.vue
Normal file
91
app/components/player/PlayerVisualizer.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="h-12 w-full rounded-lg opacity-60"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const store = usePlayerStore()
|
||||
const { getAudio } = useAudioPlayer()
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let source: MediaElementAudioSourceNode | null = null
|
||||
let animId: number | null = null
|
||||
let connected = false
|
||||
|
||||
function initAnalyser() {
|
||||
if (connected || !canvasRef.value) return
|
||||
|
||||
try {
|
||||
const audio = getAudio()
|
||||
audioContext = new AudioContext()
|
||||
analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 64
|
||||
source = audioContext.createMediaElementSource(audio)
|
||||
source.connect(analyser)
|
||||
analyser.connect(audioContext.destination)
|
||||
connected = true
|
||||
}
|
||||
catch {
|
||||
// Web Audio API might not be available
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!canvasRef.value || !analyser) {
|
||||
animId = requestAnimationFrame(draw)
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
|
||||
canvas.width = canvas.offsetWidth * window.devicePixelRatio
|
||||
canvas.height = canvas.offsetHeight * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
const width = canvas.offsetWidth
|
||||
const height = canvas.offsetHeight
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const barWidth = width / bufferLength
|
||||
const gap = 2
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * height
|
||||
const x = i * (barWidth + gap)
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight)
|
||||
gradient.addColorStop(0, 'hsl(12, 76%, 48%)')
|
||||
gradient.addColorStop(1, 'hsl(36, 80%, 52%)')
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, height - barHeight, barWidth, barHeight)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
watch(() => store.isPlaying, (playing) => {
|
||||
if (playing) {
|
||||
initAnalyser()
|
||||
if (!animId) draw()
|
||||
if (audioContext?.state === 'suspended') {
|
||||
audioContext.resume()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animId) cancelAnimationFrame(animId)
|
||||
})
|
||||
</script>
|
||||
23
app/components/song/SongBadges.vue
Normal file
23
app/components/song/SongBadges.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div v-if="songs.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
|
||||
>
|
||||
<div class="i-lucide-music h-2.5 w-2.5" />
|
||||
{{ song.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
chapterSlug: string
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
</script>
|
||||
65
app/components/song/SongItem.vue
Normal file
65
app/components/song/SongItem.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
class="card-surface flex cursor-pointer items-center gap-4"
|
||||
:class="{ 'border-primary/40! shadow-primary/10!': isCurrent }"
|
||||
@click="handlePlay"
|
||||
>
|
||||
<!-- Play indicator / cover -->
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-surface-200"
|
||||
:class="{ 'animate-glow-pulse': isCurrent && store.isPlaying }"
|
||||
>
|
||||
<div
|
||||
v-if="isCurrent && store.isPlaying"
|
||||
class="i-lucide-volume-2 h-5 w-5 text-primary"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="i-lucide-play h-5 w-5 text-white/40 transition-colors group-hover:text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium" :class="isCurrent ? 'text-primary' : 'text-white'">
|
||||
{{ song.title }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-white/40">
|
||||
{{ song.artist }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="font-mono text-xs text-white/30 flex-shrink-0">
|
||||
{{ formatDuration(song.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
const props = defineProps<{
|
||||
song: Song
|
||||
}>()
|
||||
|
||||
const store = usePlayerStore()
|
||||
const { loadAndPlay, togglePlayPause } = useAudioPlayer()
|
||||
|
||||
const isCurrent = computed(() => store.currentSong?.id === props.song.id)
|
||||
|
||||
function handlePlay() {
|
||||
if (isCurrent.value) {
|
||||
togglePlayPause()
|
||||
}
|
||||
else {
|
||||
loadAndPlay(props.song)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
17
app/components/song/SongList.vue
Normal file
17
app/components/song/SongList.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SongItem
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
defineProps<{
|
||||
songs: Song[]
|
||||
}>()
|
||||
</script>
|
||||
50
app/components/song/SongLyrics.vue
Normal file
50
app/components/song/SongLyrics.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div v-if="song.lyrics" class="rounded-xl bg-surface p-6">
|
||||
<button
|
||||
class="flex w-full items-center justify-between text-left"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span class="font-display text-sm font-semibold text-white/70">Paroles</span>
|
||||
<div
|
||||
:class="isOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="h-4 w-4 text-white/40 transition-transform"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition name="lyrics-expand">
|
||||
<div v-if="isOpen" class="mt-4">
|
||||
<pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-white/60">{{ song.lyrics }}</pre>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
defineProps<{
|
||||
song: Song
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyrics-expand-enter-active,
|
||||
.lyrics-expand-leave-active {
|
||||
transition: all 0.3s var(--ease-out-expo);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lyrics-expand-enter-from,
|
||||
.lyrics-expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.lyrics-expand-enter-to,
|
||||
.lyrics-expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
41
app/components/ui/BaseButton.vue
Normal file
41
app/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<a
|
||||
v-if="href"
|
||||
:href="href"
|
||||
:target="target"
|
||||
:rel="target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
:class="variantClasses"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
<NuxtLink
|
||||
v-else-if="to"
|
||||
:to="to"
|
||||
:class="variantClasses"
|
||||
>
|
||||
<slot />
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
:class="variantClasses"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
variant?: 'primary' | 'accent' | 'ghost'
|
||||
to?: string
|
||||
href?: string
|
||||
target?: string
|
||||
}>()
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'accent': return 'btn-accent'
|
||||
case 'ghost': return 'btn-ghost'
|
||||
default: return 'btn-primary'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
33
app/components/ui/ScrollReveal.vue
Normal file
33
app/components/ui/ScrollReveal.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div ref="el" class="scroll-reveal" :style="{ animationDelay: `${delay}ms` }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
delay?: number
|
||||
}>()
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible')
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' },
|
||||
)
|
||||
|
||||
observer.observe(el.value)
|
||||
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
153
app/composables/useAudioPlayer.ts
Normal file
153
app/composables/useAudioPlayer.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
let audio: HTMLAudioElement | null = null
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const store = usePlayerStore()
|
||||
|
||||
function getAudio(): HTMLAudioElement {
|
||||
if (!audio) {
|
||||
audio = new Audio()
|
||||
audio.preload = 'metadata'
|
||||
audio.volume = store.volume
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
store.setDuration(audio!.duration)
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
const next = store.nextSong()
|
||||
if (next) {
|
||||
loadAndPlay(next)
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener('error', (e) => {
|
||||
console.error('Audio error:', e)
|
||||
store.pause()
|
||||
})
|
||||
}
|
||||
return audio
|
||||
}
|
||||
|
||||
function startTimeUpdate() {
|
||||
if (animationFrameId) return
|
||||
const update = () => {
|
||||
if (audio && !audio.paused) {
|
||||
store.setCurrentTime(audio.currentTime)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
}
|
||||
|
||||
function stopTimeUpdate() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndPlay(song: Song) {
|
||||
const el = getAudio()
|
||||
store.setSong(song)
|
||||
|
||||
// Try OGG first, fall back to MP3
|
||||
const oggPath = song.file.replace(/\.mp3$/, '.ogg')
|
||||
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis')
|
||||
|
||||
el.src = canOgg ? oggPath : song.file
|
||||
el.volume = store.volume
|
||||
|
||||
try {
|
||||
await el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
}
|
||||
catch {
|
||||
// If OGG failed, try MP3
|
||||
if (el.src !== song.file) {
|
||||
el.src = song.file
|
||||
try {
|
||||
await el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Playback failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
getAudio().pause()
|
||||
store.pause()
|
||||
stopTimeUpdate()
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const el = getAudio()
|
||||
if (el.src) {
|
||||
el.play()
|
||||
store.play()
|
||||
startTimeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (store.isPlaying) {
|
||||
pause()
|
||||
}
|
||||
else {
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
const el = getAudio()
|
||||
el.currentTime = time
|
||||
store.setCurrentTime(time)
|
||||
}
|
||||
|
||||
function setVolume(vol: number) {
|
||||
store.setVolume(vol)
|
||||
getAudio().volume = store.volume
|
||||
}
|
||||
|
||||
function playNext() {
|
||||
const song = store.nextSong()
|
||||
if (song) loadAndPlay(song)
|
||||
}
|
||||
|
||||
function playPrev() {
|
||||
const song = store.prevSong()
|
||||
if (song) {
|
||||
if (song === store.currentSong && store.currentTime <= 3) {
|
||||
// prevSong already reset time
|
||||
seek(0)
|
||||
}
|
||||
else {
|
||||
loadAndPlay(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch volume changes from store
|
||||
watch(() => store.volume, (vol) => {
|
||||
if (audio) audio.volume = vol
|
||||
})
|
||||
|
||||
return {
|
||||
loadAndPlay,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
setVolume,
|
||||
playNext,
|
||||
playPrev,
|
||||
getAudio,
|
||||
}
|
||||
}
|
||||
95
app/composables/useBookData.ts
Normal file
95
app/composables/useBookData.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import yaml from 'yaml'
|
||||
import type { Song } from '~/types/song'
|
||||
import type { ChapterSongLink, BookConfig } from '~/types/book'
|
||||
|
||||
let _configCache: BookConfig | null = null
|
||||
|
||||
async function loadConfig(): Promise<BookConfig> {
|
||||
if (_configCache) return _configCache
|
||||
|
||||
const raw = await import('~/data/librodrome.config.yml?raw').then(m => m.default)
|
||||
const parsed = yaml.parse(raw)
|
||||
|
||||
_configCache = {
|
||||
title: parsed.book.title,
|
||||
author: parsed.book.author,
|
||||
description: parsed.book.description,
|
||||
coverImage: parsed.book.coverImage,
|
||||
chapters: [],
|
||||
songs: parsed.songs as Song[],
|
||||
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
||||
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
||||
}
|
||||
|
||||
return _configCache
|
||||
}
|
||||
|
||||
export function useBookData() {
|
||||
const config = ref<BookConfig | null>(null)
|
||||
const isLoaded = ref(false)
|
||||
|
||||
async function init() {
|
||||
if (isLoaded.value) return
|
||||
config.value = await loadConfig()
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
function getSongs(): Song[] {
|
||||
return config.value?.songs ?? []
|
||||
}
|
||||
|
||||
function getSongById(id: string): Song | undefined {
|
||||
return config.value?.songs.find(s => s.id === id)
|
||||
}
|
||||
|
||||
function getChapterSongs(chapterSlug: string): Song[] {
|
||||
if (!config.value) return []
|
||||
const links = config.value.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug)
|
||||
return links
|
||||
.map(link => config.value!.songs.find(s => s.id === link.songId))
|
||||
.filter((s): s is Song => !!s)
|
||||
}
|
||||
|
||||
function getPrimarySong(chapterSlug: string): Song | undefined {
|
||||
if (!config.value) return undefined
|
||||
const link = config.value.chapterSongs.find(
|
||||
cs => cs.chapterSlug === chapterSlug && cs.primary,
|
||||
)
|
||||
if (!link) return undefined
|
||||
return config.value.songs.find(s => s.id === link.songId)
|
||||
}
|
||||
|
||||
function getChapterSongLinks(chapterSlug: string): ChapterSongLink[] {
|
||||
return config.value?.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug) ?? []
|
||||
}
|
||||
|
||||
function getPlaylistOrder(): Song[] {
|
||||
if (!config.value) return []
|
||||
return config.value.defaultPlaylistOrder
|
||||
.map(id => config.value!.songs.find(s => s.id === id))
|
||||
.filter((s): s is Song => !!s)
|
||||
}
|
||||
|
||||
function getBookMeta() {
|
||||
if (!config.value) return null
|
||||
return {
|
||||
title: config.value.title,
|
||||
author: config.value.author,
|
||||
description: config.value.description,
|
||||
coverImage: config.value.coverImage,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoaded,
|
||||
init,
|
||||
getSongs,
|
||||
getSongById,
|
||||
getChapterSongs,
|
||||
getPrimarySong,
|
||||
getChapterSongLinks,
|
||||
getPlaylistOrder,
|
||||
getBookMeta,
|
||||
}
|
||||
}
|
||||
16
app/composables/useGrateWizard.ts
Normal file
16
app/composables/useGrateWizard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function useGrateWizard() {
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
function launch() {
|
||||
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
const left = Math.round((window.screen.width - popup.width) / 2)
|
||||
const top = Math.round((window.screen.height - popup.height) / 2)
|
||||
window.open(
|
||||
url,
|
||||
'GrateWizard',
|
||||
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
)
|
||||
}
|
||||
|
||||
return { launch }
|
||||
}
|
||||
36
app/composables/useGuidedMode.ts
Normal file
36
app/composables/useGuidedMode.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function useGuidedMode() {
|
||||
const route = useRoute()
|
||||
const store = usePlayerStore()
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
async function activateGuidedMode(chapterSlug: string) {
|
||||
await bookData.init()
|
||||
|
||||
if (!store.isGuidedMode) return
|
||||
|
||||
const primarySong = bookData.getPrimarySong(chapterSlug)
|
||||
if (primarySong && primarySong.id !== store.currentSong?.id) {
|
||||
// Set the chapter's songs as the playlist
|
||||
const chapterSongs = bookData.getChapterSongs(chapterSlug)
|
||||
if (chapterSongs.length > 0) {
|
||||
store.setPlaylist(chapterSongs)
|
||||
}
|
||||
loadAndPlay(primarySong)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch route changes for guided mode
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
async (slug) => {
|
||||
if (slug && typeof slug === 'string' && store.isGuidedMode) {
|
||||
await activateGuidedMode(slug)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
activateGuidedMode,
|
||||
}
|
||||
}
|
||||
63
app/composables/useMediaSession.ts
Normal file
63
app/composables/useMediaSession.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export function useMediaSession() {
|
||||
const store = usePlayerStore()
|
||||
const { togglePlayPause, playNext, playPrev, seek } = useAudioPlayer()
|
||||
|
||||
function updateMediaSession() {
|
||||
if (!('mediaSession' in navigator)) return
|
||||
if (!store.currentSong) return
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: store.currentSong.title,
|
||||
artist: store.currentSong.artist,
|
||||
album: 'Une économie du don — enfin concevable',
|
||||
artwork: store.currentSong.coverImage
|
||||
? [{ src: store.currentSong.coverImage, sizes: '512x512', type: 'image/jpeg' }]
|
||||
: [],
|
||||
})
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', () => togglePlayPause())
|
||||
navigator.mediaSession.setActionHandler('pause', () => togglePlayPause())
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev())
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => playNext())
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (details.seekTime != null) seek(details.seekTime)
|
||||
})
|
||||
}
|
||||
|
||||
function updatePositionState() {
|
||||
if (!('mediaSession' in navigator)) return
|
||||
if (!store.currentSong || store.duration === 0) return
|
||||
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: store.duration,
|
||||
playbackRate: 1,
|
||||
position: Math.min(store.currentTime, store.duration),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Ignore errors from invalid position state
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for song changes
|
||||
watch(() => store.currentSong, () => {
|
||||
updateMediaSession()
|
||||
})
|
||||
|
||||
// Update position periodically
|
||||
watch(() => store.currentTime, () => {
|
||||
updatePositionState()
|
||||
})
|
||||
|
||||
// Update playback state
|
||||
watch(() => store.isPlaying, (playing) => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
updateMediaSession,
|
||||
}
|
||||
}
|
||||
5
app/composables/usePageContent.ts
Normal file
5
app/composables/usePageContent.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function usePageContent<T = Record<string, unknown>>(name: string) {
|
||||
return useAsyncData<T>(`page-${name}`, () =>
|
||||
$fetch(`/api/content/pages/${name}`),
|
||||
)
|
||||
}
|
||||
51
app/composables/usePlaylist.ts
Normal file
51
app/composables/usePlaylist.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Song } from '~/types/song'
|
||||
|
||||
export function usePlaylist() {
|
||||
const store = usePlayerStore()
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
async function loadFullPlaylist() {
|
||||
await bookData.init()
|
||||
const songs = bookData.getPlaylistOrder()
|
||||
store.setPlaylist(songs)
|
||||
}
|
||||
|
||||
function shufflePlaylist() {
|
||||
const current = [...store.playlist]
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[current[i], current[j]] = [current[j], current[i]]
|
||||
}
|
||||
store.setPlaylist(current)
|
||||
store.toggleShuffle()
|
||||
}
|
||||
|
||||
function unshuffle() {
|
||||
const songs = bookData.getPlaylistOrder()
|
||||
store.setPlaylist(songs)
|
||||
store.toggleShuffle()
|
||||
}
|
||||
|
||||
function playSongFromPlaylist(song: Song) {
|
||||
loadAndPlay(song)
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
if (store.isShuffled) {
|
||||
unshuffle()
|
||||
}
|
||||
else {
|
||||
shufflePlaylist()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadFullPlaylist,
|
||||
shufflePlaylist,
|
||||
unshuffle,
|
||||
playSongFromPlaylist,
|
||||
toggleShuffle,
|
||||
}
|
||||
}
|
||||
40
app/composables/useScrollReveal.ts
Normal file
40
app/composables/useScrollReveal.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export function useScrollReveal() {
|
||||
const observer = ref<IntersectionObserver | null>(null)
|
||||
|
||||
function init() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
observer.value = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible')
|
||||
observer.value?.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px',
|
||||
},
|
||||
)
|
||||
|
||||
document.querySelectorAll('.scroll-reveal').forEach((el) => {
|
||||
observer.value?.observe(el)
|
||||
})
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
observer.value?.disconnect()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => init())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroy()
|
||||
})
|
||||
|
||||
return { init, destroy }
|
||||
}
|
||||
30
app/composables/useSiteContent.ts
Normal file
30
app/composables/useSiteContent.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
interface SiteContent {
|
||||
identity: {
|
||||
name: string
|
||||
description: string
|
||||
url: string
|
||||
}
|
||||
navigation: NavItem[]
|
||||
footer: {
|
||||
credits: string
|
||||
links: NavItem[]
|
||||
}
|
||||
gratewizard: {
|
||||
url: string
|
||||
popup: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useSiteContent() {
|
||||
return useAsyncData<SiteContent>('site-content', () =>
|
||||
$fetch('/api/content/site'),
|
||||
)
|
||||
}
|
||||
180
app/data/librodrome.config.yml
Normal file
180
app/data/librodrome.config.yml
Normal file
@@ -0,0 +1,180 @@
|
||||
book:
|
||||
title: "Une économie du don — enfin concevable"
|
||||
author: "Yvv"
|
||||
description: "Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don."
|
||||
coverImage: "/images/book-cover.jpg"
|
||||
license: "CC-BY-NC"
|
||||
isbn: "979-1-042-45206-3"
|
||||
|
||||
songs:
|
||||
- id: chanson-01
|
||||
title: "1. Ce livre est une façon"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-01.mp3
|
||||
duration: 718
|
||||
lyrics: ""
|
||||
tags: [introduction, livre, don]
|
||||
|
||||
- id: chanson-02
|
||||
title: "2. Un don qui se mesure"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-02.mp3
|
||||
duration: 589
|
||||
lyrics: ""
|
||||
tags: [don, mesure, valeur]
|
||||
|
||||
- id: chanson-03
|
||||
title: "3. Les asymétries"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-03.mp3
|
||||
duration: 727
|
||||
lyrics: ""
|
||||
tags: [asymétrie, communauté, philosophie]
|
||||
|
||||
- id: chanson-04
|
||||
title: "4. Inverser les flux"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-04.mp3
|
||||
duration: 610
|
||||
lyrics: ""
|
||||
tags: [flux, économie, production]
|
||||
|
||||
- id: chanson-05
|
||||
title: "5. Ainsi soit-il"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-05.mp3
|
||||
duration: 545
|
||||
lyrics: ""
|
||||
tags: [action, engagement, avenir]
|
||||
|
||||
- id: chanson-06
|
||||
title: "6. La croissance, une option ?"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-06.mp3
|
||||
duration: 510
|
||||
lyrics: ""
|
||||
tags: [croissance, monnaie, questionnement]
|
||||
|
||||
- id: chanson-07
|
||||
title: "7. Monnaie libre essence"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-07.mp3
|
||||
duration: 475
|
||||
lyrics: ""
|
||||
tags: [monnaie libre, TRM, June]
|
||||
|
||||
- id: chanson-08
|
||||
title: "8. Des cercles qui se croisent"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-08.mp3
|
||||
duration: 496
|
||||
lyrics: ""
|
||||
tags: [échange, réseau, cercles]
|
||||
|
||||
- id: chanson-09
|
||||
title: "9. Coder la liberté"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-09.mp3
|
||||
duration: 376
|
||||
lyrics: ""
|
||||
tags: [logiciel libre, code, liberté]
|
||||
|
||||
chapterSongs:
|
||||
# Chapitre 1 — Introduction
|
||||
- chapterSlug: introduction
|
||||
songId: chanson-01
|
||||
primary: true
|
||||
- chapterSlug: introduction
|
||||
songId: chanson-02
|
||||
primary: false
|
||||
|
||||
# Chapitre 2 — De quel don parlons-nous ?
|
||||
- chapterSlug: de-quel-don-parlons-nous
|
||||
songId: chanson-03
|
||||
primary: true
|
||||
- chapterSlug: de-quel-don-parlons-nous
|
||||
songId: chanson-01
|
||||
primary: false
|
||||
|
||||
# Chapitre 3 — La mesure du don
|
||||
- chapterSlug: la-mesure-du-don
|
||||
songId: chanson-02
|
||||
primary: true
|
||||
- chapterSlug: la-mesure-du-don
|
||||
songId: chanson-03
|
||||
primary: false
|
||||
|
||||
# Chapitre 4 — Raison d'être d'une monnaie
|
||||
- chapterSlug: raison-d-etre-d-une-monnaie
|
||||
songId: chanson-06
|
||||
primary: true
|
||||
- chapterSlug: raison-d-etre-d-une-monnaie
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
# Chapitre 5 — La TRM
|
||||
- chapterSlug: la-trm
|
||||
songId: chanson-07
|
||||
primary: true
|
||||
- chapterSlug: la-trm
|
||||
songId: chanson-06
|
||||
primary: false
|
||||
|
||||
# Chapitre 6 — Créer une économie ?
|
||||
- chapterSlug: creer-une-economie
|
||||
songId: chanson-04
|
||||
primary: true
|
||||
- chapterSlug: creer-une-economie
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
# Chapitre 7 — Échanger
|
||||
- chapterSlug: echanger
|
||||
songId: chanson-08
|
||||
primary: true
|
||||
- chapterSlug: echanger
|
||||
songId: chanson-04
|
||||
primary: false
|
||||
|
||||
# Chapitre 8 — Relation institutionnelle
|
||||
- chapterSlug: relation-institutionnelle
|
||||
songId: chanson-05
|
||||
primary: false
|
||||
- chapterSlug: relation-institutionnelle
|
||||
songId: chanson-08
|
||||
primary: false
|
||||
|
||||
# Chapitre 9 — Autres greffes
|
||||
- chapterSlug: autres-greffes
|
||||
songId: chanson-04
|
||||
primary: false
|
||||
- chapterSlug: autres-greffes
|
||||
songId: chanson-08
|
||||
primary: false
|
||||
|
||||
# Chapitre 10 — Et maintenant ?
|
||||
- chapterSlug: et-maintenant
|
||||
songId: chanson-05
|
||||
primary: true
|
||||
- chapterSlug: et-maintenant
|
||||
songId: chanson-09
|
||||
primary: false
|
||||
|
||||
# Chapitre 11 — Annexes
|
||||
- chapterSlug: annexes
|
||||
songId: chanson-09
|
||||
primary: true
|
||||
- chapterSlug: annexes
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
defaultPlaylistOrder:
|
||||
- chanson-01
|
||||
- chanson-02
|
||||
- chanson-03
|
||||
- chanson-04
|
||||
- chanson-05
|
||||
- chanson-06
|
||||
- chanson-07
|
||||
- chanson-08
|
||||
- chanson-09
|
||||
28
app/layouts/admin.vue
Normal file
28
app/layouts/admin.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<AdminSidebar />
|
||||
<main class="admin-main">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
app/layouts/default.vue
Normal file
15
app/layouts/default.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<main class="pb-[var(--player-height)]">
|
||||
<slot />
|
||||
</main>
|
||||
<LayoutTheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
</style>
|
||||
53
app/layouts/reading.vue
Normal file
53
app/layouts/reading.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<div class="reading-layout pb-[var(--player-height)]">
|
||||
<aside class="chapter-sidebar hidden lg:block">
|
||||
<BookChapterNav />
|
||||
</aside>
|
||||
<main class="reading-main">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<LayoutTheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.reading-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.reading-layout {
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-sidebar {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
height: calc(100dvh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid hsl(0 0% 100% / 0.08);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.reading-main {
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 65ch;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.reading-main {
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
app/middleware/admin.ts
Normal file
11
app/middleware/admin.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only protect admin routes (except login)
|
||||
if (!to.path.startsWith('/admin') || to.path === '/admin/login') return
|
||||
|
||||
try {
|
||||
await $fetch('/api/admin/auth/check')
|
||||
}
|
||||
catch {
|
||||
return navigateTo('/admin/login')
|
||||
}
|
||||
})
|
||||
21
app/pages/a-propos.vue
Normal file
21
app/pages/a-propos.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<ContentRenderer v-if="page" :value="page" class="prose" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'À propos',
|
||||
})
|
||||
|
||||
const { data: page } = await useAsyncData('about', () =>
|
||||
queryCollection('pages').path('/pages/about').first(),
|
||||
)
|
||||
</script>
|
||||
94
app/pages/admin/book/[slug].vue
Normal file
94
app/pages/admin/book/[slug].vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin/book" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Chapitres
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">
|
||||
{{ chapter?.slug }}
|
||||
</h1>
|
||||
</div>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
</div>
|
||||
|
||||
<template v-if="chapter">
|
||||
<AdminFormSection title="Frontmatter" open>
|
||||
<textarea
|
||||
v-model="frontmatter"
|
||||
class="fm-textarea"
|
||||
rows="6"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="Contenu Markdown" open>
|
||||
<AdminMarkdownEditor v-model="body" :rows="30" />
|
||||
</AdminFormSection>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
|
||||
|
||||
const frontmatter = ref('')
|
||||
const body = ref('')
|
||||
|
||||
watch(chapter, (val) => {
|
||||
if (val) {
|
||||
frontmatter.value = val.frontmatter ?? ''
|
||||
body.value = val.body ?? ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
await $fetch(`/api/admin/chapters/${slug.value}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
frontmatter: frontmatter.value,
|
||||
body: body.value,
|
||||
},
|
||||
})
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fm-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 4%);
|
||||
color: hsl(36 80% 76%);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.fm-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
59
app/pages/admin/book/index.vue
Normal file
59
app/pages/admin/book/index.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-bold text-white mb-6">Chapitres</h1>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<NuxtLink
|
||||
v-for="chapter in chapters"
|
||||
:key="chapter.slug"
|
||||
:to="`/admin/book/${chapter.slug}`"
|
||||
class="chapter-item"
|
||||
>
|
||||
<span class="chapter-order">{{ String(chapter.order ?? 0).padStart(2, '0') }}</span>
|
||||
<span class="chapter-title">{{ chapter.title }}</span>
|
||||
<div class="i-lucide-chevron-right h-4 w-4 text-white/20" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: chapters } = await useFetch('/api/admin/chapters')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chapter-item:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 6%);
|
||||
}
|
||||
|
||||
.chapter-order {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
color: hsl(12 76% 48% / 0.5);
|
||||
font-weight: 600;
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
flex: 1;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
60
app/pages/admin/index.vue
Normal file
60
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-bold text-white mb-6">Dashboard</h1>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink to="/admin/site" class="dash-card">
|
||||
<div class="i-lucide-globe h-8 w-8 text-primary mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Site</h2>
|
||||
<p class="text-sm text-white/50">Identité, navigation, footer</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/pages/home" class="dash-card">
|
||||
<div class="i-lucide-home h-8 w-8 text-primary mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Pages</h2>
|
||||
<p class="text-sm text-white/50">Contenus des pages publiques</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/book" class="dash-card">
|
||||
<div class="i-lucide-book-open h-8 w-8 text-primary mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Chapitres</h2>
|
||||
<p class="text-sm text-white/50">Éditer le contenu du livre</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/admin/songs" class="dash-card">
|
||||
<div class="i-lucide-music h-8 w-8 text-accent mb-2" />
|
||||
<h2 class="text-lg font-semibold text-white">Chansons</h2>
|
||||
<p class="text-sm text-white/50">Métadonnées des pistes</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>
|
||||
<p class="text-sm text-white/50">Images, audio, PDF</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dash-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
background: hsl(20 8% 6%);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dash-card:hover {
|
||||
border-color: hsl(12 76% 48% / 0.3);
|
||||
background: hsl(20 8% 8%);
|
||||
}
|
||||
</style>
|
||||
134
app/pages/admin/login.vue
Normal file
134
app/pages/admin/login.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<form class="login-form" @submit.prevent="login">
|
||||
<div class="i-lucide-lock h-10 w-10 text-primary mb-4 mx-auto" />
|
||||
<h1 class="font-display text-2xl font-bold text-white text-center mb-6">Administration</h1>
|
||||
|
||||
<div v-if="error" class="login-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<label class="login-label" for="password">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="login-input"
|
||||
placeholder="Entrez le mot de passe"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
<div v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin" />
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function login() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: { password: password.value },
|
||||
})
|
||||
navigateTo('/admin')
|
||||
}
|
||||
catch {
|
||||
error.value = 'Mot de passe incorrect'
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(20 8% 3.5%);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 1rem;
|
||||
background: hsl(20 8% 6%);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(0 60% 45% / 0.15);
|
||||
color: hsl(0 60% 70%);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(20 8% 60%);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 4%);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(12 76% 48%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: hsl(12 76% 42%);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
</style>
|
||||
32
app/pages/admin/media.vue
Normal file
32
app/pages/admin/media.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-bold text-white mb-6">Médias</h1>
|
||||
|
||||
<AdminMediaUpload class="mb-6" @uploaded="refresh" />
|
||||
|
||||
<AdminMediaBrowser
|
||||
v-if="files"
|
||||
:files="files"
|
||||
@delete="deleteFile"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: files, refresh } = await useFetch('/api/admin/media')
|
||||
|
||||
async function deleteFile(path: string) {
|
||||
if (!confirm(`Supprimer ${path} ?`)) return
|
||||
|
||||
// Remove leading slash for the API path
|
||||
const apiPath = path.startsWith('/') ? path.slice(1) : path
|
||||
|
||||
await $fetch(`/api/admin/media/${apiPath}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
}
|
||||
</script>
|
||||
209
app/pages/admin/messages.vue
Normal file
209
app/pages/admin/messages.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Messages</h1>
|
||||
<span class="text-sm text-white/40">{{ messages?.length || 0 }} message(s)</span>
|
||||
</div>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-3">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="msg-row"
|
||||
:class="{ 'msg-row--draft': !msg.published }"
|
||||
>
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between gap-4 mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-semibold text-white text-sm truncate">{{ msg.author }}</span>
|
||||
<span v-if="msg.email" class="text-white/30 text-xs truncate">{{ msg.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs text-white/30">{{ formatDate(msg.createdAt) }}</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="msg.published ? 'status-badge--pub' : 'status-badge--draft'"
|
||||
>
|
||||
{{ msg.published ? 'Publié' : 'En attente' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texte éditable -->
|
||||
<div v-if="editing === msg.id" class="mb-3">
|
||||
<input
|
||||
v-model="editForm.author"
|
||||
class="admin-input mb-2 w-full"
|
||||
placeholder="Auteur"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editForm.text"
|
||||
class="admin-input w-full"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-white/70 text-sm leading-relaxed mb-3">{{ msg.text }}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="action-btn" @click="togglePublished(msg)">
|
||||
<div :class="msg.published ? 'i-lucide-eye-off' : 'i-lucide-eye'" class="h-3.5 w-3.5" />
|
||||
{{ msg.published ? 'Dépublier' : 'Publier' }}
|
||||
</button>
|
||||
|
||||
<template v-if="editing === msg.id">
|
||||
<button class="action-btn action-btn--save" @click="saveEdit(msg)">
|
||||
<div class="i-lucide-check h-3.5 w-3.5" />
|
||||
Valider
|
||||
</button>
|
||||
<button class="action-btn" @click="editing = null">
|
||||
Annuler
|
||||
</button>
|
||||
</template>
|
||||
<button v-else class="action-btn" @click="startEdit(msg)">
|
||||
<div class="i-lucide-pencil h-3.5 w-3.5" />
|
||||
Modifier
|
||||
</button>
|
||||
|
||||
<button class="action-btn action-btn--danger ml-auto" @click="remove(msg)">
|
||||
<div class="i-lucide-trash-2 h-3.5 w-3.5" />
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-white/40 py-12">Aucun message.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: messages, refresh } = await useFetch<any[]>('/api/admin/messages')
|
||||
|
||||
const editing = ref<number | null>(null)
|
||||
const editForm = reactive({ author: '', text: '' })
|
||||
|
||||
function startEdit(msg: any) {
|
||||
editing.value = msg.id
|
||||
editForm.author = msg.author
|
||||
editForm.text = msg.text
|
||||
}
|
||||
|
||||
async function saveEdit(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { author: editForm.author, text: editForm.text },
|
||||
})
|
||||
editing.value = null
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function togglePublished(msg: any) {
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, {
|
||||
method: 'PUT',
|
||||
body: { published: !msg.published },
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function remove(msg: any) {
|
||||
if (!confirm(`Supprimer le message de "${msg.author}" ?`)) return
|
||||
await $fetch(`/api/admin/messages/${msg.id}`, { method: 'DELETE' })
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-row {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.msg-row--draft {
|
||||
border-left: 3px solid hsl(36 80% 52%);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-badge--pub {
|
||||
background: hsl(142 70% 40% / 0.15);
|
||||
color: hsl(142 70% 60%);
|
||||
}
|
||||
|
||||
.status-badge--draft {
|
||||
background: hsl(36 80% 52% / 0.15);
|
||||
color: hsl(36 80% 66%);
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 60%);
|
||||
background: none;
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: hsl(20 8% 10%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn--save {
|
||||
border-color: hsl(142 70% 40% / 0.3);
|
||||
color: hsl(142 70% 60%);
|
||||
}
|
||||
|
||||
.action-btn--save:hover {
|
||||
background: hsl(142 70% 40% / 0.1);
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: hsl(0 70% 40% / 0.1);
|
||||
border-color: hsl(0 70% 40% / 0.3);
|
||||
color: hsl(0 70% 60%);
|
||||
}
|
||||
</style>
|
||||
98
app/pages/admin/pages/[name].vue
Normal file
98
app/pages/admin/pages/[name].vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<NuxtLink to="/admin" class="text-sm text-white/40 hover:text-white/60 transition-colors">
|
||||
← Dashboard
|
||||
</NuxtLink>
|
||||
<h1 class="font-display text-2xl font-bold text-white mt-1">
|
||||
Page : {{ pageName }}
|
||||
</h1>
|
||||
</div>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
</div>
|
||||
|
||||
<div v-if="data" class="page-editor">
|
||||
<AdminFormSection title="Contenu YAML" open>
|
||||
<div class="yaml-editor-wrapper">
|
||||
<textarea
|
||||
v-model="yamlContent"
|
||||
class="yaml-textarea"
|
||||
rows="30"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="text-red-400">
|
||||
Erreur de chargement : {{ error.message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import yaml from 'yaml'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const pageName = computed(() => route.params.name as string)
|
||||
|
||||
const { data, error } = await useFetch(() => `/api/content/pages/${pageName.value}`)
|
||||
|
||||
const yamlContent = ref('')
|
||||
|
||||
watch(data, (val) => {
|
||||
if (val) {
|
||||
yamlContent.value = yaml.stringify(val, { lineWidth: 120 })
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
const parsed = yaml.parse(yamlContent.value)
|
||||
await $fetch(`/api/admin/content/pages/${pageName.value}`, {
|
||||
method: 'PUT',
|
||||
body: parsed,
|
||||
})
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
catch (e: any) {
|
||||
alert('Erreur YAML : ' + (e?.message ?? 'format invalide'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yaml-textarea {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 4%);
|
||||
color: hsl(36 80% 76%);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.yaml-textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
116
app/pages/admin/site.vue
Normal file
116
app/pages/admin/site.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Configuration du site</h1>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
</div>
|
||||
|
||||
<template v-if="data">
|
||||
<AdminFormSection title="Identité" open>
|
||||
<AdminFieldText v-model="data.identity.name" label="Nom du site" />
|
||||
<AdminFieldTextarea v-model="data.identity.description" label="Description" :rows="3" />
|
||||
<AdminFieldText v-model="data.identity.url" label="URL" />
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="Navigation" open>
|
||||
<AdminFieldList
|
||||
v-model="data.navigation"
|
||||
label="Liens de navigation"
|
||||
add-label="Ajouter un lien"
|
||||
:default-item="() => ({ label: '', to: '/' })"
|
||||
>
|
||||
<template #default="{ item, update }">
|
||||
<div class="flex gap-2 flex-1">
|
||||
<input
|
||||
:value="item.label"
|
||||
class="admin-input flex-1"
|
||||
placeholder="Label"
|
||||
@input="update({ ...item, label: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
<input
|
||||
:value="item.to"
|
||||
class="admin-input w-32"
|
||||
placeholder="/chemin"
|
||||
@input="update({ ...item, to: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AdminFieldList>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="Pied de page">
|
||||
<AdminFieldText v-model="data.footer.credits" label="Crédits" />
|
||||
<AdminFieldList
|
||||
v-model="data.footer.links"
|
||||
label="Liens"
|
||||
add-label="Ajouter un lien"
|
||||
:default-item="() => ({ label: '', to: '/' })"
|
||||
>
|
||||
<template #default="{ item, update }">
|
||||
<div class="flex gap-2 flex-1">
|
||||
<input
|
||||
:value="item.label"
|
||||
class="admin-input flex-1"
|
||||
placeholder="Label"
|
||||
@input="update({ ...item, label: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
<input
|
||||
:value="item.to"
|
||||
class="admin-input w-32"
|
||||
placeholder="/chemin"
|
||||
@input="update({ ...item, to: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AdminFieldList>
|
||||
</AdminFormSection>
|
||||
|
||||
<AdminFormSection title="GrateWizard">
|
||||
<AdminFieldText v-model="data.gratewizard.url" label="URL de l'application" />
|
||||
</AdminFormSection>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data } = await useFetch('/api/content/site')
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
await $fetch('/api/admin/content/site', {
|
||||
method: 'PUT',
|
||||
body: data.value,
|
||||
})
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
92
app/pages/admin/songs.vue
Normal file
92
app/pages/admin/songs.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Chansons</h1>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="save" />
|
||||
</div>
|
||||
|
||||
<template v-if="config">
|
||||
<AdminFormSection title="Métadonnées des chansons" open>
|
||||
<div
|
||||
v-for="(song, i) in config.songs"
|
||||
:key="i"
|
||||
class="song-row"
|
||||
>
|
||||
<span class="song-num">{{ i + 1 }}</span>
|
||||
<div class="flex-1 grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
v-model="song.title"
|
||||
class="admin-input"
|
||||
placeholder="Titre"
|
||||
/>
|
||||
<input
|
||||
v-model="song.file"
|
||||
class="admin-input"
|
||||
placeholder="/audio/fichier.mp3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const { data: config } = await useFetch('/api/content/config')
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
await $fetch('/api/admin/content/config', {
|
||||
method: 'PUT',
|
||||
body: config.value,
|
||||
})
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.song-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid hsl(20 8% 10%);
|
||||
}
|
||||
|
||||
.song-num {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 40%);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
</style>
|
||||
110
app/pages/ecouter/index.vue
Normal file
110
app/pages/ecouter/index.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-accent uppercase">{{ content?.kicker }}</p>
|
||||
<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">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Search + view toggle -->
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<div class="i-lucide-search absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/30" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="content?.searchPlaceholder"
|
||||
class="w-full rounded-lg bg-surface border border-white/8 py-2 pl-10 pr-4 text-sm text-white placeholder:text-white/30 focus:border-primary/50 focus:outline-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-lg bg-surface p-1">
|
||||
<button
|
||||
class="rounded p-1.5 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-white/10 text-white' : 'text-white/40'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 transition-colors"
|
||||
:class="viewMode === 'grid' ? 'bg-white/10 text-white' : 'text-white/40'"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<div class="i-lucide-grid-3x3 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Song list -->
|
||||
<div v-if="viewMode === 'list'" class="flex flex-col gap-2">
|
||||
<SongItem
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Song grid -->
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SongItem
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
:song="song"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="filteredSongs.length === 0" class="text-center text-white/40 py-12">
|
||||
{{ content?.noResults }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('ecouter')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Écouter',
|
||||
})
|
||||
|
||||
const store = usePlayerStore()
|
||||
const bookData = useBookData()
|
||||
const { loadFullPlaylist } = usePlaylist()
|
||||
|
||||
await bookData.init()
|
||||
|
||||
// Switch to free mode
|
||||
store.setMode('free')
|
||||
await loadFullPlaylist()
|
||||
|
||||
const search = ref('')
|
||||
const viewMode = ref<'list' | 'grid'>('list')
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
const songs = bookData.getSongs()
|
||||
if (!search.value.trim()) return songs
|
||||
|
||||
const q = search.value.toLowerCase()
|
||||
return songs.filter(
|
||||
s => s.title.toLowerCase().includes(q)
|
||||
|| s.artist.toLowerCase().includes(q)
|
||||
|| s.tags.some(t => t.toLowerCase().includes(q)),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
</style>
|
||||
95
app/pages/gratewizard.vue
Normal file
95
app/pages/gratewizard.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content max-w-3xl mx-auto">
|
||||
<!-- Back link -->
|
||||
<UiScrollReveal>
|
||||
<NuxtLink to="/" class="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-8 transition-colors">
|
||||
<div class="i-lucide-arrow-left h-4 w-4" />
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Header -->
|
||||
<UiScrollReveal>
|
||||
<div class="text-center mb-12">
|
||||
<span class="inline-block mb-3 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.kicker }}
|
||||
</span>
|
||||
<h1 class="page-title font-display font-bold text-white">
|
||||
{{ content?.title }}
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-white/60 leading-relaxed max-w-xl mx-auto">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Explanation cards -->
|
||||
<div class="grid gap-6 md:grid-cols-2 mb-12">
|
||||
<UiScrollReveal
|
||||
v-for="(feature, i) in content?.features"
|
||||
:key="i"
|
||||
:delay="(i + 1) * 100"
|
||||
>
|
||||
<div class="gw-feature-card">
|
||||
<div :class="`i-lucide-${feature.icon}`" class="h-6 w-6 text-amber-400 mb-3" />
|
||||
<h3 class="font-display text-lg font-semibold text-white mb-2">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-white/40 mb-4">
|
||||
{{ content?.cta.note }}
|
||||
</p>
|
||||
<UiBaseButton @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-5 w-5" />
|
||||
{{ content?.cta.label }}
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('gratewizard')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'GrateWizard — Coefficients relatifs',
|
||||
})
|
||||
|
||||
const { launch } = useGrateWizard()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
|
||||
.gw-feature-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 8% / 0.5);
|
||||
transition: border-color 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.gw-feature-card:hover {
|
||||
border-color: hsl(40 80% 50% / 0.25);
|
||||
background: hsl(20 8% 10% / 0.5);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.25em;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
</style>
|
||||
18
app/pages/index.vue
Normal file
18
app/pages/index.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<HomeHeroSection />
|
||||
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeGrateWizardTeaser />
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Accueil',
|
||||
})
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
const showPdfReader = ref(false)
|
||||
</script>
|
||||
81
app/pages/lire/[slug].vue
Normal file
81
app/pages/lire/[slug].vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div v-if="chapter">
|
||||
<BookChapterHeader
|
||||
:title="chapter.title"
|
||||
:description="chapter.description"
|
||||
:order="chapter.order"
|
||||
:reading-time="chapter.readingTime"
|
||||
:chapter-slug="slug"
|
||||
/>
|
||||
|
||||
<BookChapterContent :content="chapter" />
|
||||
|
||||
<!-- Prev / Next navigation -->
|
||||
<nav class="mt-16 flex items-center justify-between border-t border-white/8 pt-8">
|
||||
<NuxtLink
|
||||
v-if="prevChapter"
|
||||
:to="`/lire/${prevChapter.stem}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<div class="i-lucide-arrow-left h-4 w-4" />
|
||||
<span class="text-sm">{{ prevChapter.title }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
|
||||
<NuxtLink
|
||||
v-if="nextChapter"
|
||||
:to="`/lire/${nextChapter.stem}`"
|
||||
class="btn-ghost gap-2"
|
||||
>
|
||||
<span class="text-sm">{{ nextChapter.title }}</span>
|
||||
<div class="i-lucide-arrow-right h-4 w-4" />
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'reading',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
// Initialize guided mode
|
||||
useGuidedMode()
|
||||
|
||||
const { data: chapter } = await useAsyncData(`chapter-${slug}`, () =>
|
||||
queryCollection('book').path(`/book/${slug}`).first(),
|
||||
)
|
||||
|
||||
if (!chapter.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Chapitre non trouvé' })
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: chapter.value?.title,
|
||||
})
|
||||
|
||||
// Get adjacent chapters for navigation
|
||||
const { data: allChapters } = await useAsyncData('book-nav', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
|
||||
const currentIndex = computed(() =>
|
||||
allChapters.value?.findIndex(c => c.stem === slug) ?? -1,
|
||||
)
|
||||
|
||||
const prevChapter = computed(() => {
|
||||
const idx = currentIndex.value
|
||||
if (idx <= 0 || !allChapters.value) return null
|
||||
return allChapters.value[idx - 1]
|
||||
})
|
||||
|
||||
const nextChapter = computed(() => {
|
||||
const idx = currentIndex.value
|
||||
if (!allChapters.value || idx >= allChapters.value.length - 1) return null
|
||||
return allChapters.value[idx + 1]
|
||||
})
|
||||
</script>
|
||||
71
app/pages/lire/index.vue
Normal file
71
app/pages/lire/index.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content">
|
||||
<header class="mb-12 text-center">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
|
||||
<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">
|
||||
{{ content?.description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="chapter in chapters"
|
||||
:key="chapter.path"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="`/lire/${chapter.stem}`"
|
||||
class="card-surface flex items-start gap-4 group"
|
||||
>
|
||||
<span class="font-mono text-2xl font-bold text-primary/30 leading-none mt-1 w-10 text-right flex-shrink-0">
|
||||
{{ String(chapter.order).padStart(2, '0') }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-lg font-semibold text-white group-hover:text-primary transition-colors">
|
||||
{{ chapter.title }}
|
||||
</h2>
|
||||
<p v-if="chapter.description" class="mt-1 text-sm text-white/50">
|
||||
{{ chapter.description }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<span v-if="chapter.readingTime" class="text-xs text-white/30">
|
||||
<span class="i-lucide-clock inline-block h-3 w-3 mr-1 align-middle" />
|
||||
{{ chapter.readingTime }}
|
||||
</span>
|
||||
<SongBadges :chapter-slug="chapter.stem!" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="i-lucide-chevron-right h-5 w-5 text-white/20 group-hover:text-primary/60 transition-colors flex-shrink-0 mt-2" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('lire')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Table des matières',
|
||||
})
|
||||
|
||||
const { data: chapters } = await useAsyncData('book-toc', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
}
|
||||
</style>
|
||||
50
app/pages/messages.vue
Normal file
50
app/pages/messages.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<h1 class="font-display text-3xl font-bold text-gradient mb-2">Messages des visiteurs</h1>
|
||||
<p class="text-white/50 mb-8">Les mots laissés par celles et ceux qui passent par ici.</p>
|
||||
|
||||
<div v-if="messages?.length" class="space-y-4">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-card">
|
||||
<p class="text-white/80 leading-relaxed">{{ msg.text }}</p>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-white/40">
|
||||
<span class="font-semibold text-white/60">{{ msg.author }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(msg.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-white/40 py-12">Aucun message pour l'instant.</p>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<NuxtLink to="/" class="btn-ghost text-sm">
|
||||
<div class="i-lucide-arrow-left mr-1 h-3.5 w-3.5" />
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Messages',
|
||||
})
|
||||
|
||||
const { data: messages } = await useFetch('/api/messages')
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-card {
|
||||
background: hsl(20 8% 6%);
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
</style>
|
||||
26
app/plugins/audio-player.client.ts
Normal file
26
app/plugins/audio-player.client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
// Initialize the player store on client side
|
||||
const store = usePlayerStore()
|
||||
|
||||
// Restore volume from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const savedVolume = localStorage.getItem('librodrome-volume')
|
||||
if (savedVolume) {
|
||||
store.setVolume(parseFloat(savedVolume))
|
||||
}
|
||||
|
||||
const savedMode = localStorage.getItem('librodrome-mode') as 'guided' | 'free' | null
|
||||
if (savedMode) {
|
||||
store.setMode(savedMode)
|
||||
}
|
||||
|
||||
// Watch for changes and persist
|
||||
watch(() => store.volume, (vol) => {
|
||||
localStorage.setItem('librodrome-volume', String(vol))
|
||||
})
|
||||
|
||||
watch(() => store.mode, (mode) => {
|
||||
localStorage.setItem('librodrome-mode', mode)
|
||||
})
|
||||
}
|
||||
})
|
||||
187
app/stores/player.ts
Normal file
187
app/stores/player.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Song } from '~/types/song'
|
||||
import type { PlayerMode, RepeatMode } from '~/types/player'
|
||||
|
||||
export const usePlayerStore = defineStore('player', () => {
|
||||
// State
|
||||
const isPlaying = ref(false)
|
||||
const currentSong = ref<Song | null>(null)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(0.8)
|
||||
const mode = ref<PlayerMode>('guided')
|
||||
const repeatMode = ref<RepeatMode>('none')
|
||||
const isShuffled = ref(false)
|
||||
const playlist = ref<Song[]>([])
|
||||
const queue = ref<Song[]>([])
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// Computed
|
||||
const progress = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
const isGuidedMode = computed(() => mode.value === 'guided')
|
||||
|
||||
const currentIndex = computed(() => {
|
||||
if (!currentSong.value) return -1
|
||||
return playlist.value.findIndex(s => s.id === currentSong.value!.id)
|
||||
})
|
||||
|
||||
const hasNext = computed(() => {
|
||||
if (repeatMode.value === 'all') return playlist.value.length > 0
|
||||
return currentIndex.value < playlist.value.length - 1
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => {
|
||||
return currentIndex.value > 0
|
||||
})
|
||||
|
||||
// Actions
|
||||
function setSong(song: Song) {
|
||||
currentSong.value = song
|
||||
currentTime.value = 0
|
||||
duration.value = song.duration
|
||||
}
|
||||
|
||||
function setPlaylist(songs: Song[]) {
|
||||
playlist.value = songs
|
||||
}
|
||||
|
||||
function setMode(newMode: PlayerMode) {
|
||||
mode.value = newMode
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
function play() {
|
||||
isPlaying.value = true
|
||||
}
|
||||
|
||||
function pause() {
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
function setCurrentTime(time: number) {
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
function setDuration(dur: number) {
|
||||
duration.value = dur
|
||||
}
|
||||
|
||||
function setVolume(vol: number) {
|
||||
volume.value = Math.max(0, Math.min(1, vol))
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
const modes: RepeatMode[] = ['none', 'all', 'one']
|
||||
const idx = modes.indexOf(repeatMode.value)
|
||||
repeatMode.value = modes[(idx + 1) % modes.length]
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
isShuffled.value = !isShuffled.value
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
function nextSong(): Song | null {
|
||||
if (playlist.value.length === 0) return null
|
||||
|
||||
if (repeatMode.value === 'one') {
|
||||
currentTime.value = 0
|
||||
return currentSong.value
|
||||
}
|
||||
|
||||
let nextIdx = currentIndex.value + 1
|
||||
if (nextIdx >= playlist.value.length) {
|
||||
if (repeatMode.value === 'all') {
|
||||
nextIdx = 0
|
||||
}
|
||||
else {
|
||||
pause()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const song = playlist.value[nextIdx]
|
||||
setSong(song)
|
||||
return song
|
||||
}
|
||||
|
||||
function prevSong(): Song | null {
|
||||
if (playlist.value.length === 0) return null
|
||||
|
||||
// If more than 3 seconds in, restart current song
|
||||
if (currentTime.value > 3) {
|
||||
currentTime.value = 0
|
||||
return currentSong.value
|
||||
}
|
||||
|
||||
let prevIdx = currentIndex.value - 1
|
||||
if (prevIdx < 0) {
|
||||
if (repeatMode.value === 'all') {
|
||||
prevIdx = playlist.value.length - 1
|
||||
}
|
||||
else {
|
||||
currentTime.value = 0
|
||||
return currentSong.value
|
||||
}
|
||||
}
|
||||
|
||||
const song = playlist.value[prevIdx]
|
||||
setSong(song)
|
||||
return song
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isPlaying,
|
||||
currentSong,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
mode,
|
||||
repeatMode,
|
||||
isShuffled,
|
||||
playlist,
|
||||
queue,
|
||||
isExpanded,
|
||||
// Computed
|
||||
progress,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
isGuidedMode,
|
||||
currentIndex,
|
||||
hasNext,
|
||||
hasPrev,
|
||||
// Actions
|
||||
setSong,
|
||||
setPlaylist,
|
||||
setMode,
|
||||
togglePlay,
|
||||
play,
|
||||
pause,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setVolume,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
toggleExpanded,
|
||||
nextSong,
|
||||
prevSong,
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
24
app/types/book.ts
Normal file
24
app/types/book.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ChapterMeta {
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
order: number
|
||||
readingTime?: string
|
||||
}
|
||||
|
||||
export interface ChapterSongLink {
|
||||
chapterSlug: string
|
||||
songId: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
title: string
|
||||
author: string
|
||||
description: string
|
||||
coverImage?: string
|
||||
chapters: ChapterMeta[]
|
||||
songs: import('./song').Song[]
|
||||
chapterSongs: ChapterSongLink[]
|
||||
defaultPlaylistOrder: string[]
|
||||
}
|
||||
18
app/types/player.ts
Normal file
18
app/types/player.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Song } from './song'
|
||||
|
||||
export type PlayerMode = 'guided' | 'free'
|
||||
export type RepeatMode = 'none' | 'one' | 'all'
|
||||
|
||||
export interface PlayerState {
|
||||
isPlaying: boolean
|
||||
currentSong: Song | null
|
||||
currentTime: number
|
||||
duration: number
|
||||
volume: number
|
||||
mode: PlayerMode
|
||||
repeatMode: RepeatMode
|
||||
isShuffled: boolean
|
||||
playlist: Song[]
|
||||
queue: Song[]
|
||||
isExpanded: boolean
|
||||
}
|
||||
10
app/types/song.ts
Normal file
10
app/types/song.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Song {
|
||||
id: string
|
||||
title: string
|
||||
artist: string
|
||||
file: string
|
||||
duration: number // seconds
|
||||
coverImage?: string
|
||||
lyrics?: string
|
||||
tags: string[]
|
||||
}
|
||||
24
content.config.ts
Normal file
24
content.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
|
||||
|
||||
export default defineContentConfig({
|
||||
collections: {
|
||||
book: defineCollection({
|
||||
type: 'page',
|
||||
source: 'book/**/*.md',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
order: z.number(),
|
||||
readingTime: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
pages: defineCollection({
|
||||
type: 'page',
|
||||
source: 'pages/**/*.md',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
80
content/book/annexes.md
Normal file
80
content/book/annexes.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: "Chapitres annexes — sujets connexes"
|
||||
description: "Approfondissements : cryptomonnaies, June et blockchain, logiciel libre, réseau monétique et questions techniques."
|
||||
order: 11
|
||||
readingTime: "25 min"
|
||||
---
|
||||
|
||||
Ces chapitres annexes abordent des sujets techniques et connexes qui complètent le propos principal du livre. Ils sont destinés aux lecteurs qui souhaitent approfondir certaines questions ou clarifier des points techniques.
|
||||
|
||||
## Les cryptos
|
||||
|
||||
Le mot « crypto » est devenu un fourre-tout. Il désigne pêle-mêle le Bitcoin, l'Ethereum, les NFT, les memecoins, les stablecoins, les tokens de DeFi... Cette confusion est problématique, car elle amalgame des projets aux philosophies radicalement différentes.
|
||||
|
||||
Les cryptomonnaies « classiques » (Bitcoin, Ethereum) partagent un trait commun : elles reproduisent, voire amplifient, les asymétries du système financier traditionnel. Le Bitcoin, par exemple, est créé par le **minage** — un processus qui favorise ceux qui disposent du plus de puissance de calcul (et donc du plus de capital). Les premiers mineurs ont accumulé des quantités astronomiques de bitcoins à moindre coût, créant une oligarchie monétaire encore plus concentrée que celle du système fiat.
|
||||
|
||||
La spéculation est le moteur principal de l'écosystème crypto classique. On achète des tokens non pas pour les utiliser, mais pour les revendre plus cher. C'est un casino déguisé en innovation technologique.
|
||||
|
||||
## La June est-elle une crypto ?
|
||||
|
||||
Techniquement, oui : la Ğ1 utilise une blockchain (Duniter) pour enregistrer les transactions. Mais philosophiquement, elle est aux antipodes des cryptos spéculatives.
|
||||
|
||||
Les différences fondamentales :
|
||||
- **Création monétaire** : dans le Bitcoin, la monnaie est créée par le minage (asymétrique). Dans la Ğ1, elle est créée par le Dividende Universel (symétrique).
|
||||
- **Objectif** : le Bitcoin vise à être une « réserve de valeur » (une forme d'or numérique). La Ğ1 vise à être un **outil d'échange** au service d'une économie du don.
|
||||
- **Identité** : dans le Bitcoin, les utilisateurs sont anonymes. Dans la Ğ1, chaque compte est lié à une **personne réelle** via la toile de confiance.
|
||||
- **Spéculation** : le Bitcoin est conçu pour prendre de la valeur avec le temps (déflation). La Ğ1 est conçue pour maintenir un **équilibre** entre les membres (convergence à la moyenne).
|
||||
- **Énergie** : le Bitcoin consomme autant d'électricité qu'un pays de taille moyenne. La Ğ1, qui utilise un consensus par toile de confiance (et non par preuve de travail), a une empreinte énergétique négligeable.
|
||||
|
||||
Dire que la Ğ1 est une crypto est donc techniquement correct mais sémantiquement trompeur. C'est comme dire qu'un vélo et un char d'assaut sont tous les deux des véhicules : c'est vrai, mais ça ne dit pas grand-chose d'utile.
|
||||
|
||||
## Introduction sur un DeX vs. CeX
|
||||
|
||||
Dans l'univers crypto, on distingue les **CeX** (Centralized Exchanges) et les **DeX** (Decentralized Exchanges).
|
||||
|
||||
Un **CeX** est une plateforme centralisée (comme Binance ou Coinbase) où un intermédiaire gère les ordres d'achat et de vente, détient les fonds des utilisateurs, et applique ses propres règles. C'est pratique, mais c'est un point de centralisation et de vulnérabilité : si la plateforme fait faillite ou se fait pirater, les utilisateurs perdent tout (cf. l'affaire FTX).
|
||||
|
||||
Un **DeX** est un protocole décentralisé où les échanges se font directement entre utilisateurs, via des smart contracts, sans intermédiaire de confiance. C'est plus lent, parfois plus complexe, mais c'est plus cohérent avec l'esprit de décentralisation.
|
||||
|
||||
La Ğ1 n'est pas cotée sur les exchanges crypto classiques (ni CeX ni DeX). C'est un choix délibéré : la June n'est pas un actif spéculatif. Elle ne doit pas être achetée et revendue comme un token. Elle doit être **co-créée** par ses membres et **utilisée** dans l'économie réelle.
|
||||
|
||||
## Question du « bankrun »
|
||||
|
||||
Le « bankrun » est un scénario dans lequel tous les détenteurs d'une monnaie cherchent simultanément à la convertir en une autre, provoquant l'effondrement de sa valeur.
|
||||
|
||||
Ce scénario est pertinent pour les monnaies adossées à une réserve (comme les stablecoins ou les monnaies locales convertibles). Si la réserve est insuffisante pour couvrir toutes les conversions, le système s'effondre.
|
||||
|
||||
La Ğ1 n'est **pas** sujette au bankrun, pour une raison simple : elle n'est adossée à rien. Il n'y a pas de réserve en euro, pas de promesse de conversion, pas de « prix plancher ». La valeur de la Ğ1 repose uniquement sur la **confiance** des membres dans le réseau et sur l'**utilité** de la monnaie dans l'économie réelle.
|
||||
|
||||
Si tous les membres cessaient d'utiliser la Ğ1 demain, elle perdrait effectivement toute valeur. Mais ce scénario est le même pour n'importe quelle monnaie, y compris l'euro : une monnaie vaut quelque chose parce que des gens l'acceptent. Si plus personne ne l'accepte, elle ne vaut plus rien.
|
||||
|
||||
La meilleure protection contre le « bankrun » de la June est le développement de l'économie réelle en monnaie libre. Plus il y a de biens et services disponibles en June, plus la monnaie est utile, plus les membres ont intérêt à la conserver et à l'utiliser.
|
||||
|
||||
## Réseau monétique
|
||||
|
||||
Le **réseau monétique** de la Ğ1 est l'ensemble des outils techniques qui permettent d'effectuer des transactions en monnaie libre.
|
||||
|
||||
L'infrastructure repose sur **Duniter**, le logiciel qui gère la blockchain de la Ğ1. Duniter est un logiciel libre, développé par la communauté, qui implémente les règles de la TRM : Dividende Universel, toile de confiance, consensus décentralisé.
|
||||
|
||||
Côté utilisateur, plusieurs applications permettent d'interagir avec la Ğ1 :
|
||||
- **Cesium** : l'application historique (web et mobile) pour gérer son compte, envoyer et recevoir des Ğ1
|
||||
- **Tikka** : une application mobile plus récente et plus ergonomique
|
||||
- **Gchange** : une place de marché en ligne pour publier des annonces de vente/achat en Ğ1
|
||||
|
||||
Le réseau monétique de la Ğ1 est encore jeune et en développement actif. L'ergonomie et la fiabilité des outils s'améliorent continuellement. C'est l'un des chantiers les plus importants de la communauté : des outils simples et fiables sont indispensables pour l'adoption à grande échelle.
|
||||
|
||||
La transition de Duniter v1 vers v2 est en cours, avec des améliorations significatives en termes de performance, de scalabilité et de fonctionnalités.
|
||||
|
||||
## Le logiciel libre
|
||||
|
||||
La monnaie libre et le logiciel libre partagent un ADN commun. Les quatre libertés du logiciel libre (utiliser, étudier, modifier, redistribuer) font écho aux quatre libertés économiques de la TRM.
|
||||
|
||||
Richard Stallman, le fondateur du mouvement du logiciel libre, a montré dès les années 1980 qu'un commun numérique — le code source — pouvait être géré de manière coopérative, sans propriétaire exclusif, par une communauté de contributeurs bénévoles. Le résultat est impressionnant : Linux, Firefox, WordPress, LibreOffice, et des milliers d'autres logiciels libres sont aujourd'hui utilisés par des milliards de personnes.
|
||||
|
||||
La monnaie libre est au système monétaire ce que le logiciel libre est au système informatique : une alternative fondée sur la **liberté**, la **transparence** et la **coopération**.
|
||||
|
||||
Toute l'infrastructure technique de la Ğ1 est en logiciel libre. Le code est ouvert, auditable, modifiable par quiconque. Les développeurs contribuent bénévolement (souvent rémunérés en June par la communauté). C'est un commun numérique au service d'un commun monétaire.
|
||||
|
||||
> Coder la liberté, ce n'est pas seulement écrire du logiciel libre. C'est aussi coder les règles d'une monnaie libre — une monnaie dont le « code source » est ouvert, compréhensible et juste.
|
||||
|
||||
Cette convergence entre logiciel libre et monnaie libre n'est pas un hasard. C'est le même mouvement de fond : la conviction que les infrastructures essentielles de la société — le code informatique, le code monétaire — doivent être des **communs**, gérés démocratiquement, au bénéfice de tous.
|
||||
73
content/book/autres-greffes.md
Normal file
73
content/book/autres-greffes.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Autres greffes"
|
||||
description: "Applications concrètes de l'économie du don dans divers secteurs : emploi, ESS, agriculture, artisanat, éducation."
|
||||
order: 9
|
||||
readingTime: "15 min"
|
||||
---
|
||||
|
||||
L'économie de greffe ne se limite pas aux marchés et aux circuits alimentaires. Elle peut se déployer dans de nombreux secteurs de la vie économique et sociale. Ce chapitre explore quelques pistes de greffes, certaines déjà amorcées, d'autres encore en gestation.
|
||||
|
||||
## Pôle Emploi et mission locale
|
||||
|
||||
Les demandeurs d'emploi sont parmi les premières victimes de l'asymétrie monétaire. Sans revenu en euro, ils sont exclus de l'économie — alors même qu'ils ont des compétences, du temps et de l'énergie à offrir.
|
||||
|
||||
La monnaie libre offre une issue à cette impasse. Un demandeur d'emploi peut produire et échanger en June, développer ses compétences, entretenir son réseau, et maintenir une activité économique réelle — même en l'absence de « travail » au sens classique.
|
||||
|
||||
Les Pôles Emploi et les missions locales pourraient jouer un rôle de relais en orientant les demandeurs d'emploi vers les communautés June locales. Non pas comme un substitut à l'emploi salarié, mais comme un **complément** qui maintient le lien social et économique pendant les périodes de transition.
|
||||
|
||||
Certaines expériences locales vont dans ce sens : des ateliers de présentation de la monnaie libre organisés en partenariat avec des structures d'insertion, des Ğmarchés accueillant des personnes en réinsertion professionnelle.
|
||||
|
||||
## ESS (Économie Sociale et Solidaire)
|
||||
|
||||
L'Économie Sociale et Solidaire partage de nombreuses valeurs avec la monnaie libre : solidarité, gouvernance démocratique, primauté de l'humain sur le capital, ancrage territorial.
|
||||
|
||||
Les structures de l'ESS — coopératives, mutuelles, associations, fondations — sont des partenaires naturels pour le développement de l'économie en monnaie libre. Elles disposent de réseaux, de compétences juridiques, d'une légitimité institutionnelle.
|
||||
|
||||
Les greffes possibles sont nombreuses :
|
||||
- Des **AMAP** (Associations pour le Maintien d'une Agriculture Paysanne) qui acceptent la June
|
||||
- Des **ressourceries** et **recycleries** qui pratiquent le double pricing
|
||||
- Des **coopératives d'activité** qui accompagnent des entrepreneurs en monnaie libre
|
||||
- Des **tiers-lieux** qui accueillent des Ğmarchés et des ateliers
|
||||
|
||||
Le dialogue avec l'ESS est aussi l'occasion de faire connaître la monnaie libre à un public plus large, et de montrer qu'elle n'est pas un gadget technologique mais un outil de transformation sociale.
|
||||
|
||||
## Associations populaires et caritatives
|
||||
|
||||
Les associations caritatives — Restos du Cœur, Secours Populaire, Emmaüs, etc. — distribuent des biens aux plus démunis. Leur action est indispensable, mais elle maintient une logique d'**assistance** : les bénéficiaires reçoivent, mais ne participent pas en tant qu'acteurs économiques.
|
||||
|
||||
La monnaie libre propose un changement de paradigme. Plutôt que de distribuer des biens, on peut distribuer du **pouvoir d'achat** en June. Le bénéficiaire n'est plus un assisté passif : il devient un **acteur économique** qui choisit librement ce qu'il achète, à qui, et à quel moment.
|
||||
|
||||
Ce passage de l'assistance à l'**autonomie** est fondamental. Il restaure la dignité des personnes en situation de précarité. Il les intègre dans un réseau d'échange où elles sont traitées comme des égales — pas comme des bénéficiaires de charité.
|
||||
|
||||
Des expériences pilotes associant monnaie libre et action caritative pourraient ouvrir des perspectives considérables.
|
||||
|
||||
## Productions agricoles, maraîchages
|
||||
|
||||
L'agriculture est le secteur le plus naturellement adapté à la monnaie libre. Les maraîchers produisent des biens essentiels, en circuit court, à une échelle compatible avec les communautés locales.
|
||||
|
||||
De nombreux maraîchers acceptent déjà la June, en tout ou en partie. Certains vont plus loin : ils achètent des semences, du matériel, des services en June. Ils créent ainsi des mini-filières en monnaie libre, depuis la semence jusqu'à l'assiette.
|
||||
|
||||
Le défi pour l'agriculture en monnaie libre est celui de la **viabilité économique**. Un maraîcher doit payer ses charges en euro (foncier, matériel, assurances, cotisations). Tant que ces charges ne sont pas couvertes en June, la part de l'activité en monnaie libre reste limitée.
|
||||
|
||||
La solution passe par la densification du réseau : plus il y a de producteurs et de prestataires qui acceptent la June, plus chacun peut couvrir ses besoins en monnaie libre, et moins il dépend de l'euro.
|
||||
|
||||
## Artisanat — Commerce — Entreprise
|
||||
|
||||
L'artisanat offre un terrain fertile pour la monnaie libre. Les artisans travaillent souvent en solo ou en petite équipe, ils sont proches de leurs clients, et leur production est locale par nature.
|
||||
|
||||
Menuisiers, couturiers, réparateurs, électriciens, plombiers, boulangers... autant de métiers qui peuvent intégrer la June dans leur activité. Le modèle le plus courant est le **double pricing** : une partie en euro (pour couvrir les charges incompressibles) et une partie en June.
|
||||
|
||||
Pour les commerces et les entreprises de taille plus importante, l'intégration de la June demande une réflexion comptable et organisationnelle plus poussée. Mais les exemples existent : des boutiques qui acceptent la June, des prestataires de services informatiques qui facturent en DU.
|
||||
|
||||
## Lycées — Écoles
|
||||
|
||||
L'éducation est un terrain d'expérimentation passionnant pour la monnaie libre. Apprendre aux jeunes comment fonctionne la monnaie — pas seulement comment la gagner et la dépenser, mais comment elle est créée, par qui, selon quelles règles — est un enjeu civique majeur.
|
||||
|
||||
Des initiatives existent : des ateliers sur la monnaie libre dans des lycées, des projets pédagogiques autour de la Ğ1, des simulations d'économie en monnaie libre avec des classes.
|
||||
|
||||
L'intérêt pédagogique est triple :
|
||||
- **Économique** : comprendre la création monétaire, l'inflation, les systèmes monétaires
|
||||
- **Mathématique** : manipuler les notions de croissance, de convergence, de symétrie
|
||||
- **Civique** : réfléchir à la gouvernance des communs, à la démocratie économique
|
||||
|
||||
Les jeunes qui découvrent la monnaie libre réagissent souvent avec enthousiasme. L'idée qu'une autre monnaie est possible — et qu'elle existe déjà — ouvre des horizons que l'enseignement classique de l'économie tend à fermer.
|
||||
81
content/book/creer-une-economie.md
Normal file
81
content/book/creer-une-economie.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Créer une économie ?"
|
||||
description: "Passer de la théorie à la pratique : produire, greffer une économie du don sur le tissu local, inverser les flux."
|
||||
order: 6
|
||||
readingTime: "25 min"
|
||||
---
|
||||
|
||||
Après la théorie, la pratique. Après avoir compris *pourquoi* une autre monnaie est nécessaire et *comment* elle fonctionne, la question qui brûle est : **comment construire concrètement une économie du don ?**
|
||||
|
||||
La réponse n'est pas de table rase. On ne détruit pas l'économie existante pour en construire une autre à la place. On **greffe**. On crée des passerelles. On développe des circuits parallèles qui, petit à petit, deviennent des alternatives crédibles.
|
||||
|
||||
## Produire
|
||||
|
||||
Toute économie commence par la **production**. Pas de production, pas d'échange. Pas d'échange, pas d'économie. La question première est donc : que produire en monnaie libre ?
|
||||
|
||||
La réponse est : tout ce dont la communauté a besoin. Des légumes, du pain, des vêtements, des réparations, des cours, des soins, des spectacles, des logiciels, des hébergements... La production en monnaie libre n'est pas cantonnée à un secteur : elle couvre potentiellement tous les besoins humains.
|
||||
|
||||
En pratique, les premiers producteurs en monnaie libre sont souvent des **artisans et des maraîchers** — des gens qui produisent à petite échelle, en circuit court, et qui sont proches de leur communauté. Mais on trouve aussi des informaticiens, des thérapeutes, des enseignants, des artistes.
|
||||
|
||||
Le point commun de ces producteurs, c'est qu'ils acceptent d'être rémunérés (en partie ou en totalité) en June. Ils font confiance à la communauté pour que cette monnaie ait de la valeur — c'est-à-dire pour que d'autres producteurs acceptent aussi la June, et qu'on puisse l'échanger contre des biens et services utiles.
|
||||
|
||||
## « Passer la seconde »
|
||||
|
||||
L'expression « passer la seconde » décrit le moment où une communauté monnaie-libre passe du stade de l'expérimentation au stade de l'**économie réelle**. C'est le moment où la June cesse d'être un jeu ou une curiosité pour devenir un outil économique fonctionnel.
|
||||
|
||||
Ce passage se caractérise par plusieurs marqueurs :
|
||||
- Des producteurs **réguliers** (pas seulement occasionnels) acceptent la June
|
||||
- Des **circuits d'échange** stables se forment entre producteurs et consommateurs
|
||||
- La June commence à circuler « en boucle » : A paie B en June, B paie C, C paie A
|
||||
- Les membres commencent à couvrir une partie significative de leurs besoins en June
|
||||
|
||||
« Passer la seconde » n'est pas un événement ponctuel. C'est un processus graduel, qui nécessite patience, persévérance et organisation collective.
|
||||
|
||||
## Économie de greffe
|
||||
|
||||
Le concept d'**économie de greffe** est central dans notre approche. Plutôt que de construire une économie alternative isolée, nous proposons de *greffer* l'économie en monnaie libre sur l'économie existante.
|
||||
|
||||
Concrètement, cela signifie que la plupart des producteurs en June acceptent aussi l'euro. Ils pratiquent un **double pricing** : un prix en euro et un prix en June (exprimé en DU). Le client choisit son moyen de paiement.
|
||||
|
||||
Cette approche présente plusieurs avantages :
|
||||
- Elle ne demande pas aux producteurs de renoncer à l'euro du jour au lendemain
|
||||
- Elle permet une transition progressive
|
||||
- Elle expose de nouveaux publics à la monnaie libre
|
||||
- Elle crée des ponts entre les deux économies
|
||||
|
||||
La greffe n'est pas un compromis ou une demi-mesure. C'est une **stratégie** de transition. L'objectif à long terme est que la part de l'économie en monnaie libre croisse naturellement, à mesure que la communauté grandit et que les circuits d'échange se multiplient.
|
||||
|
||||
## Connaître son bassin de vie
|
||||
|
||||
Pour greffer efficacement, il faut connaître son **bassin de vie** : les personnes, les activités, les ressources, les besoins du territoire. C'est un travail d'enquête et de cartographie qui peut sembler fastidieux, mais qui est indispensable.
|
||||
|
||||
Quels producteurs locaux pourraient accepter la June ? Quels services manquent ? Quels besoins ne sont pas satisfaits par l'économie classique ? Quelles compétences sont disponibles ? Où sont les forces vives ?
|
||||
|
||||
Cette connaissance du terrain permet de cibler les efforts : développer les circuits où la demande est forte, attirer les producteurs dont l'offre correspond aux besoins, organiser des événements qui rassemblent la communauté.
|
||||
|
||||
Les Groupes Locaux June (GLJ) jouent un rôle essentiel dans cette cartographie. Ce sont des collectifs informels de monnaie-libristes qui se réunissent régulièrement sur un territoire donné. Ils organisent des marchés, des rencontres, des ateliers. Ils sont les chevilles ouvrières de l'économie de greffe.
|
||||
|
||||
## Gestion « à l'anglaise »
|
||||
|
||||
La « gestion à l'anglaise » est une métaphore jardinière. En jardinage anglais, on ne cherche pas à tout contrôler comme dans un jardin à la française. On plante, on observe, on accompagne la croissance, on taille quand c'est nécessaire — mais on laisse la nature faire son travail.
|
||||
|
||||
L'économie du don fonctionne de la même manière. On ne peut pas la planifier de manière centralisée. On ne peut pas décider d'en haut qui doit produire quoi, qui doit échanger avec qui, à quel prix. Ce serait contradictoire avec l'esprit même de la liberté.
|
||||
|
||||
En revanche, on peut **créer les conditions** favorables :
|
||||
- Organiser des marchés où les producteurs se rencontrent
|
||||
- Faciliter la certification de nouveaux membres
|
||||
- Animer la communauté (forums, événements, communication)
|
||||
- Documenter et partager les bonnes pratiques
|
||||
- Résoudre les problèmes techniques (outils informatiques, applications)
|
||||
|
||||
Le rôle des organisateurs n'est pas de diriger, mais de **faciliter**. Ils créent le cadre, et la communauté remplit le cadre selon ses propres dynamiques.
|
||||
|
||||
## Économie de flux — inversés
|
||||
|
||||
Dans l'économie classique, les flux vont du bas vers le haut : l'argent remonte des consommateurs vers les producteurs, puis vers les actionnaires, puis vers les marchés financiers. C'est une économie d'**extraction** : la valeur est extraite des territoires et concentrée dans les centres de pouvoir financier.
|
||||
|
||||
L'économie du don propose d'**inverser les flux**. La monnaie naît en bas — chez les individus, par le Dividende Universel — et circule horizontalement entre pairs. Il n'y a pas de centre d'accumulation. La richesse reste dans le territoire, circule entre les membres, fertilise l'économie locale.
|
||||
|
||||
Cette inversion des flux n'est pas une redistribution. La redistribution suppose qu'on prend aux riches pour donner aux pauvres — ce qui maintient la logique d'accumulation, simplement corrigée a posteriori. L'inversion des flux change la **source** même de la monnaie. On ne corrige pas les inégalités : on supprime le mécanisme qui les crée.
|
||||
|
||||
> Inverser les flux, ce n'est pas redistribuer la richesse. C'est changer l'endroit où la richesse naît.
|
||||
53
content/book/de-quel-don-parlons-nous.md
Normal file
53
content/book/de-quel-don-parlons-nous.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "De quel don parlons-nous ?"
|
||||
description: "Exploration philosophique et sociologique du don, des asymétries communautaires aux expériences concrètes."
|
||||
order: 2
|
||||
readingTime: "20 min"
|
||||
---
|
||||
|
||||
Le mot « don » est piégé. Il charrie des siècles de connotations religieuses, morales, sentimentales. Il évoque la charité, le sacrifice, la générosité — autant de notions nobles mais qui obscurcissent ce dont nous voulons parler. Alors, de quel don parlons-nous ?
|
||||
|
||||
Nous parlons d'un don **économique**. Un don qui circule, qui se mesure, qui s'organise. Pas un don qui s'oppose à l'économie, mais un don qui *est* l'économie — ou du moins qui pourrait en devenir le principe organisateur.
|
||||
|
||||
Pour comprendre cette proposition, il faut d'abord déconstruire quelques idées reçues. C'est l'objet de ce chapitre.
|
||||
|
||||
## Trois mots de philo et de socio
|
||||
|
||||
Trois penseurs sont incontournables quand on parle du don : **Marcel Mauss**, **Jacques Derrida** et **Alain Caillé**.
|
||||
|
||||
Marcel Mauss, dans son *Essai sur le don* (1925), a montré que dans les sociétés dites « archaïques », le don n'est jamais gratuit. Il obéit à une triple obligation : **donner, recevoir, rendre**. Le don crée du lien social. Il engage des relations de réciprocité. Il structure la communauté. Le don maussien n'est pas un acte isolé de générosité : c'est un système social complet.
|
||||
|
||||
Jacques Derrida, quant à lui, a posé une question vertigineuse : le don est-il seulement possible ? Pour qu'il y ait don véritable, il faudrait que le donateur n'attende rien en retour — pas même de la reconnaissance. Dès qu'on identifie un don comme tel, il cesse d'être un don pour devenir un échange. Le don pur serait donc impossible, ou du moins impensable. Cette aporie derridienne n'est pas un obstacle pour nous : elle est une boussole. Elle nous rappelle que le don n'est jamais simple, jamais acquis, jamais achevé.
|
||||
|
||||
Alain Caillé et le mouvement du MAUSS (Mouvement Anti-Utilitariste en Sciences Sociales) proposent une troisième voie. Pour eux, le don est un **paradigme** — une manière de penser les relations humaines qui ne se réduit ni à l'intérêt (utilitarisme) ni au devoir (moralisme). Le don est un acte libre, mais pas arbitraire. Il est conditionnel, mais pas calculé. Il crée de l'obligation, mais pas de la dette.
|
||||
|
||||
Ces trois perspectives éclairent notre propos :
|
||||
- Avec Mauss, nous comprenons que le don est **structurant** : il crée de la société.
|
||||
- Avec Derrida, nous comprenons que le don est **exigeant** : il résiste à la réduction.
|
||||
- Avec Caillé, nous comprenons que le don est **possible** : il constitue un paradigme viable.
|
||||
|
||||
## Asymétries et Communautés
|
||||
|
||||
L'un des problèmes fondamentaux de toute économie est la question de l'**asymétrie**. Dans notre système actuel, les asymétries sont partout : asymétrie de pouvoir entre employeur et employé, asymétrie d'information entre producteur et consommateur, asymétrie de création monétaire entre banques et citoyens.
|
||||
|
||||
Ces asymétries ne sont pas des accidents. Elles sont **constitutives** du système. Le capitalisme ne fonctionne pas *malgré* les asymétries — il fonctionne *par* les asymétries. Le profit naît de la différence. L'accumulation naît de l'inégalité.
|
||||
|
||||
Dans une économie du don, les asymétries ne disparaissent pas — ce serait naïf de le prétendre. Mais elles changent de nature. L'asymétrie n'est plus un levier d'extraction, mais un moteur de circulation. Celui qui a plus donne plus. Celui qui sait transmet. Celui qui peut aide. Non pas par obligation morale, mais parce que le système rend cette circulation **naturelle et avantageuse pour tous**.
|
||||
|
||||
La notion de **communauté** est centrale ici. Le don ne fonctionne qu'au sein d'un groupe qui se reconnaît comme tel. Ce n'est pas nécessairement une communauté géographique ou ethnique — c'est une communauté de **confiance**. Les membres se font suffisamment confiance pour donner sans garantie immédiate de retour. Cette confiance n'est pas aveugle : elle est construite, entretenue, vérifiée par la pratique.
|
||||
|
||||
Dans la communauté June, cette confiance se construit par la **toile de confiance** (Web of Trust) : chaque nouveau membre est certifié par des membres existants qui le connaissent personnellement. Ce mécanisme garantit que chaque compte correspond à un être humain réel et vivant. Pas de bots, pas de comptes fictifs, pas de manipulation.
|
||||
|
||||
## Le cas emblématique « eco si nuestra »
|
||||
|
||||
L'Espagne nous offre un exemple remarquable avec le réseau **« eco si nuestra »** (notre éco). Ce mouvement, né dans le sillage de la crise de 2008, a développé des réseaux d'échange basés sur des monnaies locales et complémentaires.
|
||||
|
||||
Le principe est simple : des communautés créent leur propre monnaie pour faciliter les échanges locaux. Quand l'euro se fait rare — parce que le chômage explose, parce que les banques ne prêtent plus — ces monnaies locales permettent aux gens de continuer à échanger, à produire, à vivre.
|
||||
|
||||
Ce qui est remarquable dans l'expérience espagnole, c'est la vitesse à laquelle ces initiatives se sont développées face à la crise. Quand le système officiel faillit, les gens inventent spontanément des alternatives. Le don et l'échange non-monétaire ne sont pas des curiosités anthropologiques : ce sont des reflexes de survie et de solidarité.
|
||||
|
||||
## L'expérience « made in zion »
|
||||
|
||||
Plus proche de nous, l'expérience « made in zion » illustre une autre facette du don organisé. Ici, c'est la dimension **culturelle et identitaire** du don qui est mise en avant. Le don devient un acte de résistance, une affirmation d'autonomie face aux circuits économiques dominants.
|
||||
|
||||
Ces expériences montrent que le don n'est pas une abstraction théorique. Il se pratique, il s'organise, il se vit — dans des contextes très divers, face à des défis très concrets. Ce qui manque, ce n'est pas la volonté ni l'imagination. C'est un **cadre théorique solide** et des **outils techniques adaptés** pour passer à l'échelle. C'est précisément ce que proposent la TRM et la monnaie libre.
|
||||
69
content/book/echanger.md
Normal file
69
content/book/echanger.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: "Échanger"
|
||||
description: "Organiser les échanges dans une économie du don : filières, boucles, distribution et marchés Ğ1."
|
||||
order: 7
|
||||
readingTime: "18 min"
|
||||
---
|
||||
|
||||
Produire ne suffit pas. Encore faut-il que la production **circule** — qu'elle atteigne ceux qui en ont besoin, au bon moment, au bon endroit. C'est la question de l'échange, qui est au cœur de toute économie.
|
||||
|
||||
Dans l'économie classique, l'échange est organisé par le marché et médié par le prix. Offre et demande se rencontrent, le prix s'ajuste, les biens circulent. Ce mécanisme est efficace, mais il a un coût : il exclut ceux qui n'ont pas de monnaie, il uniformise les valeurs, il favorise les intermédiaires.
|
||||
|
||||
Dans l'économie du don, l'échange prend d'autres formes. Il est plus direct, plus personnel, plus ancré dans la relation.
|
||||
|
||||
## Filières et boucles
|
||||
|
||||
L'un des objectifs de l'économie de greffe est de créer des **filières** et des **boucles** d'échange en monnaie libre.
|
||||
|
||||
Une **filière** est une chaîne de production-distribution : le maraîcher produit des légumes, le boulanger achète des légumes au maraîcher et vend du pain, le client achète du pain et des légumes. Si toute la filière fonctionne en June, la monnaie circule en interne sans avoir besoin d'être convertie en euro.
|
||||
|
||||
Une **boucle** va plus loin : c'est un circuit fermé où la monnaie revient à son point de départ. A paie B, B paie C, C paie A. La boucle est le Graal de l'économie locale : elle garantit que la monnaie reste dans le territoire et qu'elle profite à tous les participants.
|
||||
|
||||
Construire des filières et des boucles demande de la coordination. Il faut identifier les maillons manquants (quels producteurs manquent pour boucler la boucle ?) et les inciter à rejoindre l'aventure. C'est un travail de **tissage économique**, patient mais gratifiant.
|
||||
|
||||
En pratique, les premières boucles qui se forment sont souvent alimentaires : maraîcher → marché → consommateur → maraîcher. L'alimentation est le besoin le plus universel et le plus fréquent, ce qui en fait le meilleur point de départ pour construire des circuits en monnaie libre.
|
||||
|
||||
## Distribuer
|
||||
|
||||
La question de la **distribution** est souvent négligée dans les projets de monnaie alternative. On se concentre sur la production et l'échange, mais on oublie la logistique : comment acheminer les produits du producteur au consommateur ?
|
||||
|
||||
Dans l'économie classique, la distribution est assurée par un réseau dense et efficace de supermarchés, de plateformes de livraison, de grossistes. Concurrencer ce réseau est illusoire. Mais le compléter — offrir des alternatives pour ceux qui veulent consommer autrement — est tout à fait possible.
|
||||
|
||||
Les formes de distribution en monnaie libre sont variées :
|
||||
- **Marchés physiques** : les marchés June sont des événements réguliers où producteurs et consommateurs se retrouvent
|
||||
- **Vente directe** : de la ferme à l'assiette, sans intermédiaire
|
||||
- **Groupements d'achat** : des consommateurs se regroupent pour commander ensemble
|
||||
- **Plateformes en ligne** : des annonces de produits et services en June
|
||||
|
||||
Chaque forme a ses avantages et ses limites. Le marché physique crée du lien social mais demande de l'organisation. La vente directe est simple mais géographiquement limitée. La plateforme en ligne est accessible mais impersonnelle.
|
||||
|
||||
## Connecter avec l'existant
|
||||
|
||||
L'économie du don ne vit pas en vase clos. Elle coexiste avec l'économie classique, et elle doit **s'y connecter** intelligemment.
|
||||
|
||||
La connexion prend plusieurs formes :
|
||||
- **Double pricing** : les producteurs affichent un prix en euro et un prix en DU
|
||||
- **Paiement mixte** : le client paie une partie en June et une partie en euro
|
||||
- **Passerelles comptables** : les entreprises qui acceptent la June tiennent une comptabilité qui intègre les deux monnaies
|
||||
|
||||
Cette connexion est pragmatique. Elle reconnaît que, pour l'instant, personne ne peut vivre à 100% en monnaie libre. Les charges fixes (loyer, impôts, assurances) se paient en euro. Le lait, le carburant, l'électricité aussi — du moins tant que ces filières ne sont pas développées en June.
|
||||
|
||||
Mais chaque nouveau producteur qui accepte la June, chaque nouvelle filière qui se crée, réduit la dépendance à l'euro. C'est une transition, pas une révolution. Et les transitions réussies sont celles qui avancent pas à pas, sans brûler les ponts.
|
||||
|
||||
## Ğ(marchés)
|
||||
|
||||
Les **Ğmarchés** (prononcer « Junmarchés ») sont les marchés physiques en monnaie libre. Ce sont des lieux de rencontre, d'échange et de convivialité où producteurs et consommateurs se retrouvent régulièrement.
|
||||
|
||||
Un Ğmarché typique rassemble une dizaine à une trentaine de stands : légumes, fruits, pain, miel, savons, vêtements, artisanat, services de massage, couture, informatique... Les prix sont affichés en DU. Les paiements se font via l'application Ğ1 sur smartphone ou par un système de bons papier pour les moins connectés.
|
||||
|
||||
Les Ğmarchés ne sont pas seulement des lieux d'échange économique. Ce sont des lieux de **vie communautaire**. On y vient aussi pour discuter, partager un repas, apprendre, s'entraider. L'aspect social est au moins aussi important que l'aspect économique.
|
||||
|
||||
La fréquence des Ğmarchés varie selon les territoires : hebdomadaire dans les zones les plus actives, mensuel ailleurs. Certains sont itinérants, d'autres ont un lieu fixe. Certains sont couplés à des événements culturels (concerts, conférences, ateliers).
|
||||
|
||||
Le succès d'un Ğmarché dépend de plusieurs facteurs :
|
||||
- La **diversité de l'offre** : plus les stands sont variés, plus les visiteurs trouvent ce qu'ils cherchent
|
||||
- La **régularité** : un marché prévisible fidélise les clients
|
||||
- L'**ambiance** : un marché convivial attire et retient
|
||||
- La **communication** : faire connaître le marché au-delà de la communauté existante
|
||||
|
||||
Les Ğmarchés sont la vitrine de l'économie du don. Ils montrent concrètement que cette économie fonctionne, qu'elle produit de la valeur, qu'elle crée du lien. Pour beaucoup de nouveaux venus, le premier Ğmarché est le déclic qui les convainc de rejoindre l'aventure.
|
||||
51
content/book/et-maintenant.md
Normal file
51
content/book/et-maintenant.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "Et maintenant ?… action ?"
|
||||
description: "L'appel à l'action : comment rejoindre le mouvement, participer et construire ensemble une économie du don."
|
||||
order: 10
|
||||
readingTime: "8 min"
|
||||
---
|
||||
|
||||
Vous avez lu ce livre — ou du moins une partie. Vous avez écouté les chansons — ou du moins certaines. Et maintenant ?
|
||||
|
||||
La question n'est pas rhétorique. Ce livre n'est pas un traité académique destiné à rester sur une étagère. C'est un **appel à l'action**. Un appel à rejoindre, à construire, à expérimenter.
|
||||
|
||||
## Par où commencer ?
|
||||
|
||||
Si vous êtes convaincu — ou même simplement curieux — voici quelques pistes concrètes :
|
||||
|
||||
**1. Créer son compte Ğ1.** C'est la première étape. Rejoindre la toile de confiance, recevoir sa certification, et commencer à co-créer de la monnaie. Le processus prend quelques semaines (le temps de rencontrer des membres existants et d'être certifié), mais il est gratuit et ouvert à tous.
|
||||
|
||||
**2. Rejoindre un Groupe Local June (GLJ).** Les GLJ sont les cellules vivantes de la communauté. Ils organisent des rencontres, des marchés, des ateliers. C'est le meilleur endroit pour rencontrer des monnaie-libristes, poser des questions, et découvrir l'économie en June de l'intérieur.
|
||||
|
||||
**3. Participer à un Ğmarché.** Même sans rien à vendre, allez voir un Ğmarché. Observez comment ça fonctionne. Goûtez un pain fait maison payé en DU. Discutez avec les producteurs. Sentez l'ambiance.
|
||||
|
||||
**4. Proposer un bien ou un service.** Vous savez faire quelque chose ? Proposez-le en June. Cours de guitare, réparation d'ordinateur, traduction, baby-sitting, confection de confitures... Toute compétence a de la valeur.
|
||||
|
||||
**5. Parler autour de vous.** Le bouche-à-oreille est le premier vecteur de développement de la communauté. Parlez de la monnaie libre à vos proches, à vos collègues, à vos voisins. Prêtez ce livre. Partagez les chansons.
|
||||
|
||||
## Les RML et événements
|
||||
|
||||
Les **Rencontres de la Monnaie Libre** (RML) sont des événements régionaux et nationaux qui rassemblent la communauté. Pendant plusieurs jours, les participants échangent, débattent, présentent leurs projets, et font vivre l'économie en June à grande échelle.
|
||||
|
||||
Les RML sont des moments forts de la vie communautaire. On y fait des rencontres, on y noue des liens, on y prend de l'énergie. C'est souvent lors d'une RML qu'on passe du statut de « curieux » à celui de « monnaie-libriste convaincu ».
|
||||
|
||||
D'autres événements ponctuent l'année : les Journées Inter-Nodales (JIN), les ateliers techniques, les formations, les fêtes locales avec Ğmarché intégré.
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
Ce livre ne prétend pas avoir toutes les réponses. Beaucoup de questions restent ouvertes :
|
||||
|
||||
- **L'échelle** : la monnaie libre peut-elle fonctionner à l'échelle d'un pays, d'un continent ? Ou est-elle condamnée à rester locale ?
|
||||
- **La transition** : comment articuler la coexistence euro/June sur le long terme ? L'objectif est-il de remplacer l'euro ou de le compléter ?
|
||||
- **La gouvernance** : comment prendre des décisions collectives dans une communauté décentralisée ? Comment éviter les dérives ?
|
||||
- **La technologie** : les outils actuels (blockchain Duniter, applications Cesium/Tikka) sont-ils suffisamment robustes et accessibles ?
|
||||
|
||||
Ces questions ne sont pas des obstacles. Ce sont des **chantiers** — des invitations à la réflexion et à l'expérimentation collectives.
|
||||
|
||||
## Ce que nous pouvons ensemble
|
||||
|
||||
> Une économie du don — enfin concevable.
|
||||
|
||||
Le titre de ce livre est une promesse. Pas une promesse de résultat, mais une promesse de **possibilité**. Les outils existent. La théorie est solide. Les expériences sont encourageantes. Ce qui manque, c'est le nombre. Plus nous serons nombreux à expérimenter, à produire, à échanger, à imaginer — plus l'économie du don deviendra non seulement concevable, mais réelle.
|
||||
|
||||
Ce livre est un don. Les chansons sont un don. Faites-en ce que vous voulez. Partagez-les, discutez-les, critiquez-les, prolongez-les. C'est ainsi que le don circule.
|
||||
36
content/book/introduction.md
Normal file
36
content/book/introduction.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Introduction"
|
||||
description: "Un livre et des chansons pour rendre concevable une économie du don, portée par la communauté monnaie-libriste."
|
||||
order: 1
|
||||
readingTime: "15 min"
|
||||
---
|
||||
|
||||
Ce livre est une façon de raconter ce que nous vivons, ce que nous expérimentons, et ce que nous pensons être possible. Il ne prétend pas être un manuel d'économie, encore moins un traité de philosophie. C'est un récit collectif, un témoignage d'expérience, un partage de convictions.
|
||||
|
||||
Nous sommes un petit groupe de personnes qui, depuis plusieurs années, explorons une idée simple mais radicale : **une économie fondée sur le don est non seulement souhaitable, mais concevable**. Et nous pensons que les outils pour la construire existent déjà.
|
||||
|
||||
Ce livre est accompagné de neuf chansons. Elles ne sont pas là pour décorer. Elles racontent le livre autrement, par la musique et la poésie. Chaque chanson éclaire un aspect du propos, ouvre une porte émotionnelle là où le texte reste analytique. Elles sont aussi un don — librement accessibles, librement partageables.
|
||||
|
||||
## Le projet
|
||||
|
||||
L'économie du don n'est pas une utopie lointaine. C'est une pratique quotidienne que nous connaissons tous : le repas préparé pour sa famille, le coup de main donné au voisin, le logiciel libre partagé sur Internet, le savoir transmis à un élève. Ce qui est nouveau, ce n'est pas le don. C'est l'idée qu'on puisse en faire le **fondement** d'un système économique entier.
|
||||
|
||||
Pour cela, il faut répondre à des questions difficiles. Comment mesurer sans dénaturer ? Comment échanger sans exploiter ? Comment produire sans détruire ? Comment organiser sans dominer ?
|
||||
|
||||
Ce livre propose des pistes. Il s'appuie sur des travaux théoriques rigoureux — notamment la Théorie Relative de la Monnaie (TRM) de Stéphane Laborde — et sur des expériences concrètes menées par des communautés en France et ailleurs.
|
||||
|
||||
## Monnaie-libristes
|
||||
|
||||
Nous nous appelons « monnaie-libristes ». Ce néologisme désigne les personnes qui utilisent, promeuvent ou contribuent au développement de monnaies libres — c'est-à-dire de monnaies dont la création est symétrique entre tous les membres, présents et futurs.
|
||||
|
||||
La monnaie libre la plus aboutie aujourd'hui est la **Ğ1** (prononcer « June »). Créée en 2017, elle est fondée sur les principes de la TRM. Chaque membre co-crée la même quantité de monnaie, chaque jour, par un Dividende Universel (DU). Personne ne contrôle la création monétaire. Personne n'en est exclu.
|
||||
|
||||
Les monnaie-libristes forment une communauté diverse : informaticiens, agriculteurs, artisans, enseignants, artistes, retraités. Ce qui les rassemble, c'est la conviction qu'une monnaie juste est le socle d'une économie juste. Et qu'une économie juste rend le don non seulement possible, mais naturel.
|
||||
|
||||
La communauté June compte aujourd'hui plusieurs milliers de membres en France et dans le monde francophone. Des marchés en June se tiennent régulièrement. Des producteurs vendent en June. Des services s'échangent en June. Une économie réelle, encore modeste mais vivante, se construit jour après jour.
|
||||
|
||||
Ce livre raconte cette aventure. Il tente d'en expliquer les fondements théoriques, d'en montrer les réalisations pratiques, et d'en dessiner les perspectives. Ce n'est pas un livre de certitudes. C'est un livre de convictions partagées, ouvert à la discussion et à la critique.
|
||||
|
||||
> « Une économie du don — enfin concevable » : le titre de ce livre est à la fois un constat et un programme. Le constat que les outils existent. Le programme qu'il reste à les déployer.
|
||||
|
||||
Bonne lecture. Et si le cœur vous en dit, bonne écoute.
|
||||
26
content/book/la-mesure-du-don.md
Normal file
26
content/book/la-mesure-du-don.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "La mesure du don"
|
||||
description: "Comment mesurer le don sans le dénaturer ? Le retournement sémantique qui rend la mesure possible."
|
||||
order: 3
|
||||
readingTime: "8 min"
|
||||
---
|
||||
|
||||
Mesurer le don : l'expression semble contradictoire. Si le don est libre, spontané, désintéressé, comment peut-on le mesurer sans le trahir ? Et pourtant, si nous voulons construire une *économie* du don — c'est-à-dire un système organisé et durable — il faut bien des repères, des unités, des mesures.
|
||||
|
||||
Le piège, c'est de confondre **mesurer** et **tarifer**. Tarifer, c'est fixer un prix, c'est-à-dire une condition d'accès. Mesurer, c'est observer une grandeur, c'est-à-dire un état de fait. On peut mesurer la température sans la contrôler. On peut mesurer le don sans le commander.
|
||||
|
||||
Dans une économie du don, la mesure sert à **informer**, pas à **conditionner**. Elle permet de savoir où sont les besoins, où sont les ressources, comment les flux circulent. Elle n'impose pas de comportement. Elle éclaire les choix.
|
||||
|
||||
## Retournement sémantique
|
||||
|
||||
Voici le retournement sémantique fondamental que ce livre propose : dans notre économie actuelle, la monnaie mesure le **prix** des choses — c'est-à-dire ce qu'il faut *sacrifier* pour les obtenir. Dans une économie du don, la monnaie mesure le **don** — c'est-à-dire ce qui circule *librement* entre les personnes.
|
||||
|
||||
Ce retournement change tout. Quand la monnaie mesure le prix, elle crée de la rareté : il faut posséder de la monnaie pour accéder aux biens. Quand la monnaie mesure le don, elle crée de la **fluidité** : la monnaie accompagne la circulation des biens, elle ne la bloque pas.
|
||||
|
||||
Dans le système de la monnaie libre Ğ1, chaque personne co-crée la même quantité de monnaie chaque jour — le Dividende Universel. Cette création monétaire n'est pas un « revenu » au sens classique : c'est une **unité de mesure** distribuée symétriquement. Elle permet à chacun de mesurer ses dons et ses réceptions, sans que personne ne soit exclu faute de monnaie.
|
||||
|
||||
La mesure du don n'est donc pas une contradiction. C'est une **nécessité** pour organiser le don à grande échelle. Et le retournement sémantique — passer de la mesure du prix à la mesure du don — est la clé conceptuelle qui rend l'opération possible.
|
||||
|
||||
> Quand on change ce que la monnaie mesure, on change la société qu'elle organise.
|
||||
|
||||
Ce retournement n'est pas seulement théorique. Il a des conséquences pratiques considérables, que les chapitres suivants vont explorer : sur la nature même de la monnaie, sur la manière de produire, d'échanger, de s'organiser collectivement.
|
||||
93
content/book/la-trm.md
Normal file
93
content/book/la-trm.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: "La TRM — Théorie Relative de la Monnaie"
|
||||
description: "Les principes fondamentaux de la Théorie Relative de la Monnaie de Stéphane Laborde : symétrie, relativité et Dividende Universel."
|
||||
order: 5
|
||||
readingTime: "35 min"
|
||||
---
|
||||
|
||||
La **Théorie Relative de la Monnaie** (TRM), formulée par Stéphane Laborde en 2010, est le socle théorique de la monnaie libre. Elle ne propose pas un modèle économique parmi d'autres : elle pose les **conditions mathématiques** qu'une monnaie doit remplir pour respecter les libertés fondamentales de ses utilisateurs.
|
||||
|
||||
La TRM est à la monnaie ce que la théorie de la relativité est à la physique : un changement de référentiel qui transforme radicalement notre compréhension. Tout comme Einstein a montré qu'il n'y a pas d'observateur privilégié dans l'univers physique, la TRM montre qu'il ne devrait pas y avoir de créateur privilégié dans l'univers monétaire.
|
||||
|
||||
## Flux monétaire et vie humaine
|
||||
|
||||
Le point de départ de la TRM est une observation simple mais fondamentale : **les êtres humains naissent, vivent et meurent**. Ils ne sont pas éternels. Toute théorie monétaire qui ignore cette réalité biologique est incomplète.
|
||||
|
||||
Dans le système actuel, la monnaie peut s'accumuler indéfiniment. Des patrimoines se transmettent sur des siècles. La monnaie survit aux individus qui l'ont créée ou gagnée. Cela crée une asymétrie fondamentale entre les générations : les premiers arrivés captent une part disproportionnée de la monnaie, et les suivants doivent se battre pour les miettes.
|
||||
|
||||
La TRM intègre la durée de vie humaine comme paramètre fondamental. Elle prend en compte le renouvellement des générations — en moyenne, une demi-vie de 40 ans dans une communauté donnée. Ce paramètre détermine le taux de création monétaire nécessaire pour maintenir la symétrie entre les membres.
|
||||
|
||||
Le flux monétaire doit être pensé en rapport avec le flux de la vie humaine. Quand un nouveau membre rejoint la communauté, il doit pouvoir participer à l'économie au même titre que les membres les plus anciens. Quand un membre quitte la communauté (par décès ou départ), la monnaie qu'il a co-créée se dilue naturellement dans le temps, sans mécanisme d'héritage ou de transmission forcée.
|
||||
|
||||
## Symétrie dans l'espace-temps
|
||||
|
||||
La TRM définit quatre libertés économiques, par analogie avec les libertés du logiciel libre :
|
||||
|
||||
1. **Liberté de choix du système monétaire** : tout individu est libre d'utiliser la monnaie de son choix
|
||||
2. **Liberté d'accès aux ressources** : la monnaie ne doit pas créer de barrière artificielle
|
||||
3. **Liberté de production** : chacun peut produire toute valeur qu'il estime utile
|
||||
4. **Liberté d'échange** : chacun peut échanger librement avec qui il veut
|
||||
|
||||
Pour que ces quatre libertés soient respectées, la TRM démontre qu'une seule forme de création monétaire est compatible : le **Dividende Universel** (DU). Chaque membre de la communauté crée la même quantité de monnaie, à chaque période, quels que soient son ancienneté, son âge, son activité ou sa situation.
|
||||
|
||||
La symétrie est **spatiale** (tous les membres créent la même chose au même moment) et **temporelle** (les membres futurs auront le même pouvoir de création que les membres présents). C'est cette double symétrie qui garantit la liberté.
|
||||
|
||||
## Relativité
|
||||
|
||||
La TRM introduit un concept crucial : la **relativité de la valeur**. Il n'existe pas de valeur absolue. Un bien n'a pas de prix intrinsèque — il a un prix *relatif* à l'observateur, au contexte, au moment.
|
||||
|
||||
Dans le système actuel, on traite les prix comme s'ils étaient absolus : « cette maison vaut 200 000 euros ». Mais ce prix n'a de sens que dans un contexte donné : un contexte de taux d'intérêt, de masse monétaire, d'offre et de demande locales. Changez le contexte, et le prix change.
|
||||
|
||||
La TRM propose de raisonner en **parts relatives** plutôt qu'en unités absolues. Au lieu de dire « j'ai 1000 Ğ1 », on dit « j'ai X % de la masse monétaire moyenne par membre ». Cette approche relative permet de comparer des situations dans le temps et dans l'espace, indépendamment de la masse monétaire totale.
|
||||
|
||||
En pratique, dans la Ğ1, on exprime souvent les prix en DU plutôt qu'en unités absolues. Un objet qui « vaut 10 DU » vaut l'équivalent de 10 jours de création monétaire d'un individu. Cette unité relative est beaucoup plus stable et significative qu'un nombre absolu.
|
||||
|
||||
## Ancienneté
|
||||
|
||||
Un aspect souvent mal compris de la monnaie libre est la question de l'**ancienneté**. Dans le système actuel, ceux qui sont arrivés en premier ont accumulé plus de monnaie. Dans la monnaie libre, est-ce différent ?
|
||||
|
||||
Oui et non. Oui, à un instant donné, un membre ancien a co-créé plus de DU qu'un membre récent. Mais non, cette différence n'est pas permanente ni croissante. Elle converge vers une valeur maximale déterminée par la durée de vie humaine.
|
||||
|
||||
Mathématiquement, le solde d'un membre qui ne fait qu'accumuler ses DU sans rien dépenser converge vers une valeur finie. Et la part relative de chaque ancien membre dans la masse monétaire totale diminue à mesure que de nouveaux membres arrivent et créent leur propre monnaie.
|
||||
|
||||
C'est la grande différence avec le système actuel : dans la monnaie libre, le temps joue en faveur de l'**égalisation**, pas de la concentration. Les écarts se résorbent naturellement, sans intervention politique ni redistribution forcée.
|
||||
|
||||
## Volume des offres
|
||||
|
||||
La TRM a aussi des implications sur le **volume des offres** — c'est-à-dire la quantité de biens et services disponibles dans l'économie.
|
||||
|
||||
Dans le système fiat, le volume des offres est contraint par la disponibilité du crédit. Si les banques ne prêtent pas, les entreprises ne peuvent pas investir, les consommateurs ne peuvent pas acheter, et la production stagne — même si les ressources physiques et humaines sont disponibles. C'est le paradoxe d'une économie riche en capacités mais pauvre en monnaie.
|
||||
|
||||
Dans une économie en monnaie libre, le volume des offres est libéré de cette contrainte. Chaque membre dispose en permanence de sa création monétaire pour participer à l'économie. La demande n'est plus rationnée par le crédit. Le volume des offres peut ainsi s'ajuster aux besoins réels plutôt qu'aux décisions des banques.
|
||||
|
||||
## Diversité des offres
|
||||
|
||||
La **diversité des offres** est un autre avantage structurel de la monnaie libre. Dans le système actuel, le crédit bancaire favorise les projets les plus « rentables » au sens financier — c'est-à-dire ceux qui génèrent le plus de profit monétaire. Les projets sociaux, culturels, environnementaux ou simplement atypiques peinent à trouver un financement.
|
||||
|
||||
Dans la monnaie libre, chaque membre décide librement comment utiliser sa création monétaire. Il n'y a pas de filtre bancaire, pas de business plan à soumettre, pas de rentabilité à prouver. Un artisan peut vendre son travail en June. Un artiste peut être rémunéré en June. Un voisin peut rendre service et recevoir des June en échange.
|
||||
|
||||
Cette liberté d'usage favorise la **diversité** des offres. On voit apparaître dans les marchés June des produits et services qu'on ne trouverait jamais dans l'économie classique : cours de musique, massages, légumes du jardin, réparation de vélos, conseils juridiques, cours de langue, travaux de couture...
|
||||
|
||||
## Échelles de valeurs
|
||||
|
||||
Un concept fondamental de la TRM est que **chaque individu a sa propre échelle de valeurs**. Ce qui est précieux pour l'un ne l'est pas nécessairement pour l'autre. Et cette diversité des échelles de valeurs est non seulement normale, mais **souhaitable**.
|
||||
|
||||
Le système de prix unique du marché impose une échelle de valeurs commune : le prix en euros. Mais cette uniformité est une illusion. Derrière un même prix, deux acheteurs peuvent avoir des motivations totalement différentes. Le prix ne reflète pas la valeur — il reflète le pouvoir de négociation.
|
||||
|
||||
Dans la monnaie libre, les échanges se font de gré à gré, à des « prix » librement négociés entre les parties. Il n'y a pas de référentiel de prix centralisé. Chacun évalue selon ses propres critères. Cette approche peut sembler chaotique, mais elle est en réalité plus honnête : elle reconnaît que la valeur est **subjective et contextuelle**.
|
||||
|
||||
## Convergence à la moyenne
|
||||
|
||||
L'un des résultats les plus remarquables de la TRM est la démonstration de la **convergence à la moyenne**. Dans un système à Dividende Universel, les comptes des membres convergent naturellement vers une valeur moyenne, exprimée en proportion de la masse monétaire par membre.
|
||||
|
||||
Concrètement : même si des écarts importants existent à un moment donné entre les comptes des membres, ces écarts se réduisent inévitablement avec le temps. Un membre très riche en Ğ1 voit sa part relative diminuer à mesure que de nouvelles créations monétaires arrivent. Un membre pauvre voit sa part relative augmenter.
|
||||
|
||||
Cette convergence n'est pas le résultat d'un impôt ou d'une redistribution. C'est une propriété **mathématique** du Dividende Universel. Elle est automatique, impartiale et irrésistible. C'est la symétrie en action.
|
||||
|
||||
## Commun monétaire
|
||||
|
||||
Le concept de **commun monétaire** résume l'ambition de la TRM. La monnaie libre est un commun : une ressource partagée, co-produite par tous, gouvernée par des règles transparentes et immuables.
|
||||
|
||||
Contrairement aux monnaies fiat (gouvernées par des banques centrales) ou aux cryptomonnaies classiques (gouvernées par des algorithmes de minage qui favorisent les plus puissants), la monnaie libre est gouvernée par un principe simple et universel : chaque être humain crée la même part de monnaie.
|
||||
|
||||
Ce commun monétaire est le fondement de l'économie du don. En garantissant à chacun un accès égal et symétrique à la monnaie, il rend possible une économie où le don circule librement — non pas par charité, mais par structure.
|
||||
76
content/book/raison-d-etre-d-une-monnaie.md
Normal file
76
content/book/raison-d-etre-d-une-monnaie.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: "Raison d'être d'une monnaie"
|
||||
description: "Au-delà des trois fonctions classiques, explorer pourquoi nous avons besoin d'une monnaie — et pourquoi la monnaie actuelle est mal codée."
|
||||
order: 4
|
||||
readingTime: "30 min"
|
||||
---
|
||||
|
||||
Pourquoi une monnaie ? La question semble triviale — tout le monde utilise de la monnaie, donc tout le monde sait à quoi elle sert. Mais cette familiarité est trompeuse. Elle masque des choix fondamentaux, des hypothèses cachées, des asymétries profondes.
|
||||
|
||||
Pour comprendre pourquoi nous avons besoin d'une *autre* monnaie, il faut d'abord comprendre ce qu'est vraiment la monnaie — au-delà de ce qu'on nous en enseigne.
|
||||
|
||||
## Au-delà de ses 3 fonctions
|
||||
|
||||
Les manuels d'économie définissent la monnaie par trois fonctions :
|
||||
1. **Unité de compte** : elle permet de mesurer la valeur des biens et services
|
||||
2. **Intermédiaire des échanges** : elle facilite les transactions
|
||||
3. **Réserve de valeur** : elle permet d'épargner du pouvoir d'achat
|
||||
|
||||
Ces trois fonctions sont réelles, mais elles sont **insuffisantes** pour comprendre la monnaie. Elles décrivent ce que la monnaie *fait*, pas ce qu'elle *est*. Or, ce qu'elle est — c'est-à-dire la manière dont elle est créée, distribuée et gouvernée — détermine le type de société qu'elle engendre.
|
||||
|
||||
Une monnaie créée par le crédit bancaire (comme l'euro) produit une société de débiteurs et de créanciers. Une monnaie créée par un État produit une société de contribuables et de bénéficiaires. Une monnaie créée symétriquement par tous ses membres produit une société de co-créateurs égaux.
|
||||
|
||||
La question « à quoi sert une monnaie ? » doit donc être complétée par une autre question, plus profonde : **« qui crée la monnaie, et selon quelles règles ? »**
|
||||
|
||||
## Bassin économique
|
||||
|
||||
Un concept fondamental pour penser la monnaie est celui de **bassin économique**. Un bassin économique est l'ensemble des personnes et des activités qui utilisent une même monnaie. Ses frontières ne sont pas nécessairement géographiques : elles sont définies par les flux d'échange.
|
||||
|
||||
L'euro, par exemple, définit un bassin économique qui couvre vingt pays. Mais ce bassin est loin d'être homogène : les économies de l'Allemagne et de la Grèce sont très différentes, et le fait qu'elles partagent la même monnaie crée des tensions structurelles.
|
||||
|
||||
À l'inverse, une monnaie locale comme la June crée un bassin économique plus restreint mais plus cohérent. Les membres se connaissent, les besoins sont identifiés, les circuits sont courts. La monnaie circule là où elle est utile.
|
||||
|
||||
Le choix du bassin économique n'est pas neutre. Il détermine qui participe à l'économie et qui en est exclu. Un bassin trop large dilue les liens. Un bassin trop étroit limite les échanges. L'art de la monnaie, c'est aussi l'art de dessiner le bon bassin.
|
||||
|
||||
## Problèmes génétiques des monnaies fiat
|
||||
|
||||
Les monnaies « fiat » — c'est-à-dire les monnaies créées par décret, comme l'euro ou le dollar — souffrent de ce qu'on peut appeler des **problèmes génétiques**. Ces problèmes ne sont pas des bugs : ce sont des *features* du système, inscrites dans son code même.
|
||||
|
||||
## Leçon #1 : La création monétaire est asymétrique
|
||||
|
||||
Premier problème génétique : la monnaie fiat est créée par le **crédit bancaire**. Quand une banque accorde un prêt, elle ne prête pas de l'argent qu'elle possède — elle *crée* l'argent du prêt. Ce mécanisme, connu sous le nom de « création monétaire ex nihilo », signifie que toute la monnaie en circulation est née d'une dette.
|
||||
|
||||
Les conséquences sont profondes :
|
||||
- Pour qu'il y ait de la monnaie, il faut qu'il y ait de la dette
|
||||
- Les intérêts sur la dette nécessitent une croissance permanente de la masse monétaire
|
||||
- Les banques ont un pouvoir considérable : elles décident *qui* a accès à la monnaie et *à quelles conditions*
|
||||
|
||||
Cette asymétrie de création monétaire est le péché originel du système fiat. Elle signifie que certains acteurs — les banques et leurs clients privilégiés — ont un accès à la monnaie que les autres n'ont pas. Ce n'est pas une question de mérite ou de productivité : c'est une question de **position dans le réseau** de création monétaire.
|
||||
|
||||
## Leçon #2 : La monnaie n'est pas neutre
|
||||
|
||||
Deuxième problème : la monnaie fiat prétend être un instrument neutre, un simple voile jeté sur les échanges « réels ». Cette fiction de la neutralité monétaire, héritée de l'économie classique, est contredite par l'expérience.
|
||||
|
||||
La monnaie **oriente** les comportements. Une monnaie fondée sur la dette encourage l'endettement. Une monnaie qui se raréfie encourage la thésaurisation. Une monnaie contrôlée par un petit nombre encourage la concentration du pouvoir.
|
||||
|
||||
Les politiques monétaires des banques centrales — taux directeurs, quantitative easing, etc. — sont la preuve flagrante que la monnaie n'est pas neutre. Chaque décision de politique monétaire redistribue la richesse, favorise certains acteurs au détriment d'autres, oriente l'économie dans une direction plutôt qu'une autre.
|
||||
|
||||
Si la monnaie n'est pas neutre, alors le choix de la monnaie est un **choix politique**. Et comme tout choix politique, il devrait être **démocratique** — ce qui est loin d'être le cas aujourd'hui.
|
||||
|
||||
## Leçon #3 : L'espace-temps monétaire
|
||||
|
||||
Troisième problème, et peut-être le plus subtil : la monnaie fiat ignore la **dimension temporelle** de l'économie. Elle traite les êtres humains comme des entités permanentes, alors qu'ils naissent, vivent et meurent.
|
||||
|
||||
Dans le système actuel, ceux qui possèdent de la monnaie la conservent indéfiniment (hors inflation). Les patrimoines se transmettent de génération en génération, créant des dynasties économiques. Les jeunes arrivent dans un monde où la monnaie est déjà distribuée — souvent très inégalement — et doivent « gagner » leur accès à des ressources que d'autres ont accumulées avant eux.
|
||||
|
||||
La TRM appelle ce problème l'absence de **symétrie temporelle**. Une monnaie juste devrait traiter de manière égale les individus présents et futurs. Elle devrait intégrer le fait que les êtres humains ont une durée de vie limitée, et que chaque génération devrait disposer du même pouvoir monétaire que les précédentes.
|
||||
|
||||
C'est précisément ce que fait le Dividende Universel de la Ğ1 : en créant de la monnaie de manière continue et symétrique, il garantit que chaque nouveau membre dispose de la même opportunité monétaire que ceux qui l'ont précédé. Avec le temps, les différences de solde entre les membres convergent vers la moyenne — non pas par redistribution forcée, mais par le mécanisme même de la création monétaire.
|
||||
|
||||
## Une économie mal codée
|
||||
|
||||
Pour résumer ce chapitre par une métaphore informatique : notre économie actuelle est **mal codée**. Elle repose sur un code monétaire qui produit structurellement de l'inégalité, de l'endettement et de l'exclusion. Ce n'est pas que les gens sont mauvais. C'est que les règles du jeu sont mauvaises.
|
||||
|
||||
Changer les règles du jeu monétaire, c'est changer le code de l'économie. C'est passer d'un code qui concentre le pouvoir à un code qui le distribue. D'un code qui mesure le prix à un code qui mesure le don. D'un code qui exclut à un code qui inclut.
|
||||
|
||||
La TRM propose précisément cela : un nouveau code monétaire, fondé sur la symétrie et la liberté. Le chapitre suivant en détaille les principes.
|
||||
76
content/book/relation-institutionnelle.md
Normal file
76
content/book/relation-institutionnelle.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: "Relation institutionnelle"
|
||||
description: "Naviguer dans le cadre légal et fiscal : impôts, TVA, cotisations sociales et financement de l'écosystème."
|
||||
order: 8
|
||||
readingTime: "20 min"
|
||||
---
|
||||
|
||||
Développer une économie parallèle en monnaie libre ne se fait pas hors-sol. Il existe un cadre légal, fiscal et réglementaire qu'il faut connaître, respecter, et parfois interroger. Ce chapitre aborde les questions institutionnelles que rencontrent les monnaie-libristes dans leur pratique quotidienne.
|
||||
|
||||
## Impôts, taxes et cotisations
|
||||
|
||||
Question récurrente : faut-il déclarer ses revenus en June aux impôts ? La réponse est **oui**. En droit français, tout revenu est imposable, quelle que soit la forme sous laquelle il est perçu. Un producteur qui vend des légumes en June réalise un chiffre d'affaires au même titre que s'il vendait en euro.
|
||||
|
||||
En pratique, la valorisation en euro des transactions en June pose des questions techniques. Comment convertir des DU en euros pour la déclaration fiscale ? Il n'y a pas de taux de change officiel. La pratique la plus courante est de se référer aux prix pratiqués dans les Ğmarchés, ou d'établir une équivalence DU/euro basée sur les prix de produits comparables.
|
||||
|
||||
Les cotisations sociales suivent la même logique : un professionnel qui exerce en June est soumis aux mêmes obligations qu'un professionnel qui exerce en euro. Le statut juridique (auto-entrepreneur, association, coopérative) détermine le régime applicable.
|
||||
|
||||
Il est important de ne pas tomber dans le piège du « c'est de la monnaie alternative, donc c'est hors système ». Ce raisonnement est juridiquement faux et stratégiquement dangereux. L'économie du don n'a pas vocation à se soustraire à l'impôt. Elle a vocation à **transformer** le système de l'intérieur, en construisant une économie plus juste au sein du cadre légal existant.
|
||||
|
||||
## Environnement légal
|
||||
|
||||
La monnaie libre Ğ1 n'est pas une monnaie « officielle » au sens du Code monétaire et financier. Elle n'est ni émise ni garantie par une banque centrale. Juridiquement, elle s'apparente à un **système d'échange local** (SEL) ou à une **monnaie complémentaire**.
|
||||
|
||||
Le cadre légal français est relativement tolérant envers les monnaies complémentaires, à condition qu'elles respectent certaines règles :
|
||||
- Pas de convertibilité automatique en euro (ce qui n'est pas le cas de la Ğ1 par design)
|
||||
- Transparence des règles de fonctionnement
|
||||
- Respect des obligations fiscales et sociales
|
||||
- Pas de publicité mensongère ou de promesses de rendement
|
||||
|
||||
La loi ESS (Économie Sociale et Solidaire) de 2014 a donné un cadre juridique aux monnaies locales complémentaires (MLC), mais la Ğ1 ne rentre pas exactement dans cette catégorie. Elle n'est pas adossée à l'euro, elle n'est pas gérée par une association locale, elle est décentralisée et numérique.
|
||||
|
||||
Cette situation juridique « en marge » n'est pas un handicap. Elle laisse une liberté d'action importante, tant que les participants respectent le droit commun (déclaration des revenus, respect des normes sanitaires pour l'alimentaire, etc.).
|
||||
|
||||
## TVA
|
||||
|
||||
La question de la **TVA** (Taxe sur la Valeur Ajoutée) est l'une des plus complexes. En théorie, toute vente de bien ou service est soumise à la TVA si le vendeur est assujetti. Le fait que le paiement soit en June ne change rien.
|
||||
|
||||
En pratique, la grande majorité des producteurs en June sont des particuliers ou des micro-entrepreneurs en dessous du seuil de franchise de TVA. La question ne se pose donc pas pour eux.
|
||||
|
||||
Pour les structures plus importantes (associations, coopératives, entreprises), la TVA doit être collectée et reversée sur les ventes en June, exactement comme sur les ventes en euro. La base taxable est la valeur en euro de la transaction.
|
||||
|
||||
## Bénévolat et cotisations sociales
|
||||
|
||||
Une question délicate concerne le **bénévolat**. Beaucoup d'activités dans l'économie June s'apparentent à du bénévolat : on donne de son temps, de son énergie, de ses compétences, et on reçoit des June en échange. Mais juridiquement, le bénévolat est par définition non rémunéré. Si le bénévole reçoit une contrepartie (même en monnaie alternative), l'activité peut être requalifiée en **travail**, avec les obligations sociales qui s'y attachent.
|
||||
|
||||
Cette zone grise est source de confusion et d'inquiétude. La frontière entre le coup de main amical (qui n'a pas de valeur économique) et le service rémunéré (qui en a) n'est pas toujours claire.
|
||||
|
||||
La prudence recommande de distinguer clairement :
|
||||
- Les **dons** purs (sans contrepartie attendue) — pas de problème juridique
|
||||
- Les **échanges de services** occasionnels entre particuliers — tolérance administrative
|
||||
- Les **activités régulières et organisées** — nécessitent un cadre juridique (auto-entrepreneur, association, etc.)
|
||||
|
||||
## Le financement de notre écosystème
|
||||
|
||||
Comment financer le développement de l'infrastructure de la monnaie libre ? Les serveurs, les développeurs, les événements, la communication — tout cela a un coût, souvent en euro.
|
||||
|
||||
Plusieurs mécanismes coexistent :
|
||||
- **Dons en euro** : la communauté finance les projets par des dons via des plateformes comme Liberapay
|
||||
- **Cotisations associatives** : certaines structures locales (GLJ, associations) collectent des cotisations
|
||||
- **Contributions en nature** : des développeurs contribuent au code, des graphistes au design, des rédacteurs au contenu
|
||||
- **Auto-financement en June** : à mesure que l'économie June se développe, une part croissante des coûts peut être couverte en monnaie libre
|
||||
|
||||
Le financement de l'écosystème est lui-même un exercice d'économie du don. On ne paie pas un « service » : on contribue à un **commun**. Et cette contribution est libre — dans son montant, sa forme et sa fréquence.
|
||||
|
||||
## Symboles et sémantique
|
||||
|
||||
Les mots comptent. Les symboles comptent. La manière dont on nomme et représente les choses influence la manière dont on les pense.
|
||||
|
||||
La Ğ1 a fait des choix symboliques forts :
|
||||
- Le nom « June » évoque la chaleur, le mois de juin, le soleil — une image positive et accessible
|
||||
- Le symbole Ğ1 (Ğ avec un 1) rappelle la singularité de chaque être humain dans le système
|
||||
- Le Dividende Universel se mesure en « DU » — un acronyme simple qui évoque l'universalité
|
||||
|
||||
Ces choix ne sont pas cosmétiques. Ils sont **stratégiques**. Une monnaie qui porte un nom compliqué ou un symbole obscur aura du mal à se démocratiser. La June a réussi à créer une identité reconnaissable et sympathique, ce qui facilite son adoption.
|
||||
|
||||
La sémantique est aussi un enjeu politique. Parler de « don » plutôt que de « transaction », de « co-création monétaire » plutôt que de « revenu universel », de « commun » plutôt que de « propriété » — ces choix de mots reflètent et renforcent une vision du monde. Ils invitent à penser autrement.
|
||||
28
content/pages/about.md
Normal file
28
content/pages/about.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "À propos"
|
||||
description: "Le Librodrome — une plateforme coopérative pour les productions collectives."
|
||||
---
|
||||
|
||||
# À propos du Librodrome
|
||||
|
||||
Le Librodrome est un espace coopératif dédié à la production collective, afin de couvrir nos besoins et nourrir nos plaisirs de vivre.
|
||||
Ce site est voué à devenir une plateforme support pour les équipes qui se lanceront dans de telles productions, afin de pouvoir coopérer au sein des équipes et avec les autres initiatives.
|
||||
Le librodrome sera également un événement, afin de réunir pysiquement tous les acteurs et faciliter les synergies. Cet événement sera l'occasion de présenter le logiciel libre et la monnaie libre, les deux outils stratégiques qui rendent possible la conception et la réalisation d'une économie alternative.
|
||||
|
||||
Pour commencer : **« Une économie du don — enfin concevable »** accompagné de **9 chansons** qui racontent le livre.
|
||||
|
||||
## La vision
|
||||
|
||||
Nous croyons qu'une économie fondée sur le don est une possibilité concrète. Ce livre et ces chansons sont une invitation à repenser nos rapports économiques, en inversant les flux.
|
||||
|
||||
## L'expérience de lecture
|
||||
|
||||
Le Librodrome propose une expérience unique : une **lecture guidée synchronisée** où chaque chapitre est accompagné de sa chanson, et une **écoute libre** pour savourer la musique indépendamment.
|
||||
|
||||
## L'équipe
|
||||
|
||||
Le Librodrome est un projet coopératif. Si vous en avez le coeur, si vous avez de la disponibilité pour produire, ... nous vous invitons à rejoindre les équipes qui se constituent. Soyez les bienvenus.
|
||||
|
||||
## La suite
|
||||
|
||||
Cette plateforme n'est qu'un début. Nous construisons un espace où d'autres projets de production collective pourront naître et grandir.
|
||||
180
data/librodrome.config.yml
Normal file
180
data/librodrome.config.yml
Normal file
@@ -0,0 +1,180 @@
|
||||
book:
|
||||
title: "Une économie du don — enfin concevable"
|
||||
author: "Yvv"
|
||||
description: "Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don."
|
||||
coverImage: "/images/book-cover.jpg"
|
||||
license: "CC-BY-NC"
|
||||
isbn: "979-1-042-45206-3"
|
||||
|
||||
songs:
|
||||
- id: chanson-01
|
||||
title: "1. Ce livre est une façon"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-01.mp3
|
||||
duration: 718
|
||||
lyrics: ""
|
||||
tags: [introduction, livre, don]
|
||||
|
||||
- id: chanson-02
|
||||
title: "2. Un don qui se mesure"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-02.mp3
|
||||
duration: 589
|
||||
lyrics: ""
|
||||
tags: [don, mesure, valeur]
|
||||
|
||||
- id: chanson-03
|
||||
title: "3. Les asymétries"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-03.mp3
|
||||
duration: 727
|
||||
lyrics: ""
|
||||
tags: [asymétrie, communauté, philosophie]
|
||||
|
||||
- id: chanson-04
|
||||
title: "4. Inverser les flux"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-04.mp3
|
||||
duration: 610
|
||||
lyrics: ""
|
||||
tags: [flux, économie, production]
|
||||
|
||||
- id: chanson-05
|
||||
title: "5. Ainsi soit-il"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-05.mp3
|
||||
duration: 545
|
||||
lyrics: ""
|
||||
tags: [action, engagement, avenir]
|
||||
|
||||
- id: chanson-06
|
||||
title: "6. La croissance, une option ?"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-06.mp3
|
||||
duration: 510
|
||||
lyrics: ""
|
||||
tags: [croissance, monnaie, questionnement]
|
||||
|
||||
- id: chanson-07
|
||||
title: "7. Monnaie libre essence"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-07.mp3
|
||||
duration: 475
|
||||
lyrics: ""
|
||||
tags: [monnaie libre, TRM, June]
|
||||
|
||||
- id: chanson-08
|
||||
title: "8. Des cercles qui se croisent"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-08.mp3
|
||||
duration: 496
|
||||
lyrics: ""
|
||||
tags: [échange, réseau, cercles]
|
||||
|
||||
- id: chanson-09
|
||||
title: "9. Coder la liberté"
|
||||
artist: Yvv
|
||||
file: /audio/chanson-09.mp3
|
||||
duration: 376
|
||||
lyrics: ""
|
||||
tags: [logiciel libre, code, liberté]
|
||||
|
||||
chapterSongs:
|
||||
# Chapitre 1 — Introduction
|
||||
- chapterSlug: introduction
|
||||
songId: chanson-01
|
||||
primary: true
|
||||
- chapterSlug: introduction
|
||||
songId: chanson-02
|
||||
primary: false
|
||||
|
||||
# Chapitre 2 — De quel don parlons-nous ?
|
||||
- chapterSlug: de-quel-don-parlons-nous
|
||||
songId: chanson-03
|
||||
primary: true
|
||||
- chapterSlug: de-quel-don-parlons-nous
|
||||
songId: chanson-01
|
||||
primary: false
|
||||
|
||||
# Chapitre 3 — La mesure du don
|
||||
- chapterSlug: la-mesure-du-don
|
||||
songId: chanson-02
|
||||
primary: true
|
||||
- chapterSlug: la-mesure-du-don
|
||||
songId: chanson-03
|
||||
primary: false
|
||||
|
||||
# Chapitre 4 — Raison d'être d'une monnaie
|
||||
- chapterSlug: raison-d-etre-d-une-monnaie
|
||||
songId: chanson-06
|
||||
primary: true
|
||||
- chapterSlug: raison-d-etre-d-une-monnaie
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
# Chapitre 5 — La TRM
|
||||
- chapterSlug: la-trm
|
||||
songId: chanson-07
|
||||
primary: true
|
||||
- chapterSlug: la-trm
|
||||
songId: chanson-06
|
||||
primary: false
|
||||
|
||||
# Chapitre 6 — Créer une économie ?
|
||||
- chapterSlug: creer-une-economie
|
||||
songId: chanson-04
|
||||
primary: true
|
||||
- chapterSlug: creer-une-economie
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
# Chapitre 7 — Échanger
|
||||
- chapterSlug: echanger
|
||||
songId: chanson-08
|
||||
primary: true
|
||||
- chapterSlug: echanger
|
||||
songId: chanson-04
|
||||
primary: false
|
||||
|
||||
# Chapitre 8 — Relation institutionnelle
|
||||
- chapterSlug: relation-institutionnelle
|
||||
songId: chanson-05
|
||||
primary: false
|
||||
- chapterSlug: relation-institutionnelle
|
||||
songId: chanson-08
|
||||
primary: false
|
||||
|
||||
# Chapitre 9 — Autres greffes
|
||||
- chapterSlug: autres-greffes
|
||||
songId: chanson-04
|
||||
primary: false
|
||||
- chapterSlug: autres-greffes
|
||||
songId: chanson-08
|
||||
primary: false
|
||||
|
||||
# Chapitre 10 — Et maintenant ?
|
||||
- chapterSlug: et-maintenant
|
||||
songId: chanson-05
|
||||
primary: true
|
||||
- chapterSlug: et-maintenant
|
||||
songId: chanson-09
|
||||
primary: false
|
||||
|
||||
# Chapitre 11 — Annexes
|
||||
- chapterSlug: annexes
|
||||
songId: chanson-09
|
||||
primary: true
|
||||
- chapterSlug: annexes
|
||||
songId: chanson-07
|
||||
primary: false
|
||||
|
||||
defaultPlaylistOrder:
|
||||
- chanson-01
|
||||
- chanson-02
|
||||
- chanson-03
|
||||
- chanson-04
|
||||
- chanson-05
|
||||
- chanson-06
|
||||
- chanson-07
|
||||
- chanson-08
|
||||
- chanson-09
|
||||
7
data/messages.yml
Normal file
7
data/messages.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
messages:
|
||||
- id: 1
|
||||
author: test
|
||||
email: ""
|
||||
text: test
|
||||
published: true
|
||||
createdAt: 2026-02-20T01:23:38.633Z
|
||||
15
data/pages/book-player.yml
Normal file
15
data/pages/book-player.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
cover:
|
||||
title: "Une économie du don"
|
||||
subtitle: "enfin concevable"
|
||||
description: "Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don."
|
||||
cta: "Commencer le voyage"
|
||||
coverAlt: "Couverture"
|
||||
reader:
|
||||
sommaireTitle: "Sommaire"
|
||||
hints:
|
||||
desktop: "← → pages · ↑ ↓ chapitres · Esc fermer"
|
||||
mobile: "Glissez pour naviguer"
|
||||
default: "Esc pour fermer"
|
||||
pdf:
|
||||
barTitle: "Une économie du don — enfin concevable"
|
||||
iframeTitle: "Lecture du livre — Une économie du don"
|
||||
7
data/pages/ecouter.yml
Normal file
7
data/pages/ecouter.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
kicker: Écoute libre
|
||||
title: Les 9 chansons
|
||||
description: Ecoutez librement les chansons qui racontent le livre.
|
||||
searchPlaceholder: Rechercher une chanson...
|
||||
noResults: Aucune chanson ne correspond à votre recherche.
|
||||
meta:
|
||||
title: Écouter
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user