initiation librodrome
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user