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:
parent
c8952d6425
commit
3416a6d492
11 changed files with 1602 additions and 41 deletions
6
.env.example
Normal file
6
.env.example
Normal 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
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ node_modules/
|
|||
dist/
|
||||
.astro/
|
||||
.clever.json
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import { loadEnv } from 'vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
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({
|
||||
integrations: [icon()],
|
||||
output: isPreview ? 'server' : 'static',
|
||||
...(isPreview && { adapter: node({ mode: 'standalone' }) }),
|
||||
integrations: [
|
||||
icon(),
|
||||
storyblok({
|
||||
accessToken: env.STORYBLOK_TOKEN,
|
||||
bridge: isPreview,
|
||||
livePreview: isPreview,
|
||||
}),
|
||||
],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [tailwindcss(), ...(isPreview ? [mkcert()] : [])],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
1427
package-lock.json
generated
1427
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,10 +10,15 @@
|
|||
"clean": "rm -rf dist"
|
||||
},
|
||||
"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-icon": "^1.1.5",
|
||||
"@iconify-json/lucide": "^1.2.40",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"tailwindcss": "^4.1.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite-plugin-mkcert": "^1.17.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
src/lib/storyblok.ts
Normal file
91
src/lib/storyblok.ts
Normal 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
1
src/middleware.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { onRequest } from '@storyblok/astro/middleware.ts';
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
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 now = new Date();
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StarFilled from '../components/icons/StarFilled.astro';
|
||||
import { companyInfo, spectacles, agenda } from '../data';
|
||||
import { companyInfo } 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
|
||||
.filter(event => new Date(event.date) >= new Date())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.slice(0, 2);
|
||||
|
||||
const spectacles = allSpectacles;
|
||||
---
|
||||
|
||||
<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 -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="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">
|
||||
|
|
|
|||
|
|
@ -1,24 +1,52 @@
|
|||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
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 => ({
|
||||
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 spectacle = spectacles.find(s => s.id === id)!;
|
||||
const liveStory = await getLiveStory(Astro);
|
||||
|
||||
const upcomingDates = agenda
|
||||
let spectacle;
|
||||
let upcomingDates;
|
||||
|
||||
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`}>
|
||||
<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 -->
|
||||
<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>
|
||||
|
|
@ -69,7 +97,7 @@ const upcomingDates = agenda
|
|||
</div>
|
||||
|
||||
{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 && (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StarFilled from '../../components/icons/StarFilled.astro';
|
||||
import { spectacles } from '../../data';
|
||||
import { storyblokEditable } from '@storyblok/astro';
|
||||
import { fetchSpectacles } from '../../lib/storyblok';
|
||||
|
||||
const spectacles = await fetchSpectacles();
|
||||
const jeunePublic = spectacles.filter(s => s.category === 'jeune-public' && !s.retired);
|
||||
const toutPublic = spectacles.filter(s => s.category === 'tout-public' && !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">
|
||||
{jeunePublic.map((spectacle, index) => (
|
||||
<div
|
||||
{...storyblokEditable(spectacle._blok)}
|
||||
class:list={[
|
||||
'fade-up flex flex-col items-center gap-10 md:gap-16',
|
||||
index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row',
|
||||
|
|
@ -78,7 +80,7 @@ const retraites = spectacles.filter(s => s.retired);
|
|||
)}
|
||||
</div>
|
||||
{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">
|
||||
<a
|
||||
|
|
@ -107,6 +109,7 @@ const retraites = spectacles.filter(s => s.retired);
|
|||
const globalIndex = jeunePublic.length + index;
|
||||
return (
|
||||
<div
|
||||
{...storyblokEditable(spectacle._blok)}
|
||||
class:list={[
|
||||
'fade-up flex flex-col items-center gap-10 md:gap-16',
|
||||
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">
|
||||
{retraites.map((spectacle, index) => (
|
||||
<div
|
||||
{...storyblokEditable(spectacle._blok)}
|
||||
class:list={[
|
||||
'fade-up flex flex-col items-center gap-10 md:gap-16',
|
||||
index % 2 === 1 ? 'md:flex-row-reverse' : 'md:flex-row',
|
||||
|
|
@ -206,7 +210,7 @@ const retraites = spectacles.filter(s => s.retired);
|
|||
</div>
|
||||
</div>
|
||||
{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">
|
||||
<a
|
||||
|
|
@ -224,12 +228,5 @@ const retraites = spectacles.filter(s => s.retired);
|
|||
)}
|
||||
</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>
|
||||
</Layout>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue