This commit is contained in:
ordinarthur 2026-04-05 12:11:46 +02:00
commit 9d172a4422
28 changed files with 13359 additions and 0 deletions

154
.astro/content.d.ts vendored Normal file
View File

@ -0,0 +1,154 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = never;
}

5
.astro/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1775383544839
}
}

1
.astro/types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
public/

13
astro.config.mjs Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
output: 'static',
site: 'https://aureliebarre.fr',
vite: {
css: {
preprocessorOptions: {}
}
}
});

6192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "aurelie-barre-portfolio",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^6.1.3",
"@astrojs/react": "^4.2.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@sanity/client": "^7.3.0",
"@sanity/image-url": "^1.1.0",
"motion": "^12.7.3"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"typescript": "^5.8.3"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp"
]
}
}

4009
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,77 @@
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'processStep',
title: 'Étape du processus',
type: 'document',
fields: [
defineField({
name: 'title',
title: "Titre de l'étape",
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'stepNumber',
title: "Numéro d'étape",
type: 'number',
validation: (rule) => rule.required().min(1).max(10),
}),
defineField({
name: 'subtitle',
title: 'Sous-titre',
type: 'string',
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
rows: 4,
}),
defineField({
name: 'icon',
title: "Icône / illustration de l'étape",
type: 'image',
}),
defineField({
name: 'gallery',
title: 'Photos illustratives',
type: 'array',
of: [
{
type: 'image',
options: { hotspot: true },
fields: [
defineField({ name: 'alt', title: 'Texte alternatif', type: 'string' }),
defineField({ name: 'caption', title: 'Légende', type: 'string' }),
],
},
],
}),
defineField({
name: 'duration',
title: 'Durée estimée',
type: 'string',
}),
],
preview: {
select: {
title: 'title',
subtitle: 'subtitle',
stepNumber: 'stepNumber',
},
prepare({ title, subtitle, stepNumber }) {
return {
title: `${stepNumber}. ${title}`,
subtitle,
};
},
},
orderings: [
{
title: 'Par numéro',
name: 'stepNumberAsc',
by: [{ field: 'stepNumber', direction: 'asc' }],
},
],
});

138
sanity/schemas/project.ts Normal file
View File

@ -0,0 +1,138 @@
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'project',
title: 'Projet',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Titre du projet',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug (URL)',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'category',
title: 'Catégorie',
type: 'string',
options: {
list: [
{ title: 'Réalisation Pérenne', value: 'perenne' },
{ title: 'Création Événement', value: 'event' },
],
layout: 'radio',
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'client',
title: 'Client',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'date',
title: 'Date du projet',
type: 'date',
}),
defineField({
name: 'location',
title: 'Lieu',
type: 'string',
}),
defineField({
name: 'description',
title: 'Description courte',
type: 'text',
rows: 3,
}),
defineField({
name: 'heroImage',
title: 'Image principale',
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
title: 'Texte alternatif',
type: 'string',
}),
],
}),
defineField({
name: 'gallery',
title: 'Galerie photos',
type: 'array',
of: [
{
type: 'image',
options: { hotspot: true },
fields: [
defineField({
name: 'alt',
title: 'Texte alternatif',
type: 'string',
}),
defineField({
name: 'size',
title: "Taille d'affichage",
type: 'string',
options: {
list: [
{ title: 'Petit', value: 'small' },
{ title: 'Moyen', value: 'medium' },
{ title: 'Grand', value: 'large' },
],
},
initialValue: 'medium',
}),
],
},
],
}),
defineField({
name: 'tags',
title: 'Tags',
type: 'array',
of: [{ type: 'string' }],
options: { layout: 'tags' },
}),
defineField({
name: 'featured',
title: "Mis en avant sur la page d'accueil",
type: 'boolean',
initialValue: false,
}),
defineField({
name: 'order',
title: "Ordre d'affichage",
type: 'number',
}),
],
preview: {
select: {
title: 'title',
subtitle: 'client',
media: 'heroImage',
},
},
orderings: [
{
title: 'Par ordre',
name: 'orderAsc',
by: [{ field: 'order', direction: 'asc' }],
},
{
title: 'Par date',
name: 'dateDesc',
by: [{ field: 'date', direction: 'desc' }],
},
],
});

View File

@ -0,0 +1,22 @@
import { defineType, defineField } from 'sanity';
export default defineType({
name: 'siteSettings',
title: 'Paramètres du site',
type: 'document',
fields: [
defineField({ name: 'siteName', title: 'Nom du site', type: 'string' }),
defineField({ name: 'tagline', title: 'Accroche', type: 'string' }),
defineField({ name: 'description', title: 'Description SEO', type: 'text', rows: 3 }),
defineField({ name: 'email', title: 'Email de contact', type: 'string' }),
defineField({ name: 'phone', title: 'Téléphone', type: 'string' }),
defineField({ name: 'instagram', title: 'Instagram', type: 'url' }),
defineField({ name: 'linkedin', title: 'LinkedIn', type: 'url' }),
defineField({
name: 'heroImages',
title: "Images hero page d'accueil",
type: 'array',
of: [{ type: 'image', options: { hotspot: true } }],
}),
],
});

108
src/components/Footer.astro Normal file
View File

@ -0,0 +1,108 @@
---
const year = new Date().getFullYear();
---
<footer class="footer">
<div class="container">
<div class="divider"></div>
<div class="footer__inner">
<div class="footer__left">
<p class="footer__name">Aurélie Barré</p>
<p class="footer__tagline text-secondary">Design d'intérieur & Création événementielle</p>
</div>
<div class="footer__center">
<a href="mailto:contact@aureliebarre.fr" class="footer__link">contact@aureliebarre.fr</a>
</div>
<div class="footer__right">
<div class="footer__social">
<a href="https://instagram.com/aureliebarre" target="_blank" rel="noopener noreferrer" aria-label="Instagram" class="footer__social-link">
Instagram
</a>
<a href="https://linkedin.com/in/aureliebarre" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" class="footer__social-link">
LinkedIn
</a>
</div>
<p class="footer__copyright text-light">&copy; {year}</p>
</div>
</div>
</div>
</footer>
<style>
.footer {
padding-top: var(--space-xl);
padding-bottom: var(--space-lg);
}
.footer__inner {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-top: var(--space-xl);
gap: var(--space-lg);
}
.footer__name {
font-family: var(--font-serif);
font-size: 1.1rem;
margin-bottom: var(--space-xs);
}
.footer__tagline {
font-size: 0.85rem;
}
.footer__link {
font-size: 0.85rem;
letter-spacing: 0.02em;
color: var(--color-text-secondary);
transition: color var(--duration-fast) var(--ease-out);
}
.footer__link:hover {
color: var(--color-accent);
}
.footer__right {
text-align: right;
}
.footer__social {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-sm);
}
.footer__social-link {
font-size: 0.85rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.footer__social-link:hover {
color: var(--color-accent);
}
.footer__copyright {
font-size: 0.75rem;
}
@media (max-width: 768px) {
.footer__inner {
flex-direction: column;
align-items: center;
text-align: center;
}
.footer__right {
text-align: center;
}
.footer__social {
justify-content: center;
}
}
</style>

257
src/components/Header.astro Normal file
View File

@ -0,0 +1,257 @@
---
const currentPath = Astro.url.pathname;
const navItems = [
{ label: 'Réalisations Pérennes', href: '/realisations-perennes' },
{ label: 'Créations Événement', href: '/creations-evenement' },
{ label: 'Processus Créatif', href: '/processus' },
];
---
<header class="header">
<div class="header__inner container">
<a href="/" class="header__logo">
<span class="header__logo-name">Aurélie Barré</span>
</a>
<nav class="header__nav" aria-label="Navigation principale">
<ul class="header__nav-list">
{navItems.map(item => (
<li>
<a
href={item.href}
class:list={['header__nav-link', { 'active': currentPath.startsWith(item.href) }]}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
<button class="header__menu-btn" aria-label="Ouvrir le menu" aria-expanded="false">
<span class="header__menu-line"></span>
<span class="header__menu-line"></span>
</button>
</div>
<!-- Mobile overlay -->
<div class="header__overlay" aria-hidden="true">
<nav class="header__overlay-nav">
{navItems.map(item => (
<a href={item.href} class="header__overlay-link">
{item.label}
</a>
))}
</nav>
</div>
</header>
<script>
const btn = document.querySelector('.header__menu-btn');
const overlay = document.querySelector('.header__overlay');
const header = document.querySelector('.header');
btn?.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!isOpen));
overlay?.setAttribute('aria-hidden', String(isOpen));
header?.classList.toggle('menu-open');
document.body.style.overflow = isOpen ? '' : 'hidden';
});
// Close on link click
document.querySelectorAll('.header__overlay-link').forEach(link => {
link.addEventListener('click', () => {
btn?.setAttribute('aria-expanded', 'false');
overlay?.setAttribute('aria-hidden', 'true');
header?.classList.remove('menu-open');
document.body.style.overflow = '';
});
});
// Header scroll effect
let lastScroll = 0;
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
if (scrollY > 100) {
header?.classList.add('scrolled');
} else {
header?.classList.remove('scrolled');
}
lastScroll = scrollY;
});
</script>
<style>
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: var(--header-height);
background-color: transparent;
transition: background-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out);
}
.header.scrolled {
background-color: rgba(250, 250, 248, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 1px 0 var(--color-border-light);
}
.header__inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
.header__logo {
text-decoration: none;
z-index: 101;
}
.header__logo-name {
font-family: var(--font-serif);
font-size: 1.4rem;
color: var(--color-text);
letter-spacing: 0.01em;
}
.header__nav-list {
display: flex;
gap: var(--space-xl);
list-style: none;
}
.header__nav-link {
font-size: 0.85rem;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-secondary);
text-decoration: none;
position: relative;
padding-bottom: 2px;
transition: color var(--duration-fast) var(--ease-out);
}
.header__nav-link::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 1px;
background-color: var(--color-accent);
transition: width var(--duration-normal) var(--ease-out);
}
.header__nav-link:hover,
.header__nav-link.active {
color: var(--color-text);
}
.header__nav-link:hover::after,
.header__nav-link.active::after {
width: 100%;
}
/* Mobile menu button */
.header__menu-btn {
display: none;
flex-direction: column;
gap: 6px;
background: none;
border: none;
cursor: pointer;
padding: 8px;
z-index: 101;
}
.header__menu-line {
display: block;
width: 24px;
height: 1.5px;
background-color: var(--color-text);
transition: transform var(--duration-normal) var(--ease-out),
opacity var(--duration-fast);
}
.menu-open .header__menu-line:first-child {
transform: translateY(3.75px) rotate(45deg);
}
.menu-open .header__menu-line:last-child {
transform: translateY(-3.75px) rotate(-45deg);
}
/* Mobile overlay */
.header__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity var(--duration-normal) var(--ease-out),
visibility var(--duration-normal);
z-index: 99;
}
.menu-open .header__overlay {
opacity: 1;
visibility: visible;
}
.header__overlay-nav {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-lg);
}
.header__overlay-link {
font-family: var(--font-serif);
font-size: clamp(1.8rem, 5vw, 2.5rem);
color: var(--color-text);
text-decoration: none;
opacity: 0;
transform: translateY(20px);
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out),
color var(--duration-fast);
}
.menu-open .header__overlay-link {
opacity: 1;
transform: translateY(0);
}
.menu-open .header__overlay-link:nth-child(1) { transition-delay: 100ms; }
.menu-open .header__overlay-link:nth-child(2) { transition-delay: 200ms; }
.menu-open .header__overlay-link:nth-child(3) { transition-delay: 300ms; }
.header__overlay-link:hover {
color: var(--color-accent);
}
@media (max-width: 768px) {
.header__nav {
display: none;
}
.header__menu-btn {
display: flex;
}
}
</style>

View File

@ -0,0 +1,255 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
interface GalleryImage {
src: string;
alt: string;
size?: 'small' | 'medium' | 'large';
}
interface Props {
images: GalleryImage[];
}
export default function MasonryGallery({ images }: Props) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [columns, setColumns] = useState(3);
const containerRef = useRef<HTMLDivElement>(null);
// Responsive columns
useEffect(() => {
const updateColumns = () => {
const width = window.innerWidth;
if (width < 640) setColumns(1);
else if (width < 1024) setColumns(2);
else setColumns(3);
};
updateColumns();
window.addEventListener('resize', updateColumns);
return () => window.removeEventListener('resize', updateColumns);
}, []);
// Distribute images across columns
const getColumns = useCallback(() => {
const cols: GalleryImage[][] = Array.from({ length: columns }, () => []);
images.forEach((img, i) => {
cols[i % columns].push(img);
});
return cols;
}, [images, columns]);
// Lightbox keyboard nav
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (selectedIndex === null) return;
if (e.key === 'Escape') setSelectedIndex(null);
if (e.key === 'ArrowRight') setSelectedIndex(prev =>
prev !== null && prev < images.length - 1 ? prev + 1 : prev
);
if (e.key === 'ArrowLeft') setSelectedIndex(prev =>
prev !== null && prev > 0 ? prev - 1 : prev
);
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [selectedIndex, images.length]);
// Lock body scroll when lightbox open
useEffect(() => {
document.body.style.overflow = selectedIndex !== null ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [selectedIndex]);
const flatIndex = (colIdx: number, rowIdx: number) => {
return rowIdx * columns + colIdx;
};
return (
<>
<div ref={containerRef} style={gridContainerStyle(columns)}>
{getColumns().map((col, colIdx) => (
<div key={colIdx} style={columnStyle}>
{col.map((img, rowIdx) => {
const idx = flatIndex(colIdx, rowIdx);
return (
<motion.div
key={idx}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.06, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
style={imageWrapperStyle}
onClick={() => setSelectedIndex(idx)}
role="button"
tabIndex={0}
aria-label={`Voir ${img.alt}`}
>
<img
src={img.src}
alt={img.alt}
loading="lazy"
decoding="async"
style={imageStyle}
onMouseEnter={(e) => {
(e.target as HTMLImageElement).style.transform = 'scale(1.03)';
}}
onMouseLeave={(e) => {
(e.target as HTMLImageElement).style.transform = 'scale(1)';
}}
/>
</motion.div>
);
})}
</div>
))}
</div>
{/* Lightbox */}
<AnimatePresence>
{selectedIndex !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
style={lightboxStyle}
onClick={() => setSelectedIndex(null)}
>
{/* Close button */}
<button
style={closeButtonStyle}
onClick={() => setSelectedIndex(null)}
aria-label="Fermer"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<line x1="4" y1="4" x2="20" y2="20" />
<line x1="20" y1="4" x2="4" y2="20" />
</svg>
</button>
{/* Counter */}
<div style={counterStyle}>
{selectedIndex + 1} / {images.length}
</div>
{/* Navigation arrows */}
{selectedIndex > 0 && (
<button
style={{ ...arrowStyle, left: '2rem' }}
onClick={(e) => { e.stopPropagation(); setSelectedIndex(selectedIndex - 1); }}
aria-label="Photo précédente"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<polyline points="15,4 7,12 15,20" />
</svg>
</button>
)}
{selectedIndex < images.length - 1 && (
<button
style={{ ...arrowStyle, right: '2rem' }}
onClick={(e) => { e.stopPropagation(); setSelectedIndex(selectedIndex + 1); }}
aria-label="Photo suivante"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<polyline points="9,4 17,12 9,20" />
</svg>
</button>
)}
{/* Image */}
<motion.img
key={selectedIndex}
src={images[selectedIndex].src}
alt={images[selectedIndex].alt}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
style={lightboxImageStyle}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
)}
</AnimatePresence>
</>
);
}
// --- Inline Styles ---
const gridContainerStyle = (cols: number): React.CSSProperties => ({
display: 'grid',
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gap: '1rem',
});
const columnStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '1rem',
};
const imageWrapperStyle: React.CSSProperties = {
overflow: 'hidden',
cursor: 'pointer',
borderRadius: '2px',
backgroundColor: '#F3F1ED',
};
const imageStyle: React.CSSProperties = {
width: '100%',
height: 'auto',
display: 'block',
transition: 'transform 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
};
const lightboxStyle: React.CSSProperties = {
position: 'fixed',
inset: 0,
zIndex: 200,
backgroundColor: 'rgba(0, 0, 0, 0.92)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
};
const lightboxImageStyle: React.CSSProperties = {
maxWidth: '90vw',
maxHeight: '90vh',
objectFit: 'contain',
cursor: 'default',
};
const closeButtonStyle: React.CSSProperties = {
position: 'absolute',
top: '2rem',
right: '2rem',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '8px',
zIndex: 201,
};
const counterStyle: React.CSSProperties = {
position: 'absolute',
top: '2rem',
left: '50%',
transform: 'translateX(-50%)',
color: 'rgba(255,255,255,0.6)',
fontSize: '0.85rem',
letterSpacing: '0.08em',
zIndex: 201,
};
const arrowStyle: React.CSSProperties = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '12px',
zIndex: 201,
opacity: 0.7,
transition: 'opacity 0.2s',
};

View File

@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from 'react';
import { motion, useInView } from 'motion/react';
interface ProcessStep {
title: string;
stepNumber: number;
subtitle: string;
description: string;
duration?: string;
}
interface Props {
steps: ProcessStep[];
}
function StepCard({ step, index }: { step: ProcessStep; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: '-100px 0px' });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 60 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 60 }}
transition={{ duration: 0.8, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
style={stepStyle}
>
<div style={stepLeftStyle}>
<div style={stepNumberContainerStyle}>
<motion.span
initial={{ scale: 0 }}
animate={isInView ? { scale: 1 } : { scale: 0 }}
transition={{ delay: 0.2, duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
style={stepNumberStyle}
>
{String(step.stepNumber).padStart(2, '0')}
</motion.span>
{index < 4 && <div style={stepLineStyle} />}
</div>
</div>
<div style={stepContentStyle}>
<span style={stepSubtitleStyle}>{step.subtitle}</span>
<h3 style={stepTitleStyle}>{step.title}</h3>
<p style={stepDescStyle}>{step.description}</p>
{step.duration && (
<span style={stepDurationStyle}>{step.duration}</span>
)}
</div>
</motion.div>
);
}
export default function ProcessTimeline({ steps }: Props) {
const [activeStep, setActiveStep] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current) return;
const children = containerRef.current.children;
const viewportCenter = window.innerHeight / 2;
for (let i = children.length - 1; i >= 0; i--) {
const rect = children[i].getBoundingClientRect();
if (rect.top < viewportCenter) {
setActiveStep(i);
break;
}
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div style={containerStyle}>
{/* Progress indicator */}
<div style={progressBarContainerStyle}>
<div style={progressTrackStyle}>
<motion.div
style={progressFillStyle}
animate={{ height: `${((activeStep + 1) / steps.length) * 100}%` }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
/>
</div>
{steps.map((_, i) => (
<motion.div
key={i}
style={{
...progressDotStyle,
top: `${(i / (steps.length - 1)) * 100}%`,
}}
animate={{
backgroundColor: i <= activeStep ? '#C4A77D' : '#E8E6E1',
scale: i === activeStep ? 1.3 : 1,
}}
transition={{ duration: 0.3 }}
/>
))}
</div>
{/* Steps */}
<div ref={containerRef} style={stepsContainerStyle}>
{steps.map((step, index) => (
<StepCard key={step.stepNumber} step={step} index={index} />
))}
</div>
</div>
);
}
// --- Styles ---
const containerStyle: React.CSSProperties = {
display: 'flex',
gap: '3rem',
position: 'relative',
maxWidth: '900px',
margin: '0 auto',
};
const progressBarContainerStyle: React.CSSProperties = {
position: 'sticky',
top: '40vh',
height: '300px',
width: '2px',
alignSelf: 'flex-start',
display: 'none', // Hidden on mobile, shown via media query in parent
};
const progressTrackStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#E8E6E1',
borderRadius: '1px',
};
const progressFillStyle: React.CSSProperties = {
width: '100%',
backgroundColor: '#C4A77D',
borderRadius: '1px',
};
const progressDotStyle: React.CSSProperties = {
position: 'absolute',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '8px',
height: '8px',
borderRadius: '50%',
zIndex: 1,
};
const stepsContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '6rem',
flex: 1,
};
const stepStyle: React.CSSProperties = {
display: 'flex',
gap: '2rem',
alignItems: 'flex-start',
};
const stepLeftStyle: React.CSSProperties = {
flexShrink: 0,
width: '60px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
};
const stepNumberContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
};
const stepNumberStyle: React.CSSProperties = {
fontFamily: "'DM Serif Display', Georgia, serif",
fontSize: '2rem',
color: '#C4A77D',
lineHeight: 1,
};
const stepLineStyle: React.CSSProperties = {
width: '1px',
height: '100px',
backgroundColor: '#E8E6E1',
marginTop: '0.5rem',
};
const stepContentStyle: React.CSSProperties = {
flex: 1,
paddingTop: '0.25rem',
};
const stepSubtitleStyle: React.CSSProperties = {
fontSize: '0.75rem',
letterSpacing: '0.1em',
textTransform: 'uppercase' as const,
color: '#9A9A9A',
display: 'block',
marginBottom: '0.5rem',
};
const stepTitleStyle: React.CSSProperties = {
fontFamily: "'DM Serif Display', Georgia, serif",
fontSize: 'clamp(1.5rem, 3vw, 2rem)',
fontWeight: 400,
marginBottom: '1rem',
lineHeight: 1.3,
color: '#1A1A1A',
};
const stepDescStyle: React.CSSProperties = {
fontSize: '1rem',
lineHeight: 1.7,
color: '#6B6B6B',
maxWidth: '55ch',
};
const stepDurationStyle: React.CSSProperties = {
display: 'inline-block',
marginTop: '1rem',
fontSize: '0.8rem',
letterSpacing: '0.04em',
color: '#C4A77D',
padding: '4px 12px',
border: '1px solid #E8E6E1',
borderRadius: '2px',
};

View File

@ -0,0 +1,123 @@
---
interface Props {
title: string;
client: string;
slug: string;
category: 'perenne' | 'event';
heroImage: string;
description: string;
tags?: string[];
index?: number;
}
const { title, client, slug, category, heroImage, description, tags = [], index = 0 } = Astro.props;
const baseUrl = category === 'perenne' ? '/realisations-perennes' : '/creations-evenement';
const href = `${baseUrl}/${slug}`;
const delay = `${index * 100}ms`;
---
<a href={href} class="project-card reveal" style={`transition-delay: ${delay}`}>
<div class="project-card__image img-hover-zoom">
<img
src={heroImage}
alt={title}
loading="lazy"
decoding="async"
/>
<div class="project-card__overlay">
<span class="project-card__cta">Découvrir</span>
</div>
</div>
<div class="project-card__info">
<span class="project-card__client text-light">{client}</span>
<h3 class="project-card__title">{title}</h3>
{tags.length > 0 && (
<div class="project-card__tags">
{tags.slice(0, 3).map(tag => (
<span class="project-card__tag">{tag}</span>
))}
</div>
)}
</div>
</a>
<style>
.project-card {
display: block;
text-decoration: none;
color: inherit;
}
.project-card__image {
position: relative;
aspect-ratio: 4 / 3;
background-color: var(--color-bg-alt);
margin-bottom: var(--space-md);
}
.project-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-card__overlay {
position: absolute;
inset: 0;
background: rgba(26, 26, 26, 0);
display: flex;
align-items: center;
justify-content: center;
transition: background var(--duration-normal) var(--ease-out);
}
.project-card__cta {
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: white;
opacity: 0;
transform: translateY(10px);
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
}
.project-card:hover .project-card__overlay {
background: rgba(26, 26, 26, 0.25);
}
.project-card:hover .project-card__cta {
opacity: 1;
transform: translateY(0);
}
.project-card__client {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
display: block;
margin-bottom: var(--space-xs);
}
.project-card__title {
font-family: var(--font-serif);
font-size: 1.2rem;
font-weight: 400;
margin-bottom: var(--space-sm);
}
.project-card__tags {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.project-card__tag {
font-size: 0.7rem;
letter-spacing: 0.04em;
color: var(--color-text-light);
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 2px;
}
</style>

10
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_SANITY_PROJECT_ID: string;
readonly PUBLIC_SANITY_DATASET: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,79 @@
---
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
image?: string;
}
const {
title = 'Aurélie Barré — Design d\'intérieur & Création événementielle',
description = 'Portfolio d\'Aurélie Barré, designeuse d\'intérieur et directrice artistique spécialisée en design d\'expérience et création événementielle.',
image = '/og-image.jpg'
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:type" content="website" />
<!-- Fonts: DM Serif Display + Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Inter:wght@300;400;500&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body>
<Header />
<main class="page-enter">
<slot />
</main>
<Footer />
<script>
// Intersection Observer for scroll reveal animations
const observerCallback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
};
const observer = new IntersectionObserver(observerCallback, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
});
document.querySelectorAll('.reveal').forEach((el) => {
observer.observe(el);
});
</script>
</body>
</html>
<style>
main {
min-height: calc(100vh - var(--header-height));
}
</style>

346
src/lib/mock-data.ts Normal file
View File

@ -0,0 +1,346 @@
// Mock data for development without Sanity connection
// Will be replaced by Sanity queries in production
export interface MockProject {
_id: string;
title: string;
slug: { current: string };
category: 'perenne' | 'event';
client: string;
date: string;
location?: string;
description: string;
heroImage: string;
gallery: Array<{ src: string; alt: string; size: 'small' | 'medium' | 'large' }>;
tags: string[];
featured: boolean;
order: number;
}
// Helper: generate gallery from numbered files
function makeGallery(basePath: string, count: number, ext: string, title: string): MockProject['gallery'] {
const sizes: Array<'small' | 'medium' | 'large'> = ['large', 'medium', 'small', 'medium', 'large', 'small', 'medium', 'large', 'small', 'medium', 'large', 'medium'];
return Array.from({ length: count }, (_, i) => ({
src: `${basePath}/${String(i + 1).padStart(2, '0')}.${ext}`,
alt: `${title} — Photo ${i + 1}`,
size: sizes[i % sizes.length],
}));
}
export const mockProjects: MockProject[] = [
// --- EVENTS ---
{
_id: '1',
title: 'Inside Moët & Chandon',
slug: { current: 'moet-chandon' },
category: 'event',
client: 'Moët & Chandon',
date: '2019-06',
location: 'Paris, Champs-Élysées',
description: 'Scénographie d\'un rooftop éphémère avec vue sur l\'Arc de Triomphe pour le lancement de la collection Inside.',
heroImage: '/images/events/moet/01.jpg',
gallery: makeGallery('/images/events/moet', 12, 'jpg', 'Moët & Chandon'),
tags: ['Luxe', 'Rooftop', 'Scénographie'],
featured: true,
order: 1,
},
{
_id: '2',
title: 'Engie — Convention',
slug: { current: 'engie' },
category: 'event',
client: 'Engie',
date: '2019-06',
location: 'Paris',
description: 'Design global et direction artistique pour la convention annuelle Engie. Création d\'univers immersifs déclinés en plusieurs espaces.',
heroImage: '/images/events/engie/01.jpg',
gallery: makeGallery('/images/events/engie', 10, 'jpg', 'Engie'),
tags: ['Corporate', 'Convention', 'Direction artistique'],
featured: false,
order: 2,
},
{
_id: '3',
title: 'Martell — Lancement',
slug: { current: 'martell' },
category: 'event',
client: 'Martell',
date: '2019-09',
location: 'Paris',
description: 'Conception et réalisation de l\'espace de lancement pour Martell. Un mélange subtil de tradition et de modernité.',
heroImage: '/images/events/martell/01.jpg',
gallery: makeGallery('/images/events/martell', 5, 'jpg', 'Martell'),
tags: ['Luxe', 'Lancement produit', 'Spiritueux'],
featured: false,
order: 3,
},
{
_id: '4',
title: 'AlUla — Paris & Arabie',
slug: { current: 'alula' },
category: 'event',
client: 'AlUla',
date: '2020-01',
location: 'Paris & Arabie Saoudite',
description: 'Création d\'un parcours sensoriel entre Paris et l\'Arabie Saoudite, mêlant patrimoine culturel et design contemporain.',
heroImage: '/images/events/alula/01.jpg',
gallery: makeGallery('/images/events/alula', 12, 'jpg', 'AlUla'),
tags: ['International', 'Culture', 'Immersif'],
featured: true,
order: 4,
},
{
_id: '5',
title: 'LVMH — Métaverse',
slug: { current: 'lvmh-metaverse' },
category: 'event',
client: 'LVMH',
date: '2024-03',
location: 'Paris',
description: 'Direction artistique de l\'événement LVMH Métaverse. Une immersion entre monde physique et digital.',
heroImage: '/images/events/lvmh-metaverse/01.jpg',
gallery: makeGallery('/images/events/lvmh-metaverse', 12, 'jpg', 'LVMH Métaverse'),
tags: ['Luxe', 'Digital', 'Innovation'],
featured: true,
order: 5,
},
{
_id: '6',
title: 'Dior — Christmas Popup',
slug: { current: 'dior-christmas' },
category: 'event',
client: 'Dior',
date: '2022-12',
location: 'Paris',
description: 'Scénographie du popup de Noël Dior. Un univers féerique mêlant bleu nuit, or et cristal.',
heroImage: '/images/events/dior/01.jpeg',
gallery: makeGallery('/images/events/dior', 12, 'jpeg', 'Dior Christmas'),
tags: ['Luxe', 'Popup', 'Noël'],
featured: true,
order: 6,
},
{
_id: '7',
title: 'AMI — Showroom',
slug: { current: 'ami' },
category: 'event',
client: 'AMI Paris',
date: '2023-06',
location: 'Paris',
description: 'Design et aménagement du showroom AMI Paris pour la Fashion Week. Minimalisme parisien et chaleur.',
heroImage: '/images/events/ami/01.jpg',
gallery: makeGallery('/images/events/ami', 12, 'jpg', 'AMI Paris'),
tags: ['Mode', 'Showroom', 'Fashion Week'],
featured: false,
order: 7,
},
{
_id: '8',
title: 'Aurora',
slug: { current: 'aurora' },
category: 'event',
client: 'Aurora',
date: '2023-07',
location: 'Paris',
description: 'Création d\'un espace immersif pour l\'événement Aurora. Lumières, textures et mise en scène poétique.',
heroImage: '/images/events/aurora/01.jpg',
gallery: makeGallery('/images/events/aurora', 5, 'jpg', 'Aurora'),
tags: ['Immersif', 'Lumière', 'Poétique'],
featured: false,
order: 8,
},
{
_id: '9',
title: 'Symposium Ryad',
slug: { current: 'symposium-ryad' },
category: 'event',
client: 'Symposium',
date: '2023-11',
location: 'Riyad, Arabie Saoudite',
description: 'Direction artistique et scénographie du symposium international à Riyad.',
heroImage: '/images/events/symposium-ryad/01.jpeg',
gallery: makeGallery('/images/events/symposium-ryad', 7, 'jpeg', 'Symposium Ryad'),
tags: ['International', 'Corporate', 'Moyen-Orient'],
featured: false,
order: 9,
},
{
_id: '10',
title: 'L\'Oréal',
slug: { current: 'loreal' },
category: 'event',
client: 'L\'Oréal',
date: '2024-04',
location: 'Paris',
description: 'Conception de l\'espace événementiel L\'Oréal. Innovation beauté et design d\'expérience.',
heroImage: '/images/events/loreal/01.jpeg',
gallery: makeGallery('/images/events/loreal', 8, 'jpeg', 'L\'Oréal'),
tags: ['Beauté', 'Innovation', 'Corporate'],
featured: false,
order: 10,
},
{
_id: '11',
title: 'LVMH Dare',
slug: { current: 'lvmh-dare' },
category: 'event',
client: 'LVMH',
date: '2024-03',
location: 'Paris',
description: 'Direction artistique de l\'événement LVMH Dare, célébrant l\'innovation au sein du groupe.',
heroImage: '/images/events/lvmh-dare/01.jpeg',
gallery: makeGallery('/images/events/lvmh-dare', 12, 'jpeg', 'LVMH Dare'),
tags: ['Luxe', 'Innovation', 'Corporate'],
featured: false,
order: 11,
},
{
_id: '12',
title: 'La Première',
slug: { current: 'la-premiere' },
category: 'event',
client: 'La Première',
date: '2023-05',
location: 'Paris',
description: 'Création de l\'univers visuel et spatial pour l\'événement La Première.',
heroImage: '/images/events/la-premiere/01.jpeg',
gallery: makeGallery('/images/events/la-premiere', 10, 'jpeg', 'La Première'),
tags: ['Exclusif', 'Design', 'Événementiel'],
featured: false,
order: 12,
},
{
_id: '13',
title: 'Ministry',
slug: { current: 'ministry' },
category: 'event',
client: 'Ministry',
date: '2022-03',
location: 'Paris',
description: 'Aménagement et design d\'espace pour Ministry.',
heroImage: '/images/events/ministry/01.jpg',
gallery: makeGallery('/images/events/ministry', 4, 'jpg', 'Ministry'),
tags: ['Design', 'Espace', 'Événementiel'],
featured: false,
order: 13,
},
{
_id: '14',
title: 'LVMH Digital',
slug: { current: 'lvmh-digital' },
category: 'event',
client: 'LVMH',
date: '2020-06',
location: 'Paris',
description: 'Conception de l\'événement LVMH Digital. Transition numérique et design d\'expérience.',
heroImage: '/images/events/lvmh-digital/01.jpeg',
gallery: makeGallery('/images/events/lvmh-digital', 12, 'jpeg', 'LVMH Digital'),
tags: ['Luxe', 'Digital', 'Corporate'],
featured: false,
order: 14,
},
{
_id: '15',
title: 'Veolia',
slug: { current: 'veolia' },
category: 'event',
client: 'Veolia',
date: '2025-04',
location: 'Paris',
description: 'Design événementiel pour Veolia. Un espace éco-responsable et innovant.',
heroImage: '/images/events/veolia/01.jpeg',
gallery: makeGallery('/images/events/veolia', 6, 'jpeg', 'Veolia'),
tags: ['RSE', 'Corporate', 'Innovation'],
featured: false,
order: 15,
},
// --- PÉRENNES ---
{
_id: '18',
title: 'Cisco — Siège social',
slug: { current: 'cisco-siege' },
category: 'perenne',
client: 'Cisco',
date: '2023-10',
location: 'Paris',
description: 'Aménagement du showroom et des espaces de réception du siège Cisco France. Design contemporain et fonctionnel.',
heroImage: '/images/perennes/cisco/01.jpeg',
gallery: makeGallery('/images/perennes/cisco', 7, 'jpeg', 'Cisco Siège'),
tags: ['Corporate', 'Showroom', 'Bureaux'],
featured: true,
order: 1,
},
{
_id: '19',
title: 'Devoteam — Bureaux',
slug: { current: 'devoteam' },
category: 'perenne',
client: 'Devoteam',
date: '2019-05',
location: 'Paris',
description: 'Réaménagement des espaces de travail Devoteam. Alliance entre collaboration et bien-être au travail.',
heroImage: '/images/perennes/devoteam/01.jpg',
gallery: makeGallery('/images/perennes/devoteam', 8, 'jpg', 'Devoteam'),
tags: ['Corporate', 'Bureaux', 'Workspace'],
featured: true,
order: 2,
},
];
export const mockProcessSteps = [
{
_id: 'p1',
title: 'Prise de contact',
stepNumber: 1,
subtitle: 'Écoute & Compréhension',
description: 'Première rencontre pour comprendre votre univers, vos envies et vos contraintes. Un échange libre autour d\'un café pour poser les bases d\'une collaboration sur mesure.',
duration: '1 à 2 semaines',
},
{
_id: 'p2',
title: 'Conception & Moodboard',
stepNumber: 2,
subtitle: 'Recherche & Inspiration',
description: 'Création de planches d\'inspiration, palettes de couleurs et premiers croquis. Une phase de recherche créative pour traduire vos envies en un univers visuel cohérent.',
duration: '2 à 3 semaines',
},
{
_id: 'p3',
title: 'Sélection & Sourcing',
stepNumber: 3,
subtitle: 'Mobilier, Matériaux & Peintures',
description: 'Curation minutieuse de chaque élément : mobilier, tissus, matériaux, luminaires, peintures. Chaque pièce est choisie pour son caractère, sa qualité et sa cohérence avec l\'ensemble.',
duration: '2 à 4 semaines',
},
{
_id: 'p4',
title: 'Réalisation',
stepNumber: 4,
subtitle: 'De la théorie à la pratique',
description: 'Coordination des artisans et prestataires, suivi de chantier, gestion des imprévus. Une présence terrain pour garantir que chaque détail respecte la vision initiale.',
duration: '4 à 12 semaines',
},
{
_id: 'p5',
title: 'Livraison & Retour',
stepNumber: 5,
subtitle: 'Le résultat & l\'expérience',
description: 'Livraison clé en main, shooting photo du résultat final, et retour d\'expérience avec vous. Parce qu\'un projet réussi, c\'est avant tout un client qui se sent chez lui.',
duration: 'Le jour J',
},
];
export function getMockProjects(category?: 'perenne' | 'event'): MockProject[] {
if (category) {
return mockProjects.filter(p => p.category === category);
}
return mockProjects;
}
export function getMockFeaturedProjects(): MockProject[] {
return mockProjects.filter(p => p.featured);
}
export function getMockProjectBySlug(slug: string): MockProject | undefined {
return mockProjects.find(p => p.slug.current === slug);
}

88
src/lib/sanity.ts Normal file
View File

@ -0,0 +1,88 @@
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
export const sanityClient = createClient({
projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID || 'your-project-id',
dataset: import.meta.env.PUBLIC_SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: true,
});
const builder = imageUrlBuilder(sanityClient);
export function urlFor(source: SanityImageSource) {
return builder.image(source);
}
// --- Types ---
export interface Project {
_id: string;
title: string;
slug: { current: string };
category: 'perenne' | 'event';
client: string;
date: string;
location?: string;
description: string;
heroImage: SanityImageSource & { alt?: string };
gallery: Array<SanityImageSource & {
alt?: string;
size?: 'small' | 'medium' | 'large';
}>;
tags?: string[];
featured?: boolean;
order?: number;
}
export interface ProcessStep {
_id: string;
title: string;
stepNumber: number;
subtitle: string;
description: any; // Block content
icon?: SanityImageSource;
gallery?: Array<SanityImageSource & { alt?: string; caption?: string }>;
duration?: string;
}
// --- Queries ---
export async function getProjects(category?: 'perenne' | 'event'): Promise<Project[]> {
const filter = category
? `*[_type == "project" && category == "${category}"]`
: `*[_type == "project"]`;
return sanityClient.fetch(`${filter} | order(order asc, date desc) {
_id, title, slug, category, client, date, location, description,
heroImage { ..., alt },
gallery[] { ..., alt, size },
tags, featured, order
}`);
}
export async function getProjectBySlug(slug: string): Promise<Project | null> {
return sanityClient.fetch(
`*[_type == "project" && slug.current == $slug][0] {
_id, title, slug, category, client, date, location, description,
heroImage { ..., alt },
gallery[] { ..., alt, size },
tags, featured, order
}`,
{ slug }
);
}
export async function getProcessSteps(): Promise<ProcessStep[]> {
return sanityClient.fetch(`*[_type == "processStep"] | order(stepNumber asc) {
_id, title, stepNumber, subtitle, description, icon, duration,
gallery[] { ..., alt, caption }
}`);
}
export async function getFeaturedProjects(): Promise<Project[]> {
return sanityClient.fetch(`*[_type == "project" && featured == true] | order(order asc) {
_id, title, slug, category, client, date,
heroImage { ..., alt },
description, tags
}`);
}

View File

@ -0,0 +1,234 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import MasonryGallery from '../../components/MasonryGallery.tsx';
import { getMockProjects, getMockProjectBySlug } from '../../lib/mock-data';
export function getStaticPaths() {
const projects = getMockProjects('event');
return projects.map(p => ({ params: { slug: p.slug.current } }));
}
const { slug } = Astro.params;
const project = getMockProjectBySlug(slug!);
if (!project) {
return Astro.redirect('/creations-evenement');
}
const dateFormatted = new Date(project.date).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
});
---
<BaseLayout title={`${project.title} — Aurélie Barré`} description={project.description}>
<!-- Hero Image -->
<section class="project-hero">
<div class="project-hero__image">
<img src={project.heroImage} alt={project.title} />
<div class="project-hero__overlay"></div>
</div>
<div class="project-hero__content container">
<span class="project-hero__client">{project.client}</span>
<h1>{project.title}</h1>
</div>
</section>
<!-- Project Info -->
<section class="section">
<div class="container">
<div class="project-meta reveal">
<div class="project-meta__item">
<span class="project-meta__label">Client</span>
<span class="project-meta__value">{project.client}</span>
</div>
<div class="project-meta__item">
<span class="project-meta__label">Date</span>
<span class="project-meta__value">{dateFormatted}</span>
</div>
{project.location && (
<div class="project-meta__item">
<span class="project-meta__label">Lieu</span>
<span class="project-meta__value">{project.location}</span>
</div>
)}
<div class="project-meta__item">
<span class="project-meta__label">Type</span>
<span class="project-meta__value">Création événementielle</span>
</div>
</div>
<div class="project-description reveal">
<p>{project.description}</p>
</div>
{project.tags && project.tags.length > 0 && (
<div class="project-tags reveal">
{project.tags.map(tag => (
<span class="project-tag">{tag}</span>
))}
</div>
)}
</div>
</section>
<!-- Gallery (Masonry) -->
{project.gallery && project.gallery.length > 0 && (
<section class="section" style="background-color: var(--color-bg-alt);">
<div class="container">
<MasonryGallery
client:visible
images={project.gallery.map(img => ({
src: img.src,
alt: img.alt,
size: img.size,
}))}
/>
</div>
</section>
)}
<!-- Back link -->
<section class="section">
<div class="container">
<a href="/creations-evenement" class="back-link">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="margin-right: 8px;">
<path d="M13 8H3M3 8L7 4M3 8L7 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
Retour aux créations événement
</a>
</div>
</section>
</BaseLayout>
<style>
/* Hero */
.project-hero {
position: relative;
height: 70vh;
min-height: 500px;
display: flex;
align-items: flex-end;
overflow: hidden;
}
.project-hero__image {
position: absolute;
inset: 0;
}
.project-hero__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-hero__overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0) 100%
);
}
.project-hero__content {
position: relative;
z-index: 1;
padding-bottom: var(--space-2xl);
color: white;
}
.project-hero__client {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.7;
display: block;
margin-bottom: var(--space-sm);
}
.project-hero__content h1 {
color: white;
font-size: clamp(2rem, 5vw, 3.5rem);
}
/* Meta */
.project-meta {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
padding-bottom: var(--space-xl);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-xl);
}
.project-meta__label {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-light);
display: block;
margin-bottom: var(--space-xs);
}
.project-meta__value {
font-size: 0.95rem;
color: var(--color-text);
}
/* Description */
.project-description {
margin-bottom: var(--space-xl);
}
.project-description p {
font-size: 1.15rem;
line-height: 1.8;
color: var(--color-text-secondary);
max-width: 700px;
}
/* Tags */
.project-tags {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.project-tag {
font-size: 0.75rem;
letter-spacing: 0.04em;
color: var(--color-text-light);
padding: 4px 12px;
border: 1px solid var(--color-border);
border-radius: 2px;
}
/* Back link */
.back-link {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--duration-fast);
}
.back-link:hover {
color: var(--color-accent);
}
@media (max-width: 768px) {
.project-meta {
grid-template-columns: repeat(2, 1fr);
}
.project-hero {
height: 50vh;
}
}
</style>

View File

@ -0,0 +1,81 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ProjectCard from '../../components/ProjectCard.astro';
import { getMockProjects } from '../../lib/mock-data';
const projects = getMockProjects('event');
---
<BaseLayout title="Créations Événement — Aurélie Barré" description="Découvrez les créations événementielles d'Aurélie Barré : scénographies, pop-ups et événements pour les plus grandes maisons.">
<section class="page-hero">
<div class="container">
<span class="page-hero__label">Portfolio</span>
<h1>Créations<br /><em>Événement</em></h1>
<p class="page-hero__desc text-secondary">
Scénographies éphémères, pop-ups, lancements, conventions —
chaque événement est une expérience unique conçue sur mesure.
</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="projects-grid">
{projects.map((project, index) => (
<ProjectCard
title={project.title}
client={project.client}
slug={project.slug.current}
category={project.category}
heroImage={project.heroImage}
description={project.description}
tags={project.tags}
index={index}
/>
))}
</div>
</div>
</section>
</BaseLayout>
<style>
.page-hero {
padding-top: calc(var(--header-height) + var(--space-3xl));
padding-bottom: var(--space-xl);
}
.page-hero__label {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-light);
display: block;
margin-bottom: var(--space-md);
}
.page-hero h1 {
margin-bottom: var(--space-lg);
}
.page-hero h1 em {
color: var(--color-accent);
}
.page-hero__desc {
font-size: 1.1rem;
line-height: 1.8;
max-width: 600px;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xl) var(--space-lg);
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

249
src/pages/index.astro Normal file
View File

@ -0,0 +1,249 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ProjectCard from '../components/ProjectCard.astro';
import { getMockFeaturedProjects } from '../lib/mock-data';
const featured = getMockFeaturedProjects();
const events = featured.filter(p => p.category === 'event');
const perennes = featured.filter(p => p.category === 'perenne');
---
<BaseLayout>
<!-- Hero Section -->
<section class="hero">
<div class="container">
<div class="hero__content">
<h1 class="hero__title">
<span class="hero__title-line">Design</span>
<span class="hero__title-line hero__title-accent">d'expérience</span>
</h1>
<p class="hero__subtitle">
Aurélie Barré — Directrice artistique & designeuse d'intérieur.<br />
Du concept à la réalisation, chaque espace raconte une histoire.
</p>
<div class="hero__cta">
<a href="/processus" class="hero__link">
Découvrir mon processus
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="margin-left: 8px;">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
</a>
</div>
</div>
</div>
</section>
<!-- Featured Events -->
<section class="section">
<div class="container">
<div class="section-header reveal">
<span class="section-label">Sélection</span>
<h2>Créations Événement</h2>
<a href="/creations-evenement" class="section-link">
Voir tous les projets
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
</a>
</div>
<div class="projects-grid">
{events.slice(0, 4).map((project, index) => (
<ProjectCard
title={project.title}
client={project.client}
slug={project.slug.current}
category={project.category}
heroImage={project.heroImage}
description={project.description}
tags={project.tags}
index={index}
/>
))}
</div>
</div>
</section>
<!-- Featured Pérennes -->
{perennes.length > 0 && (
<section class="section" style="background-color: var(--color-bg-alt);">
<div class="container">
<div class="section-header reveal">
<span class="section-label">Sélection</span>
<h2>Réalisations Pérennes</h2>
<a href="/realisations-perennes" class="section-link">
Voir tous les projets
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
</a>
</div>
<div class="projects-grid">
{perennes.map((project, index) => (
<ProjectCard
title={project.title}
client={project.client}
slug={project.slug.current}
category={project.category}
heroImage={project.heroImage}
description={project.description}
tags={project.tags}
index={index}
/>
))}
</div>
</div>
</section>
)}
<!-- Process Teaser -->
<section class="section process-teaser">
<div class="container">
<div class="process-teaser__inner reveal">
<span class="section-label">Processus</span>
<h2>Comment je travaille</h2>
<p class="process-teaser__text text-secondary">
De la prise de contact à la livraison, chaque projet suit un processus créatif rigoureux
qui garantit un résultat à la hauteur de vos ambitions.
</p>
<div class="process-teaser__steps">
<span class="process-teaser__step">01 — Prise de contact</span>
<span class="process-teaser__step">02 — Conception & Moodboard</span>
<span class="process-teaser__step">03 — Sélection & Sourcing</span>
<span class="process-teaser__step">04 — Réalisation</span>
<span class="process-teaser__step">05 — Livraison & Retour</span>
</div>
<a href="/processus" class="hero__link" style="margin-top: 2rem; display: inline-flex;">
Découvrir en détail
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="margin-left: 8px;">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
</a>
</div>
</div>
</section>
</BaseLayout>
<style>
/* Hero */
.hero {
min-height: 90vh;
display: flex;
align-items: center;
padding-top: var(--header-height);
}
.hero__content {
max-width: 800px;
}
.hero__title {
margin-bottom: var(--space-lg);
}
.hero__title-line {
display: block;
}
.hero__title-accent {
font-style: italic;
color: var(--color-accent);
}
.hero__subtitle {
font-size: clamp(1rem, 2vw, 1.2rem);
color: var(--color-text-secondary);
line-height: 1.8;
margin-bottom: var(--space-xl);
}
.hero__link {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
.hero__link:hover {
color: var(--color-accent-hover);
}
/* Section Headers */
.section-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.section-label {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-light);
width: 100%;
}
.section-header h2 {
margin-right: auto;
}
.section-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
letter-spacing: 0.04em;
color: var(--color-accent);
text-decoration: none;
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Projects Grid */
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xl) var(--space-lg);
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
/* Process Teaser */
.process-teaser__inner {
max-width: 700px;
}
.process-teaser__text {
font-size: 1.1rem;
line-height: 1.8;
margin-top: var(--space-md);
margin-bottom: var(--space-xl);
}
.process-teaser__steps {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.process-teaser__step {
font-family: var(--font-serif);
font-size: 1.1rem;
color: var(--color-text-secondary);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border);
}
</style>

117
src/pages/processus.astro Normal file
View File

@ -0,0 +1,117 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ProcessTimeline from '../components/ProcessTimeline.tsx';
import { mockProcessSteps } from '../lib/mock-data';
---
<BaseLayout title="Processus Créatif — Aurélie Barré" description="Découvrez le processus créatif d'Aurélie Barré, de la prise de contact à la livraison de votre projet de design d'intérieur.">
<section class="page-hero">
<div class="container">
<span class="page-hero__label">Méthode</span>
<h1>Processus<br /><em>Créatif</em></h1>
<p class="page-hero__desc text-secondary">
Chaque projet suit un parcours rigoureux en 5 étapes,
de la première rencontre à la livraison finale.
Une méthode éprouvée pour garantir un résultat à la hauteur de vos attentes.
</p>
</div>
</section>
<section class="section process-section">
<div class="container">
<ProcessTimeline client:visible steps={mockProcessSteps} />
</div>
</section>
<!-- CTA -->
<section class="section cta-section">
<div class="container">
<div class="cta-inner reveal">
<h2>Un projet en tête ?</h2>
<p class="text-secondary">
Discutons de vos envies autour d'un café. Chaque projet commence par une conversation.
</p>
<a href="mailto:contact@aureliebarre.fr" class="cta-link">
Prendre contact
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="margin-left: 8px;">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
</a>
</div>
</div>
</section>
</BaseLayout>
<style>
.page-hero {
padding-top: calc(var(--header-height) + var(--space-3xl));
padding-bottom: var(--space-xl);
}
.page-hero__label {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-light);
display: block;
margin-bottom: var(--space-md);
}
.page-hero h1 {
margin-bottom: var(--space-lg);
}
.page-hero h1 em {
color: var(--color-accent);
}
.page-hero__desc {
font-size: 1.1rem;
line-height: 1.8;
max-width: 600px;
}
.process-section {
padding-top: var(--space-xl);
padding-bottom: var(--space-3xl);
}
/* CTA */
.cta-section {
background-color: var(--color-bg-alt);
}
.cta-inner {
text-align: center;
max-width: 500px;
margin: 0 auto;
}
.cta-inner h2 {
margin-bottom: var(--space-md);
}
.cta-inner p {
margin: 0 auto var(--space-xl);
font-size: 1.05rem;
line-height: 1.8;
}
.cta-link {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent);
text-decoration: none;
padding: 12px 32px;
border: 1px solid var(--color-accent);
transition: all var(--duration-fast) var(--ease-out);
}
.cta-link:hover {
background-color: var(--color-accent);
color: white;
}
</style>

View File

@ -0,0 +1,206 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import MasonryGallery from '../../components/MasonryGallery.tsx';
import { getMockProjects, getMockProjectBySlug } from '../../lib/mock-data';
export function getStaticPaths() {
const projects = getMockProjects('perenne');
return projects.map(p => ({ params: { slug: p.slug.current } }));
}
const { slug } = Astro.params;
const project = getMockProjectBySlug(slug!);
if (!project) {
return Astro.redirect('/realisations-perennes');
}
const dateFormatted = new Date(project.date).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
});
---
<BaseLayout title={`${project.title} — Aurélie Barré`} description={project.description}>
<!-- Hero Image -->
<section class="project-hero">
<div class="project-hero__image">
<img src={project.heroImage} alt={project.title} />
<div class="project-hero__overlay"></div>
</div>
<div class="project-hero__content container">
<span class="project-hero__client">{project.client}</span>
<h1>{project.title}</h1>
</div>
</section>
<!-- Project Info -->
<section class="section">
<div class="container">
<div class="project-meta reveal">
<div class="project-meta__item">
<span class="project-meta__label">Client</span>
<span class="project-meta__value">{project.client}</span>
</div>
<div class="project-meta__item">
<span class="project-meta__label">Date</span>
<span class="project-meta__value">{dateFormatted}</span>
</div>
{project.location && (
<div class="project-meta__item">
<span class="project-meta__label">Lieu</span>
<span class="project-meta__value">{project.location}</span>
</div>
)}
<div class="project-meta__item">
<span class="project-meta__label">Type</span>
<span class="project-meta__value">Réalisation pérenne</span>
</div>
</div>
<div class="project-description reveal">
<p>{project.description}</p>
</div>
{project.tags && project.tags.length > 0 && (
<div class="project-tags reveal">
{project.tags.map(tag => (
<span class="project-tag">{tag}</span>
))}
</div>
)}
</div>
</section>
<!-- Gallery (Masonry) -->
{project.gallery && project.gallery.length > 0 && (
<section class="section" style="background-color: var(--color-bg-alt);">
<div class="container">
<MasonryGallery
client:visible
images={project.gallery.map(img => ({
src: img.src,
alt: img.alt,
size: img.size,
}))}
/>
</div>
</section>
)}
<!-- Back link -->
<section class="section">
<div class="container">
<a href="/realisations-perennes" class="back-link">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="margin-right: 8px;">
<path d="M13 8H3M3 8L7 4M3 8L7 12" stroke="currentColor" stroke-width="1.2"/>
</svg>
Retour aux réalisations pérennes
</a>
</div>
</section>
</BaseLayout>
<style>
.project-hero {
position: relative;
height: 70vh;
min-height: 500px;
display: flex;
align-items: flex-end;
overflow: hidden;
}
.project-hero__image {
position: absolute;
inset: 0;
}
.project-hero__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-hero__overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.1) 50%, transparent 100%);
}
.project-hero__content {
position: relative;
z-index: 1;
padding-bottom: var(--space-2xl);
color: white;
}
.project-hero__client {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.7;
display: block;
margin-bottom: var(--space-sm);
}
.project-hero__content h1 {
color: white;
font-size: clamp(2rem, 5vw, 3.5rem);
}
.project-meta {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-lg);
padding-bottom: var(--space-xl);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-xl);
}
.project-meta__label {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-text-light);
display: block;
margin-bottom: var(--space-xs);
}
.project-meta__value {
font-size: 0.95rem;
}
.project-description { margin-bottom: var(--space-xl); }
.project-description p {
font-size: 1.15rem;
line-height: 1.8;
color: var(--color-text-secondary);
max-width: 700px;
}
.project-tags { display: flex; gap: var(--space-sm); flex-wrap: wrap; }
.project-tag {
font-size: 0.75rem;
letter-spacing: 0.04em;
color: var(--color-text-light);
padding: 4px 12px;
border: 1px solid var(--color-border);
border-radius: 2px;
}
.back-link {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--duration-fast);
}
.back-link:hover { color: var(--color-accent); }
@media (max-width: 768px) {
.project-meta { grid-template-columns: repeat(2, 1fr); }
.project-hero { height: 50vh; }
}
</style>

View File

@ -0,0 +1,81 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ProjectCard from '../../components/ProjectCard.astro';
import { getMockProjects } from '../../lib/mock-data';
const projects = getMockProjects('perenne');
---
<BaseLayout title="Réalisations Pérennes — Aurélie Barré" description="Découvrez les réalisations pérennes d'Aurélie Barré : aménagements de bureaux, showrooms et espaces de vie.">
<section class="page-hero">
<div class="container">
<span class="page-hero__label">Portfolio</span>
<h1>Réalisations<br /><em>Pérennes</em></h1>
<p class="page-hero__desc text-secondary">
Bureaux, showrooms, espaces de vie — des aménagements durables
pensés pour sublimer chaque lieu au quotidien.
</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="projects-grid">
{projects.map((project, index) => (
<ProjectCard
title={project.title}
client={project.client}
slug={project.slug.current}
category={project.category}
heroImage={project.heroImage}
description={project.description}
tags={project.tags}
index={index}
/>
))}
</div>
</div>
</section>
</BaseLayout>
<style>
.page-hero {
padding-top: calc(var(--header-height) + var(--space-3xl));
padding-bottom: var(--space-xl);
}
.page-hero__label {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-light);
display: block;
margin-bottom: var(--space-md);
}
.page-hero h1 {
margin-bottom: var(--space-lg);
}
.page-hero h1 em {
color: var(--color-accent);
}
.page-hero__desc {
font-size: 1.1rem;
line-height: 1.8;
max-width: 600px;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xl) var(--space-lg);
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

229
src/styles/global.css Normal file
View File

@ -0,0 +1,229 @@
/* ==========================================================================
Aurélie Barré Portfolio Global Styles
Direction: "Galerie Blanche" Éditorial Light
========================================================================== */
/* --- CSS Custom Properties --- */
:root {
/* Palette */
--color-bg: #FAFAF8;
--color-bg-alt: #F3F1ED;
--color-surface: #FFFFFF;
--color-text: #1A1A1A;
--color-text-secondary: #6B6B6B;
--color-text-light: #9A9A9A;
--color-accent: #C4A77D;
--color-accent-hover: #B08E5E;
--color-border: #E8E6E1;
--color-border-light: #F0EEEA;
/* Typography */
--font-serif: 'DM Serif Display', Georgia, 'Times New Roman', serif;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 2rem;
--space-xl: 4rem;
--space-2xl: 6rem;
--space-3xl: 8rem;
/* Layout */
--container-max: 1440px;
--container-padding: clamp(1.5rem, 4vw, 4rem);
--header-height: 80px;
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 200ms;
--duration-normal: 400ms;
--duration-slow: 800ms;
}
/* --- Reset & Base --- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.7;
color: var(--color-text);
background-color: var(--color-bg);
overflow-x: hidden;
}
/* --- Typography --- */
h1, h2, h3, h4 {
font-family: var(--font-serif);
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.01em;
}
h1 {
font-size: clamp(2.5rem, 6vw, 5rem);
}
h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
}
h3 {
font-size: clamp(1.3rem, 2.5vw, 1.8rem);
}
p {
max-width: 65ch;
}
a {
color: inherit;
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
}
a:hover {
color: var(--color-accent);
}
img {
display: block;
max-width: 100%;
height: auto;
}
/* --- Utilities --- */
.container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding-left: var(--container-padding);
padding-right: var(--container-padding);
}
.section {
padding-top: var(--space-2xl);
padding-bottom: var(--space-2xl);
}
.text-accent {
color: var(--color-accent);
}
.text-secondary {
color: var(--color-text-secondary);
}
.text-light {
color: var(--color-text-light);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* --- Divider --- */
.divider {
width: 100%;
height: 1px;
background-color: var(--color-border);
}
/* --- Reveal Animation (applied via JS) --- */
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity var(--duration-slow) var(--ease-out),
transform var(--duration-slow) var(--ease-out);
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* --- Masonry Grid --- */
.masonry-grid {
columns: 3;
column-gap: var(--space-md);
}
.masonry-grid > * {
break-inside: avoid;
margin-bottom: var(--space-md);
}
@media (max-width: 1024px) {
.masonry-grid {
columns: 2;
}
}
@media (max-width: 640px) {
.masonry-grid {
columns: 1;
}
}
/* --- Image Hover Effect --- */
.img-hover-zoom {
overflow: hidden;
}
.img-hover-zoom img {
transition: transform var(--duration-slow) var(--ease-out);
}
.img-hover-zoom:hover img {
transform: scale(1.04);
}
/* --- Page Transitions --- */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.page-enter {
animation: fadeIn var(--duration-slow) var(--ease-out) both;
}
/* --- Scrollbar --- */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-light);
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@lib/*": ["src/lib/*"]
}
}
}