Intégrer StoryBlok comme CMS headless

- Ajouter @storyblok/astro, @astrojs/node et vite-plugin-mkcert
- Créer src/lib/storyblok.ts (fetch + mapping des blocs spectacle/evenement)
- Ajouter le middleware StoryBlok pour le live preview
- Basculer les pages spectacles, agenda et accueil sur l'API StoryBlok
- Config conditionnelle SSG/SSR via STORYBLOK_IS_PREVIEW (production statique, preview SSR avec visual editor)
- Version draft/published selon l'environnement
- storyblokEditable sur les composants pour le click-to-edit
- HTTPS via mkcert en mode preview
This commit is contained in:
Jalil Arfaoui 2026-03-05 22:22:32 +01:00
parent c8952d6425
commit 3416a6d492
11 changed files with 1602 additions and 41 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
# Token StoryBlok — Preview token en dev/preview, Public token en production
STORYBLOK_TOKEN=
# Mettre à true sur l'instance preview (SSR + visual editor StoryBlok)
# Ne pas définir ou mettre à false en production (SSG)
# STORYBLOK_IS_PREVIEW=true

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ node_modules/
dist/ dist/
.astro/ .astro/
.clever.json .clever.json
.env

View file

@ -1,10 +1,26 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import { loadEnv } from 'vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import mkcert from 'vite-plugin-mkcert';
import icon from 'astro-icon'; import icon from 'astro-icon';
import node from '@astrojs/node';
import { storyblok } from '@storyblok/astro';
const env = loadEnv('', process.cwd(), 'STORYBLOK');
const isPreview = env.STORYBLOK_IS_PREVIEW === 'true';
export default defineConfig({ export default defineConfig({
integrations: [icon()], output: isPreview ? 'server' : 'static',
...(isPreview && { adapter: node({ mode: 'standalone' }) }),
integrations: [
icon(),
storyblok({
accessToken: env.STORYBLOK_TOKEN,
bridge: isPreview,
livePreview: isPreview,
}),
],
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss(), ...(isPreview ? [mkcert()] : [])],
}, },
}); });

1427
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,10 +10,15 @@
"clean": "rm -rf dist" "clean": "rm -rf dist"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.4",
"@iconify-json/lucide": "^1.2.40",
"@storyblok/astro": "^8.0.0",
"@tailwindcss/vite": "^4.1.14",
"astro": "^5.5.0", "astro": "^5.5.0",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"@iconify-json/lucide": "^1.2.40",
"@tailwindcss/vite": "^4.1.14",
"tailwindcss": "^4.1.14" "tailwindcss": "^4.1.14"
},
"devDependencies": {
"vite-plugin-mkcert": "^1.17.10"
} }
} }

91
src/lib/storyblok.ts Normal file
View file

@ -0,0 +1,91 @@
import { useStoryblokApi, renderRichText } from '@storyblok/astro';
import type { SbBlokData } from '@storyblok/astro';
function getVersion(): 'draft' | 'published' {
return import.meta.env.STORYBLOK_IS_PREVIEW === 'true' ? 'draft' : 'published';
}
export interface Spectacle {
id: string;
title: string;
category: 'jeune-public' | 'tout-public';
retired: boolean;
age: string;
duration: string;
summary: string;
credits: string;
image: string;
gallery: string[];
dossierPro: string;
_blok: SbBlokData;
}
export interface AgendaEvent {
id: string;
date: string;
location: string;
spectacleId: string;
bookingLink: string | null;
_blok: SbBlokData;
}
function extractSlug(field: unknown): string {
if (!field) return '';
if (typeof field === 'string') return field;
if (typeof field === 'object' && field !== null) {
const link = field as Record<string, string>;
const fullSlug = link.cached_url || link.slug || '';
return fullSlug.split('/').filter(Boolean).pop() || '';
}
return '';
}
export function mapStoryToSpectacle(story: any): Spectacle {
const c = story.content;
return {
id: story.slug,
title: c.titre || story.name,
category: c.categorie || 'tout-public',
retired: c.retire || false,
age: c.age || '',
duration: c.duree || '',
summary: c.resume ? renderRichText(c.resume) : '',
credits: c.credits || '',
image: c.image?.filename || '',
gallery: (c.galerie || []).map((a: any) => a.filename),
dossierPro: c.dossier_pro?.filename || '',
_blok: c,
};
}
export function mapStoryToEvent(story: any): AgendaEvent {
const c = story.content;
return {
id: story.uuid,
date: c.date || '',
location: c.lieu || '',
spectacleId: extractSlug(c.spectacle_slug),
bookingLink: c.lien_reservation || null,
_blok: c,
};
}
export async function fetchSpectacles(): Promise<Spectacle[]> {
const api = useStoryblokApi();
const { data } = await api.get('cdn/stories', {
content_type: 'spectacle',
version: getVersion(),
per_page: 100,
});
return data.stories.map(mapStoryToSpectacle);
}
export async function fetchAgenda(): Promise<AgendaEvent[]> {
const api = useStoryblokApi();
const { data } = await api.get('cdn/stories', {
content_type: 'evenement',
version: getVersion(),
per_page: 100,
});
return data.stories.map(mapStoryToEvent);
}

1
src/middleware.ts Normal file
View file

@ -0,0 +1 @@
export { onRequest } from '@storyblok/astro/middleware.ts';

View file

@ -1,7 +1,9 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { agenda, spectacles } from '../data'; import { fetchSpectacles, fetchAgenda } from '../lib/storyblok';
const [spectacles, agenda] = await Promise.all([fetchSpectacles(), fetchAgenda()]);
const sortedAgenda = [...agenda].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); const sortedAgenda = [...agenda].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const now = new Date(); const now = new Date();

View file

@ -1,15 +1,19 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StarFilled from '../components/icons/StarFilled.astro'; import { companyInfo } from '../data';
import { companyInfo, spectacles, agenda } from '../data'; import { fetchSpectacles, fetchAgenda } from '../lib/storyblok';
const activeSpectacles = spectacles.filter(s => !s.retired); const [allSpectacles, agenda] = await Promise.all([fetchSpectacles(), fetchAgenda()]);
const activeSpectacles = allSpectacles.filter(s => !s.retired);
const upcomingShows = agenda const upcomingShows = agenda
.filter(event => new Date(event.date) >= new Date()) .filter(event => new Date(event.date) >= new Date())
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.slice(0, 2); .slice(0, 2);
const spectacles = allSpectacles;
--- ---
<Layout> <Layout>
@ -20,17 +24,6 @@ const upcomingShows = agenda
<div class="absolute top-1/3 -right-20 w-[600px] h-[600px] bg-dream-blue/40 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-2000"></div> <div class="absolute top-1/3 -right-20 w-[600px] h-[600px] bg-dream-blue/40 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-2000"></div>
<div class="absolute -bottom-32 left-1/3 w-[550px] h-[550px] bg-dream-pink/40 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-4000"></div> <div class="absolute -bottom-32 left-1/3 w-[550px] h-[550px] bg-dream-pink/40 rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-blob animation-delay-4000"></div>
<!-- Floating decorations -->
<div class="absolute top-20 left-20 animate-float text-star opacity-40">
<StarFilled size={40} />
</div>
<div class="absolute bottom-40 right-20 animate-float animation-delay-2000 text-dream-coral opacity-40">
<Icon name="lucide:sparkles" size={32} />
</div>
<div class="absolute top-1/2 right-1/4 animate-float animation-delay-4000 text-dream-purple opacity-30">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>
</div>
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="animate-entrance-scale"> <div class="animate-entrance-scale">
<div class="inline-block mb-6 px-4 py-2 rounded-full bg-white/50 backdrop-blur-sm border border-white/20 shadow-sm"> <div class="inline-block mb-6 px-4 py-2 rounded-full bg-white/50 backdrop-blur-sm border border-white/20 shadow-sm">

View file

@ -1,24 +1,52 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { spectacles, agenda } from '../../data'; import { getLiveStory, storyblokEditable } from '@storyblok/astro';
import { fetchSpectacles, fetchAgenda, mapStoryToSpectacle } from '../../lib/storyblok';
export function getStaticPaths() { // Requis pour le build statique (ignoré en SSR)
export async function getStaticPaths() {
const [spectacles, agenda] = await Promise.all([fetchSpectacles(), fetchAgenda()]);
return spectacles.map(s => ({ return spectacles.map(s => ({
params: { id: s.id }, params: { id: s.id },
props: {
spectacle: s,
upcomingDates: agenda
.filter(event => event.spectacleId === s.id)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
},
})); }));
} }
const { id } = Astro.params; const { id } = Astro.params;
const spectacle = spectacles.find(s => s.id === id)!; const liveStory = await getLiveStory(Astro);
const upcomingDates = agenda let spectacle;
.filter(event => event.spectacleId === id) let upcomingDates;
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
if (liveStory) {
spectacle = mapStoryToSpectacle(liveStory);
const agenda = await fetchAgenda();
upcomingDates = agenda
.filter(event => event.spectacleId === spectacle.id)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
} else if (Astro.props.spectacle) {
({ spectacle, upcomingDates } = Astro.props);
} else {
const [spectacles, agenda] = await Promise.all([fetchSpectacles(), fetchAgenda()]);
spectacle = spectacles.find(s => s.id === id);
upcomingDates = agenda
.filter(event => event.spectacleId === id)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}
if (!spectacle) {
return Astro.redirect('/spectacles/');
}
--- ---
<Layout title={`${spectacle.title} — Compagnie AspiRêves`}> <Layout title={`${spectacle.title} — Compagnie AspiRêves`}>
<div class="pt-24 md:pt-32 pb-24 min-h-screen bg-cloud overflow-hidden relative"> <div {...storyblokEditable(spectacle._blok)} class="pt-24 md:pt-32 pb-24 min-h-screen bg-cloud overflow-hidden relative">
<!-- Background --> <!-- Background -->
<div class="absolute top-0 right-0 w-96 h-96 bg-dream-purple/20 rounded-full filter blur-[100px] -translate-y-1/2 translate-x-1/2"></div> <div class="absolute top-0 right-0 w-96 h-96 bg-dream-purple/20 rounded-full filter blur-[100px] -translate-y-1/2 translate-x-1/2"></div>
<div class="absolute bottom-0 left-0 w-96 h-96 bg-dream-blue/20 rounded-full filter blur-[100px] translate-y-1/2 -translate-x-1/2"></div> <div class="absolute bottom-0 left-0 w-96 h-96 bg-dream-blue/20 rounded-full filter blur-[100px] translate-y-1/2 -translate-x-1/2"></div>
@ -69,7 +97,7 @@ const upcomingDates = agenda
</div> </div>
{spectacle.summary && ( {spectacle.summary && (
<p class="font-sans text-lg md:text-xl text-night/70 leading-relaxed">{spectacle.summary}</p> <div class="font-sans text-lg md:text-xl text-night/70 leading-relaxed" set:html={spectacle.summary} />
)} )}
{spectacle.credits && ( {spectacle.credits && (

View file

@ -1,9 +1,10 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StarFilled from '../../components/icons/StarFilled.astro'; import { storyblokEditable } from '@storyblok/astro';
import { spectacles } from '../../data'; import { fetchSpectacles } from '../../lib/storyblok';
const spectacles = await fetchSpectacles();
const jeunePublic = spectacles.filter(s => s.category === 'jeune-public' && !s.retired); const jeunePublic = spectacles.filter(s => s.category === 'jeune-public' && !s.retired);
const toutPublic = spectacles.filter(s => s.category === 'tout-public' && !s.retired); const toutPublic = spectacles.filter(s => s.category === 'tout-public' && !s.retired);
const retraites = spectacles.filter(s => s.retired); const retraites = spectacles.filter(s => s.retired);
@ -38,6 +39,7 @@ const retraites = spectacles.filter(s => s.retired);
<div class="space-y-24 md:space-y-40"> <div class="space-y-24 md:space-y-40">
{jeunePublic.map((spectacle, index) => ( {jeunePublic.map((spectacle, index) => (
<div <div
{...storyblokEditable(spectacle._blok)}
class:list={[ class:list={[
'fade-up flex flex-col items-center gap-10 md:gap-16', 'fade-up flex flex-col items-center gap-10 md:gap-16',
index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row', index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row',
@ -78,7 +80,7 @@ const retraites = spectacles.filter(s => s.retired);
)} )}
</div> </div>
{spectacle.summary && ( {spectacle.summary && (
<p class="font-sans text-lg md:text-xl text-night/70 leading-relaxed">{spectacle.summary}</p> <div class="font-sans text-lg md:text-xl text-night/70 leading-relaxed" set:html={spectacle.summary} />
)} )}
<div class="pt-4 md:pt-8 flex flex-wrap justify-center md:justify-start gap-4 md:gap-6"> <div class="pt-4 md:pt-8 flex flex-wrap justify-center md:justify-start gap-4 md:gap-6">
<a <a
@ -107,6 +109,7 @@ const retraites = spectacles.filter(s => s.retired);
const globalIndex = jeunePublic.length + index; const globalIndex = jeunePublic.length + index;
return ( return (
<div <div
{...storyblokEditable(spectacle._blok)}
class:list={[ class:list={[
'fade-up flex flex-col items-center gap-10 md:gap-16', 'fade-up flex flex-col items-center gap-10 md:gap-16',
globalIndex % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row', globalIndex % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row',
@ -175,6 +178,7 @@ const retraites = spectacles.filter(s => s.retired);
<div class="space-y-16 md:space-y-24 opacity-60"> <div class="space-y-16 md:space-y-24 opacity-60">
{retraites.map((spectacle, index) => ( {retraites.map((spectacle, index) => (
<div <div
{...storyblokEditable(spectacle._blok)}
class:list={[ class:list={[
'fade-up flex flex-col items-center gap-10 md:gap-16', 'fade-up flex flex-col items-center gap-10 md:gap-16',
index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row', index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row',
@ -206,7 +210,7 @@ const retraites = spectacles.filter(s => s.retired);
</div> </div>
</div> </div>
{spectacle.summary && ( {spectacle.summary && (
<p class="font-sans text-lg md:text-xl text-night/70 leading-relaxed">{spectacle.summary}</p> <div class="font-sans text-lg md:text-xl text-night/70 leading-relaxed" set:html={spectacle.summary} />
)} )}
<div class="pt-4 md:pt-8 flex flex-wrap justify-center md:justify-start gap-4 md:gap-6"> <div class="pt-4 md:pt-8 flex flex-wrap justify-center md:justify-start gap-4 md:gap-6">
<a <a
@ -224,12 +228,5 @@ const retraites = spectacles.filter(s => s.retired);
)} )}
</div> </div>
<!-- Floating decorations -->
<div class="absolute top-1/4 left-10 text-dream-pink opacity-20 animate-float">
<StarFilled size={60} />
</div>
<div class="absolute bottom-1/4 right-10 text-dream-blue opacity-20 animate-float animation-delay-2000">
<StarFilled size={40} />
</div>
</div> </div>
</Layout> </Layout>