Internationalisation complète et ajout des pages code, théâtre, acting (FR, EN, AR)

Ajout des pages code et théâtre/acting en FR, EN et AR.
Création de vraies routes localisées /en/photo et /ar/تصوير au lieu du hack ?lang=. Extraction de composants partagés (PhotoHomeContent, PhotoBlogIndexContent, PhotoBlogPostContent, PhotoAlbumContent) pour éviter la duplication entre langues. Traduction des catégories photo (16 fichiers JSON), de la navigation, du footer et des aria-labels.
Routes AR avec slugs arabes (/ar/تصوير/مدونة, /ar/تصوير/ألبومات).
This commit is contained in:
Jalil Arfaoui 2026-02-18 14:14:00 +01:00
parent 4c6f57cd6a
commit 3d23e84b34
56 changed files with 1314 additions and 541 deletions

View file

@ -16,6 +16,22 @@ const translations: Record<string, Record<string, string>> = {
en: '/en/about',
ar: '/ar/نبذة-عني'
},
// Photo
'/photo': {
fr: '/photo',
en: '/en/photo',
ar: '/ar/تصوير'
},
'/en/photo': {
fr: '/photo',
en: '/en/photo',
ar: '/ar/تصوير'
},
'/ar/تصوير': {
fr: '/photo',
en: '/en/photo',
ar: '/ar/تصوير'
},
// Page d'accueil
'/': {
fr: '/',

View file

@ -1,22 +1,28 @@
---
import Logo from "../components/logo.astro";
// Détection de la langue courante
// Détection de la langue courante par le path uniquement
const pathname = Astro.url.pathname;
const currentLang = pathname.startsWith('/en') ? 'en' : pathname.startsWith('/ar') ? 'ar' : 'fr';
// Menu localisé
const menus = {
fr: [
{ name: 'Code', url: '/code' },
{ name: 'Théâtre', url: '/theatre' },
{ name: 'Photo', url: '/photo' },
{ name: 'À propos', url: '/a-propos' }
],
en: [
{ name: 'Photo', url: '/photo' },
{ name: 'Code', url: '/en/code' },
{ name: 'Acting', url: '/en/acting' },
{ name: 'Photo', url: '/en/photo' },
{ name: 'About', url: '/en/about' }
],
ar: [
{ name: 'صور', url: '/photo' },
{ name: 'برمجة', url: '/ar/برمجة' },
{ name: 'مسرح', url: '/ar/مسرح' },
{ name: 'صور', url: '/ar/تصوير' },
{ name: 'نبذة عني', url: '/ar/نبذة-عني' }
]
};

View file

@ -2,12 +2,14 @@
import HomeIcon from "./icons/HomeIcon.astro";
const pathname = Astro.url.pathname;
const currentLang = pathname.startsWith('/en') ? 'en' : pathname.startsWith('/ar') ? 'ar' : 'fr';
const isHome = pathname === '/' || pathname === '/en' || pathname === '/en/' || pathname === '/ar' || pathname === '/ar/';
const homeUrl = currentLang === 'en' ? '/en' : currentLang === 'ar' ? '/ar' : '/';
---
{!isHome && (
<a
href="/"
href={homeUrl}
class="group relative z-30 flex items-center text-neutral-600 dark:text-neutral-300 hover:text-black dark:hover:text-white transition-colors"
title="Accueil"
>

View file

@ -1,6 +1,7 @@
---
import { Picture } from 'astro:assets';
import HeroViewport from './HeroViewport.astro';
import { getDateLocale, type Locale } from '../../utils/i18n';
interface Props {
title: string;
@ -9,9 +10,10 @@ interface Props {
tags?: string[];
coverImage?: ImageMetadata;
scrollTarget?: string;
lang?: Locale;
}
const { title, description, date, tags, coverImage, scrollTarget = '.info-section' } = Astro.props;
const { title, description, date, tags, coverImage, scrollTarget = '.info-section', lang = 'fr' } = Astro.props;
---
{coverImage && (
@ -24,7 +26,7 @@ const { title, description, date, tags, coverImage, scrollTarget = '.info-sectio
{description && <p class="album-description">{description}</p>}
{date && (
<time class="album-date">
{date.toLocaleDateString('fr-FR', {
{date.toLocaleDateString(getDateLocale(lang), {
year: 'numeric',
month: 'long',
day: 'numeric'

View file

@ -4,11 +4,19 @@ import HeroViewport from './HeroViewport.astro';
import Lightbox from './Lightbox.astro';
import { Picture } from 'astro:assets';
import { getEntry } from 'astro:content';
import { getCategoryEntryId, type Locale } from '../../utils/i18n';
const { category } = Astro.props;
interface Props {
category: string;
lang?: Locale;
}
// Récupérer les métadonnées de la catégorie
const categoryData = await getEntry('photoCategories', category);
const { category, lang = 'fr' } = Astro.props;
// Récupérer les métadonnées de la catégorie (localisées avec fallback FR)
const entryId = getCategoryEntryId(category, lang);
const categoryData = await getEntry('photoCategories', entryId)
?? await getEntry('photoCategories', category);
// Auto-détection des images du dossier de la catégorie
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/categories/**/*.{jpg,jpeg,png,webp}');
@ -46,7 +54,7 @@ const lightboxImages = images.map(img => ({
---
<div id="category-gallery" class="category-container">
<CategoryNav currentCategory={category} opaque={true} />
<CategoryNav currentCategory={category} opaque={true} lang={lang} />
<!-- Image hero avec titre en overlay -->
<HeroViewport targetSelector="#thumbnails">
@ -89,7 +97,7 @@ const lightboxImages = images.map(img => ({
</div>
</div>
<Lightbox images={lightboxImages} showCategory={true} category={category} />
<Lightbox images={lightboxImages} showCategory={true} category={category} lang={lang} />
<script>
let lastScrollY = window.scrollY;

View file

@ -1,27 +1,42 @@
---
import { getCollection } from 'astro:content';
import HomeIcon from '../icons/HomeIcon.astro';
import { t, getPhotoBasePath, getPhotoBlogPath, getPhotoAlbumsPath, getHomePath, type Locale } from '../../utils/i18n';
const { currentCategory = '', opaque = false } = Astro.props;
interface Props {
currentCategory?: string;
opaque?: boolean;
lang?: Locale;
}
// Récupérer les catégories depuis la collection, triées par order
const photoCategories = await getCollection('photoCategories');
const sortedCategories = photoCategories.sort((a, b) => (a.data.order || 99) - (b.data.order || 99));
const { currentCategory = '', opaque = false, lang = 'fr' } = Astro.props;
// Catégories photos uniquement
const categories = sortedCategories.map(cat => ({ id: cat.id, title: cat.data.title }));
const photoBasePath = getPhotoBasePath(lang);
const homePath = getHomePath(lang);
// Récupérer les catégories depuis la collection, filtrées par langue
const allCategories = await getCollection('photoCategories');
const langCategories = allCategories.filter(c => (c.data.lang ?? 'fr') === lang);
const effectiveCategories = langCategories.length > 0 ? langCategories : allCategories.filter(c => (c.data.lang ?? 'fr') === 'fr');
const sortedCategories = effectiveCategories.sort((a, b) => (a.data.order || 99) - (b.data.order || 99));
// Extraire l'id de base (sans suffixe de langue)
const categories = sortedCategories.map(cat => ({
id: cat.id.replace(/\.(en|ar)$/, ''),
title: cat.data.title,
}));
---
<nav class={`category-navigation fixed top-0 left-0 right-0 z-50 ${opaque ? 'bg-black' : 'bg-black/30 backdrop-blur-sm'} transition-transform duration-300`}>
<div class="nav-container">
<!-- Titre du site à gauche -->
<div class="site-title">
<a href="/" class="site-link">
<a href={homePath} class="site-link">
<HomeIcon size={16} />
<span class="site-name">Jalil Arfaoui</span>
<span class="site-name">{t('common', 'siteName', lang)}</span>
</a>
<span class="nav-separator"></span>
<a href="/photo" class="nav-link">Photo</a>
<a href={photoBasePath} class="nav-link">{t('nav', 'photo', lang)}</a>
</div>
<!-- Bouton hamburger (mobile) -->
@ -34,10 +49,10 @@ const categories = sortedCategories.map(cat => ({ id: cat.id, title: cat.data.ti
<!-- Navigation -->
<div class="nav-menu" id="navMenu">
<a
href="/photo/blog"
href={getPhotoBlogPath(lang)}
class={`nav-link blog-link ${currentCategory === 'blog' ? 'active' : ''}`}
>
Fil Photo
{t('photo', 'photoFeed', lang)}
</a>
<!-- Séparateur -->
@ -48,7 +63,7 @@ const categories = sortedCategories.map(cat => ({ id: cat.id, title: cat.data.ti
{categories.map(cat => (
<li>
<a
href={`/photo/albums/${cat.id}`}
href={`${getPhotoAlbumsPath(lang)}/${cat.id}`}
class={`nav-link ${currentCategory === cat.id ? 'active' : ''}`}
data-category={cat.id}
>

View file

@ -1,19 +1,29 @@
---
import { getCollection } from 'astro:content';
import { t, getPhotoBlogPath, getPhotoAlbumsPath, type Locale } from '../../utils/i18n';
const photoCategories = await getCollection('photoCategories');
const sortedCategories = photoCategories.sort((a, b) => (a.data.order || 99) - (b.data.order || 99));
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
// Récupérer les catégories filtrées par langue
const allCategories = await getCollection('photoCategories');
const langCategories = allCategories.filter(c => (c.data.lang ?? 'fr') === lang);
const effectiveCategories = langCategories.length > 0 ? langCategories : allCategories.filter(c => (c.data.lang ?? 'fr') === 'fr');
const sortedCategories = effectiveCategories.sort((a, b) => (a.data.order || 99) - (b.data.order || 99));
---
<section id="explore-section" class="explore-section">
<div class="explore-content">
<div class="explore-card">
<h2 class="explore-card-title">Catégories</h2>
<p class="explore-card-desc">Parcourir les photos par thème</p>
<h2 class="explore-card-title">{t('photo', 'categories', lang)}</h2>
<p class="explore-card-desc">{t('photo', 'browseByTheme', lang)}</p>
<ul class="category-list">
{sortedCategories.map(cat => (
<li>
<a href={`/photo/albums/${cat.id}`} class="category-link">
<a href={`${getPhotoAlbumsPath(lang)}/${cat.id.replace(/\.(en|ar)$/, '')}`} class="category-link">
{cat.data.title}
</a>
</li>
@ -22,10 +32,10 @@ const sortedCategories = photoCategories.sort((a, b) => (a.data.order || 99) - (
</div>
<div class="explore-card">
<h2 class="explore-card-title">Fil Photo</h2>
<p class="explore-card-desc">Parcourir les séries chronologiques, reportages et histoires en images</p>
<a href="/photo/blog" class="explore-cta">
Voir le fil
<h2 class="explore-card-title">{t('photo', 'photoFeed', lang)}</h2>
<p class="explore-card-desc">{t('photo', 'feedDescription', lang)}</p>
<a href={getPhotoBlogPath(lang)} class="explore-cta">
{t('photo', 'viewFeed', lang)}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>

View file

@ -1,45 +1,49 @@
---
import { getCollection } from 'astro:content';
import { t, type Locale } from '../../utils/i18n';
interface Props {
images: { src: string; alt: string; title?: string }[];
albumTitle?: string;
showCategory?: boolean;
category?: string;
lang?: Locale;
}
const { images, albumTitle = '', showCategory = false, category = '' } = Astro.props;
const { images, albumTitle = '', showCategory = false, category = '', lang = 'fr' } = Astro.props;
const imagesForJS = JSON.stringify(images);
// Construire les labels depuis la collection
const photoCategories = await getCollection('photoCategories');
// Construire les labels depuis la collection filtrée par langue
const allCategories = await getCollection('photoCategories');
const langCategories = allCategories.filter(c => (c.data.lang ?? 'fr') === lang);
const effectiveCategories = langCategories.length > 0 ? langCategories : allCategories.filter(c => (c.data.lang ?? 'fr') === 'fr');
const categoryLabels: Record<string, string> = {
'blog': 'Fil Photo',
...Object.fromEntries(photoCategories.map(cat => [cat.id, cat.data.title]))
'blog': t('photo', 'photoFeed', lang),
...Object.fromEntries(effectiveCategories.map(cat => [cat.id.replace(/\.(en|ar)$/, ''), cat.data.title]))
};
---
<div id="lightbox" class="lightbox hidden">
<div class="lightbox-controls">
<button class="lightbox-fullscreen" aria-label="Plein écran">
<button class="lightbox-fullscreen" aria-label={t('photo', 'fullscreen', lang)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="lightbox-close" aria-label="Fermer">
<button class="lightbox-close" aria-label={t('photo', 'close', lang)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<button class="lightbox-prev" aria-label="Image précédente">
<button class="lightbox-prev" aria-label={t('photo', 'previousImage', lang)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18L9 12L15 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="lightbox-next" aria-label="Image suivante">
<button class="lightbox-next" aria-label={t('photo', 'nextImage', lang)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M9 18L15 12L9 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

View file

@ -1,12 +1,22 @@
---
import { t, getAboutPath, type Locale } from '../../utils/i18n';
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
---
<footer class="photo-footer">
<div class="photo-footer-inner">
<div class="photo-footer-links">
<a href="/a-propos">À propos</a>
<a href="mailto:jalil@arfaoui.net">Contact</a>
<a href={getAboutPath(lang)}>{t('photo', 'about', lang)}</a>
<a href="mailto:jalil@arfaoui.net">{t('photo', 'contact', lang)}</a>
<a href="https://instagram.com/l.i.l.a.j" target="_blank" rel="noopener noreferrer">Instagram</a>
</div>
<div class="photo-footer-copy">
© Jalil Arfaoui <a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener noreferrer">Creative Commons CC-BY-NC 4.0</a>
&copy; Jalil Arfaoui <a href="https://creativecommons.org/licenses/by-nc/4.0/" target="_blank" rel="noopener noreferrer">Creative Commons CC-BY-NC 4.0</a>
</div>
</div>
</footer>

View file

@ -3,6 +3,14 @@ import CategoryNav from './CategoryNav.astro';
import Slideshow from './Slideshow.astro';
import SlideControls from './SlideControls.astro';
import favorites from '../../data/favorites.json';
import type { Locale } from '../../utils/i18n';
interface Props {
category?: string;
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
// Auto-détection des images
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/categories/**/*.{jpg,jpeg,png,webp}');
@ -44,9 +52,9 @@ const imagesForJS = JSON.stringify(images.map(img => ({
---
<div id="photo-gallery" class="gallery-container">
<CategoryNav currentCategory="" />
<Slideshow images={images} />
<SlideControls />
<CategoryNav currentCategory="" lang={lang} />
<Slideshow images={images} lang={lang} />
<SlideControls lang={lang} />
</div>
<script is:inline define:vars={{ imagesForJS }}>

View file

@ -1,11 +1,18 @@
---
import { t, type Locale } from '../../utils/i18n';
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
---
<div class="slide-controls">
<button
id="prev-btn"
class="control-btn prev-btn"
aria-label="Image précédente"
aria-label={t('photo', 'previousImage', lang)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 18l-6-6 6-6"/>
@ -15,7 +22,7 @@
<button
id="next-btn"
class="control-btn next-btn"
aria-label="Image suivante"
aria-label={t('photo', 'nextImage', lang)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>

View file

@ -1,11 +1,13 @@
---
import { Picture } from 'astro:assets';
import { t, type Locale } from '../../utils/i18n';
interface Props {
images: { src: ImageMetadata; alt: string }[];
lang?: Locale;
}
const { images = [] } = Astro.props;
const { images = [], lang = 'fr' } = Astro.props;
---
<div id="slideshow-container" class="slideshow-wrapper">
@ -33,7 +35,7 @@ const { images = [] } = Astro.props;
<button
class={`indicator ${index === 0 ? 'active' : ''}`}
data-slide={index}
aria-label={`Aller à l'image ${index + 1}`}
aria-label={`${t('photo', 'goToImage', lang)} ${index + 1}`}
/>
))}
</div>

View file

@ -0,0 +1,25 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import CategoryGrid from '../CategoryGrid.astro';
import { getCategoryEntryId, type Locale } from '../../../utils/i18n';
import { getEntry } from 'astro:content';
interface Props {
category: string;
lang?: Locale;
}
const { category, lang = 'fr' } = Astro.props;
// Récupérer le titre localisé pour le title de la page
const entryId = getCategoryEntryId(category, lang);
const categoryData = await getEntry('photoCategories', entryId)
?? await getEntry('photoCategories', category);
const categoryLabel = categoryData?.data.title || category;
const title = `${categoryLabel} - Jalil Arfaoui`;
---
<PhotoLayout title={title} enableScroll={true} lang={lang}>
<CategoryGrid category={category} lang={lang} />
</PhotoLayout>

View file

@ -0,0 +1,338 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import CategoryNav from '../CategoryNav.astro';
import { getCollection } from 'astro:content';
import { Picture } from 'astro:assets';
import { t, getPhotoBlogPath, getDateLocale, getPostBaseSlug, type Locale } from '../../../utils/i18n';
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
const photoBlogPath = getPhotoBlogPath(lang);
const dateLocale = getDateLocale(lang);
// Importer toutes les images pour résoudre les cover images
const allImages = import.meta.glob<{ default: ImageMetadata }>(
'/src/assets/images/photos/blog/**/*.{jpg,jpeg,png,webp}'
);
// Récupération des posts photo filtrés par langue
const allPhotoBlogPosts = (await getCollection('photoBlogPosts'))
.filter(post => (post.data.lang ?? 'fr') === lang);
// Tri par date (plus récent en premier)
const sortedPosts = allPhotoBlogPosts.sort((a, b) =>
new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
// Résoudre les cover images via le glob
const postsWithImages = await Promise.all(sortedPosts.map(async (post) => {
const year = post.data.date.getFullYear();
const baseSlug = getPostBaseSlug(post.id);
const coverPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/${post.data.coverImage}`;
const loader = allImages[coverPath];
const resolvedCoverImage = loader ? (await loader()).default : undefined;
// Slug nettoyé pour les URLs
const cleanSlug = `${year}/${baseSlug}`;
return { ...post, resolvedCoverImage, cleanSlug };
}));
// Séparer les posts à la une des autres
const featuredPosts = postsWithImages.filter(post => post.data.featured);
const regularPosts = postsWithImages.filter(post => !post.data.featured);
---
<PhotoLayout title={`${t('photo', 'blogTitle', lang)} - Jalil Arfaoui`} enableScroll={true} lang={lang}>
<div class="blog-container">
<CategoryNav currentCategory="blog" opaque={false} lang={lang} />
<!-- Section héro avec posts à la une -->
{featuredPosts.length > 0 && (
<section class="featured-section">
<div class="featured-grid">
{featuredPosts.map(post => (
<article class="featured-post">
<a href={`${photoBlogPath}/${post.cleanSlug}`} class="post-link">
<div class="post-image">
{post.resolvedCoverImage && <Picture src={post.resolvedCoverImage} alt={post.data.title} widths={[600, 900, 1200]} formats={['webp']} />}
<div class="post-overlay">
<div class="post-content">
<span class="post-badge">{t('photo', 'featured', lang)}</span>
<h2 class="post-title">{post.data.title}</h2>
<time class="post-date">
{new Date(post.data.date).toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
</div>
</div>
</a>
</article>
))}
</div>
</section>
)}
<!-- Grille des autres posts -->
<section class="posts-grid">
<div class="grid-container">
{regularPosts.map(post => (
<article class="post-item">
<a href={`${photoBlogPath}/${post.cleanSlug}`} class="post-link">
{post.resolvedCoverImage && <Picture src={post.resolvedCoverImage} alt={post.data.title} widths={[400, 600, 800]} formats={['webp']} />}
<div class="post-overlay">
<div class="overlay-content">
<h3 class="post-title">{post.data.title}</h3>
<time class="post-date">
{new Date(post.data.date).toLocaleDateString(dateLocale, {
year: 'numeric',
month: 'short'
})}
</time>
</div>
</div>
</a>
</article>
))}
</div>
</section>
</div>
</PhotoLayout>
<style>
.blog-container {
background: #000000;
color: #ffffff;
min-height: 100vh;
padding-top: 53px;
}
/* Section à la une */
.featured-section {
padding: 16px 16px 0;
}
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 16px;
}
.featured-post {
position: relative;
height: 450px;
overflow: hidden;
border-radius: 8px;
}
.featured-post .post-image {
width: 100%;
height: 100%;
position: relative;
}
.featured-post img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.featured-post:hover img {
transform: scale(1.05);
}
.featured-post .post-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.7) 100%);
display: flex;
align-items: flex-end;
padding: 40px;
}
.featured-post .post-content {
color: white;
}
.post-badge {
background: rgba(255, 255, 255, 0.2);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
display: inline-block;
}
.featured-post .post-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 4px 0;
line-height: 1.2;
}
.featured-post .post-description {
font-size: 15px;
margin: 0 0 10px 0;
opacity: 0.9;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.featured-post .post-date {
display: block;
font-size: 14px;
opacity: 0.8;
}
/* Grille des posts */
.posts-grid {
padding: 16px 16px 100px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.post-item {
overflow: hidden;
border-radius: 4px;
background: #111;
}
.post-link {
display: block;
position: relative;
aspect-ratio: 3/2;
}
.featured-post .post-link {
aspect-ratio: auto;
height: 100%;
}
.post-link img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.post-item .post-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.5) 25%, rgba(0,0,0,0) 50%);
display: flex;
align-items: flex-end;
padding: 12px 16px;
transition: background 0.3s ease;
}
.post-item:hover .post-overlay {
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 25%, rgba(0,0,0,0) 50%);
}
.post-item:hover img {
transform: scale(1.02);
}
.post-item .overlay-content {
color: white;
}
.post-item .post-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 2px 0;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
.post-item .post-subtitle {
font-size: 14px;
margin: 0 0 6px 0;
opacity: 0.9;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.post-item .post-date {
font-size: 12px;
opacity: 0.8;
}
/* Responsive */
@media (max-width: 1200px) {
.grid-container {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.featured-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.featured-post {
height: 300px;
}
.featured-post .post-overlay {
padding: 20px;
}
.featured-post .post-title {
font-size: 22px;
}
.grid-container {
grid-template-columns: repeat(2, 1fr);
}
.blog-container {
padding-top: 53px;
}
}
@media (max-width: 480px) {
.grid-container {
grid-template-columns: 1fr;
}
.featured-section {
padding: 12px 12px 0;
}
.posts-grid {
padding: 12px 12px 80px;
}
}
</style>

View file

@ -0,0 +1,119 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import CategoryNav from '../CategoryNav.astro';
import AlbumHeader from '../AlbumHeader.astro';
import MasonryGallery from '../MasonryGallery.astro';
import Lightbox from '../Lightbox.astro';
import { getPostBaseSlug, type Locale } from '../../../utils/i18n';
interface Props {
post: any;
lang?: Locale;
}
const { post, lang = 'fr' } = Astro.props;
// Importer toutes les images du dossier photos
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/blog/**/*.{jpg,jpeg,png,webp}');
const { Content } = await post.render();
const baseSlug = getPostBaseSlug(post.id);
// Construire le chemin de l'album avec l'année
const year = post.data.date.getFullYear();
const albumPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/`;
const albumImages = Object.keys(allImages)
.filter(path => path.startsWith(albumPath))
.sort();
// Résoudre la cover image depuis le glob
const coverPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/${post.data.coverImage}`;
const coverImageLoader = allImages[coverPath];
const coverImage = coverImageLoader ? (await coverImageLoader()).default : undefined;
// Résoudre les images de la galerie
const galleryImages = await Promise.all(
albumImages.map(async (imagePath) => {
const loader = allImages[imagePath];
const img = await loader();
const filename = imagePath.split('/').pop() || '';
return {
src: img.default,
alt: filename.replace(/\.[^/.]+$/, '').replace(/-/g, ' ').replace(/^\d+-/, ''),
};
})
);
// Données pour la lightbox
const lightboxImages = galleryImages.map(img => ({
src: img.src.src,
alt: img.alt
}));
---
<PhotoLayout title={`${post.data.title} - Blog Photo - Jalil Arfaoui`} enableScroll={true} lang={lang}>
<div class="album-container">
<CategoryNav currentCategory="blog" opaque={false} lang={lang} />
<AlbumHeader
title={post.data.title}
description={post.data.description}
date={new Date(post.data.date)}
tags={post.data.tags}
coverImage={coverImage}
scrollTarget="#album-content"
lang={lang}
/>
<div id="album-content">
{post.body && (
<div class="post-content">
<Content />
</div>
)}
<MasonryGallery images={galleryImages} />
</div>
</div>
<Lightbox images={lightboxImages} albumTitle={post.data.title} lang={lang} />
</PhotoLayout>
<style>
.album-container {
background: #000;
color: #fff;
min-height: 100vh;
padding-top: var(--header-height, 53px);
}
.post-content {
max-width: 800px;
margin: 0 auto;
padding: 0 20px 40px;
line-height: 1.6;
text-align: center;
}
.post-content :global(h1),
.post-content :global(h2),
.post-content :global(h3) {
margin: 2em 0 1em 0;
font-weight: 600;
color: white;
}
.post-content :global(p) {
margin: 1.5em 0;
color: rgba(255, 255, 255, 0.9);
font-size: 16px;
}
.post-content :global(img) {
width: 100%;
height: auto;
border-radius: 8px;
margin: 2em 0;
}
</style>

View file

@ -0,0 +1,21 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import PhotoGallery from '../PhotoGallery.astro';
import HeroViewport from '../HeroViewport.astro';
import ExploreSection from '../ExploreSection.astro';
import { t, type Locale } from '../../../utils/i18n';
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
const title = `${t('photo', 'galleryTitle', lang)} - Jalil Arfaoui`;
---
<PhotoLayout title={title} enableScroll={true} lang={lang}>
<PhotoGallery category="all" lang={lang} />
<HeroViewport targetSelector="#explore-section" transparent />
<ExploreSection lang={lang} />
</PhotoLayout>

View file

@ -85,6 +85,7 @@ const photoCategoriesCollection = defineCollection({
title: z.string(),
subtitle: z.string(),
order: z.number().optional(),
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
}),
});

View file

@ -0,0 +1,6 @@
{
"title": "ثقافات وتقاليد",
"subtitle": "ثراء التقاليد الإنسانية",
"order": 4,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Cultures & Traditions",
"subtitle": "Richness of human traditions",
"order": 4,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "محرّكات",
"subtitle": "ميكانيكا وقوة في حركة",
"order": 7,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Engines",
"subtitle": "Mechanics and power in motion",
"order": 7,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "يوميات",
"subtitle": "لحظات من الحياة اليومية",
"order": 8,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Everyday Life",
"subtitle": "Moments of everyday life",
"order": 8,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "موسيقى واحتفالات",
"subtitle": "نغمات وأغانٍ واهتزازات تحتفي بلحظات حياتنا الكبيرة والصغيرة",
"order": 5,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Music & Celebrations",
"subtitle": "Notes, songs and vibrations celebrating life's big and small moments",
"order": 5,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "طبيعة",
"subtitle": "سحر العالم الطبيعي",
"order": 3,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Nature",
"subtitle": "The magic of the natural world",
"order": 3,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "مناظر طبيعية",
"subtitle": "جمال المناظر والأماكن",
"order": 2,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Landscapes",
"subtitle": "Beauty of landscapes and places",
"order": 2,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "بورتريهات",
"subtitle": "تعابير ومشاعر ملتقطة",
"order": 1,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Portraits",
"subtitle": "Captured expressions and emotions",
"order": 1,
"lang": "en"
}

View file

@ -0,0 +1,6 @@
{
"title": "رياضة",
"subtitle": "حركة وجهد وتجاوز للذات",
"order": 6,
"lang": "ar"
}

View file

@ -0,0 +1,6 @@
{
"title": "Sports",
"subtitle": "Movement, effort and pushing limits",
"order": 6,
"lang": "en"
}

View file

@ -1,11 +1,18 @@
---
import PhotoFooter from '../components/photo/PhotoFooter.astro';
import type { Locale } from '../utils/i18n';
const { title = "Galerie Photo - Jalil Arfaoui", enableScroll = false } = Astro.props;
interface Props {
title?: string;
enableScroll?: boolean;
lang?: Locale;
}
const { title = "Galerie Photo - Jalil Arfaoui", enableScroll = false, lang = 'fr' } = Astro.props;
---
<!doctype html>
<html lang="fr">
<html lang={lang} dir={lang === 'ar' ? 'rtl' : 'ltr'}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -27,7 +34,7 @@ const { title = "Galerie Photo - Jalil Arfaoui", enableScroll = false } = Astro.
<body class={`antialiased bg-black text-white ${enableScroll ? '' : 'overflow-hidden'}`} style="font-family: 'Karla', 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<slot />
<PhotoFooter />
<PhotoFooter lang={lang} />
<Fragment set:html={import.meta.env.FOOTER_INJECT} />
</body>

View file

@ -94,10 +94,10 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
أصوّر كل شيء تقريبًا: وجوه، حفلات، محرّكات، الحياة اليومية. لا تخصّص، مجرّد فضول.
</p>
<div class="space-y-3">
<a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
<a href="/ar/تصوير" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
← معرض الصور
</a>
<a href="/photo/blog" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
<a href="/ar/تصوير/مدونة" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
← مُدوّنة الصور
</a>
</div>

View file

@ -0,0 +1,68 @@
---
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
---
<Layout title="برمجة - جليل عرفاوي">
<section dir="rtl" lang="ar" class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="برمجة"
description="أكثر من 20 سنة في بناء البرمجيات. Craftsmanship، TDD، DDD — وهاجس التحيّزات التي نضعها في الكود دون أن ندري."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">ما أفعله</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
مطوّر مستقل مقيم في <strong class="text-gray-800 dark:text-neutral-200">ألبي، فرنسا</strong>، أرافق الفرق كمطوّر أول، أو قائد تقني، أو مدرب تقني. أفضّل البرمجيات الحرّة والأدوات التي تلبي احتياجات حقيقية.
</p>
<p>
منهجيّتي: <strong class="text-gray-800 dark:text-neutral-200">TDD، الكود النظيف، تصميم المجالات</strong>. الكود مادّة تُصاغ بعناية، لا سلعة تُنتج بالجملة.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">المهارات</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<strong class="text-gray-800 dark:text-neutral-200">اللغات</strong> — TypeScript/JavaScript، PHP، Elixir
</p>
<p>
<strong class="text-gray-800 dark:text-neutral-200">الممارسات</strong> — TDD، الكود النظيف، تصميم المجالات، العمارة السداسية، إعادة الهيكلة المستمرّة
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">مشاريع مفتوحة المصدر</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<Link href="https://debats.co" external>Débats.co</Link> — منصّة تعاونية لتلخيص النقاشات المجتمعية.
</p>
<p>
<Link href="https://github.com/dis-moi" external>DisMoi</Link> — إضافة للمتصفّح في مجال التكنولوجيا المدنية، تضيف معلومات سياقية على الويب.
</p>
<p>
<Link href="https://github.com/betagouv/mon-entreprise" external>mon-entreprise</Link> — المساعد الرسمي لروّاد الأعمال في فرنسا، مشروع <Link href="https://beta.gouv.fr/" external>beta.gouv</Link>.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">المجتمع</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
أنشّط مجتمع <Link href="https://www.meetup.com/software-crafters-albi/" external>Software Crafters Albi</Link> منذ 2018. نلتقي بانتظام للحديث عن الكود والممارسات وحرفة البرمجة.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">التدريس</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
أُدرّس البرمجة في <Link href="https://www.univ-jfc.fr/" external>جامعة شامبوليون</Link> في ألبي.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">المسار</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
حاليًا مطوّر في <Link href="https://www.urssaf.fr/" external>Urssaf</Link>. قبل ذلك، مسار مرّ عبر <Link href="https://while42.org/" external>while42</Link> وشركات ناشئة واستشارات. خرّيج <Link href="https://www.uvsq.fr/" external>UVSQ</Link> (أفضل مشروع تخرّج 2003).
</p>
</div>
</section>
</Layout>

View file

@ -0,0 +1,5 @@
---
import PhotoHomeContent from '../../components/photo/pages/PhotoHomeContent.astro';
---
<PhotoHomeContent lang="ar" />

View file

@ -0,0 +1,19 @@
---
import PhotoAlbumContent from '../../../../components/photo/pages/PhotoAlbumContent.astro';
export async function getStaticPaths() {
const categories = [
'portraits', 'places', 'nature',
'cultures', 'music', 'sports', 'engines', 'everyday'
];
return categories.map(category => ({
params: { category },
props: { category }
}));
}
const { category } = Astro.params;
---
<PhotoAlbumContent category={category} lang="ar" />

View file

@ -0,0 +1,24 @@
---
import PhotoBlogPostContent from '../../../../../components/photo/pages/PhotoBlogPostContent.astro';
import { getCollection } from 'astro:content';
import { getPostBaseSlug } from '../../../../../utils/i18n';
export async function getStaticPaths() {
const allPhotoBlogPosts = await getCollection('photoBlogPosts');
const arPosts = allPhotoBlogPosts.filter(post => post.data.lang === 'ar');
return arPosts.map(post => {
const slug = getPostBaseSlug(post.id);
return {
params: {
year: String(post.data.date.getFullYear()),
slug,
},
props: { post },
};
});
}
const { post } = Astro.props;
---
<PhotoBlogPostContent post={post} lang="ar" />

View file

@ -0,0 +1,5 @@
---
import PhotoBlogIndexContent from '../../../../components/photo/pages/PhotoBlogIndexContent.astro';
---
<PhotoBlogIndexContent lang="ar" />

View file

@ -0,0 +1,49 @@
---
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
---
<Layout title="مسرح - جليل عرفاوي">
<section dir="rtl" lang="ar" class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="مسرح"
description="ممثل ارتجالي منذ 2008، وأخوض اليوم أيضًا غمار المسرح المكتوب."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Touchatou</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
بدأ كل شيء عام 2008 مع <Link href="https://improchatou78.wordpress.com/" external>فرقة Touchatou</Link>، فرقة ارتجال قرب باريس. اكتشاف مباريات الارتجال، التمارين، تلك الطاقة الفريدة حيث يُبنى كل شيء من لا شيء.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">LaTTIFA</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
بين 2013 و2015، في المغرب، انضممت إلى <strong class="text-gray-800 dark:text-neutral-200">LaTTIFA</strong> — فرقة ارتجال مسرحي فرنسية-عربية. التمثيل بلغتين، والتنقّل بين ثقافتين على الخشبة.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Particules</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
في ألبي، شاركت في تأسيس <Link href="https://les-particules.org/" external>Les Particules</Link>، فرقة ارتجال أمثّل معها بانتظام. عروض، مباريات، ورشات.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Lost in Traduction</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
ممثل في <Link href="https://www.e-translation-agency.com/fr/lost-in-traduction-fr/lost-in-traduction-episode-1-the-pilot/" external>Lost in Traduction</Link>، سلسلة ويب من إنتاج <Link href="https://www.e-translation-agency.com/" external>E translation agency</Link> لإزالة الغموض عن قطاع الترجمة المهنية.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">المسرح المكتوب</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
بعد سنوات من الارتجال، أخوض اليوم أيضًا غمار المسرح المكتوب. اتّجاه جديد، نفس الدافع.
</p>
</div>
</section>
</Layout>

68
src/pages/code.astro Normal file
View file

@ -0,0 +1,68 @@
---
import PageHeading from "../components/page-heading.astro";
import Layout from "../layouts/main.astro";
import Link from "../components/Link.astro";
---
<Layout title="Code - Jalil Arfaoui">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Code"
description="Plus de 20 ans à construire du logiciel. Craftsmanship, TDD, DDD — et une obsession pour les biais qu'on met dans le code sans le savoir."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Ce que je fais</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Développeur freelance basé à <strong class="text-gray-800 dark:text-neutral-200">Albi</strong>, j'accompagne les équipes comme développeur senior, tech lead ou coach technique. Je privilégie le logiciel libre et les outils qui répondent à de vrais besoins.
</p>
<p>
Mon approche : <strong class="text-gray-800 dark:text-neutral-200">TDD, Clean Code, Domain-Driven Design</strong>. Le code est un matériau qu'on façonne avec soin, pas une marchandise qu'on produit à la chaîne.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Compétences</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<strong class="text-gray-800 dark:text-neutral-200">Langages</strong> — TypeScript/JavaScript, PHP, Elixir
</p>
<p>
<strong class="text-gray-800 dark:text-neutral-200">Pratiques</strong> — TDD, Clean Code, Domain-Driven Design, architecture hexagonale, refactoring continu
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Projets open source</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<Link href="https://debats.co" external>Débats.co</Link> — Plateforme collaborative de synthèse des débats de société.
</p>
<p>
<Link href="https://github.com/dis-moi" external>DisMoi</Link> — Extension navigateur civic tech, pour ajouter de l'information contextuelle sur le web.
</p>
<p>
<Link href="https://github.com/betagouv/mon-entreprise" external>mon-entreprise</Link> — L'assistant officiel des entrepreneurs, un projet <Link href="https://beta.gouv.fr/" external>beta.gouv</Link>.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Communauté</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
J'anime les <Link href="https://www.meetup.com/software-crafters-albi/" external>Software Crafters d'Albi</Link> depuis 2018. On se retrouve régulièrement pour parler code, pratiques et artisanat logiciel.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Enseignement</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
J'enseigne la programmation à <Link href="https://www.univ-jfc.fr/" external>l'université Champollion</Link> à Albi.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Parcours</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Actuellement développeur pour l'<Link href="https://www.urssaf.fr/" external>Urssaf</Link>. Avant ça, un chemin passé par <Link href="https://while42.org/" external>while42</Link>, des startups et du conseil. Diplômé de l'<Link href="https://www.uvsq.fr/" external>UVSQ</Link> (meilleur projet de promo 2003).
</p>
</div>
</section>
</Layout>

49
src/pages/en/acting.astro Normal file
View file

@ -0,0 +1,49 @@
---
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
---
<Layout title="Acting - Jalil Arfaoui">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Acting"
description="Improv actor since 2008, now also taking on scripted theater."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Touchatou</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
It all started in 2008 with <Link href="https://improchatou78.wordpress.com/" external>les Touchatou</Link>, an improv troupe near Paris. Discovering improv matches, exercises, that unique energy where everything is built from nothing.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">LaTTIFA</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Between 2013 and 2015, in Morocco, I joined <strong class="text-gray-800 dark:text-neutral-200">LaTTIFA</strong> — a French-Arab improv theater troupe. Performing in two languages, navigating two cultures on stage.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Particules</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
In Albi, I co-founded <Link href="https://les-particules.org/" external>Les Particules</Link>, an improv troupe I perform with regularly. Shows, matches, workshops.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Lost in Traduction</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Actor in <Link href="https://www.e-translation-agency.com/fr/lost-in-traduction-fr/lost-in-traduction-episode-1-the-pilot/" external>Lost in Traduction</Link>, a web series produced by <Link href="https://www.e-translation-agency.com/" external>E translation agency</Link> to demystify the professional translation industry.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Scripted theater</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
After years of improvisation, I'm now also taking on scripted theater. New direction, same drive.
</p>
</div>
</section>
</Layout>

68
src/pages/en/code.astro Normal file
View file

@ -0,0 +1,68 @@
---
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
---
<Layout title="Code - Jalil Arfaoui">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Code"
description="Over 20 years building software. Craftsmanship, TDD, DDD — and an obsession with the biases we unknowingly put into code."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">What I do</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Freelance developer based in <strong class="text-gray-800 dark:text-neutral-200">Albi, France</strong>, I work with teams as a senior developer, tech lead or technical coach. I favor free software and tools that address real needs.
</p>
<p>
My approach: <strong class="text-gray-800 dark:text-neutral-200">TDD, Clean Code, Domain-Driven Design</strong>. Code is a material to be crafted with care, not a commodity to be mass-produced.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Skills</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<strong class="text-gray-800 dark:text-neutral-200">Languages</strong> — TypeScript/JavaScript, PHP, Elixir
</p>
<p>
<strong class="text-gray-800 dark:text-neutral-200">Practices</strong> — TDD, Clean Code, Domain-Driven Design, hexagonal architecture, continuous refactoring
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Open source projects</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
<Link href="https://debats.co" external>Débats.co</Link> — A collaborative platform for synthesizing public debates.
</p>
<p>
<Link href="https://github.com/dis-moi" external>DisMoi</Link> — A civic tech browser extension that adds contextual information to the web.
</p>
<p>
<Link href="https://github.com/betagouv/mon-entreprise" external>mon-entreprise</Link> — The official assistant for entrepreneurs in France, a <Link href="https://beta.gouv.fr/" external>beta.gouv</Link> project.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Community</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
I've been running the <Link href="https://www.meetup.com/software-crafters-albi/" external>Software Crafters Albi</Link> meetup since 2018. We gather regularly to discuss code, practices and software craftsmanship.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Teaching</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
I teach programming at <Link href="https://www.univ-jfc.fr/" external>Université Champollion</Link> in Albi.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Background</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Currently a developer at <Link href="https://www.urssaf.fr/" external>Urssaf</Link>. Before that, a path through <Link href="https://while42.org/" external>while42</Link>, startups and consulting. Graduated from <Link href="https://www.uvsq.fr/" external>UVSQ</Link> (best graduation project 2003).
</p>
</div>
</section>
</Layout>

View file

@ -94,10 +94,10 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
I photograph a bit of everything: faces, concerts, engines, everyday life. No specialty, just curiosity.
</p>
<div class="space-y-3">
<a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
<a href="/en/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
→ Photo portfolio
</a>
<a href="/photo/blog" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
<a href="/en/photo/blog" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
→ Photo Feed
</a>
</div>

5
src/pages/en/photo.astro Normal file
View file

@ -0,0 +1,5 @@
---
import PhotoHomeContent from '../../components/photo/pages/PhotoHomeContent.astro';
---
<PhotoHomeContent lang="en" />

View file

@ -0,0 +1,19 @@
---
import PhotoAlbumContent from '../../../../components/photo/pages/PhotoAlbumContent.astro';
export async function getStaticPaths() {
const categories = [
'portraits', 'places', 'nature',
'cultures', 'music', 'sports', 'engines', 'everyday'
];
return categories.map(category => ({
params: { category },
props: { category }
}));
}
const { category } = Astro.params;
---
<PhotoAlbumContent category={category} lang="en" />

View file

@ -0,0 +1,24 @@
---
import PhotoBlogPostContent from '../../../../../components/photo/pages/PhotoBlogPostContent.astro';
import { getCollection } from 'astro:content';
import { getPostBaseSlug } from '../../../../../utils/i18n';
export async function getStaticPaths() {
const allPhotoBlogPosts = await getCollection('photoBlogPosts');
const enPosts = allPhotoBlogPosts.filter(post => post.data.lang === 'en');
return enPosts.map(post => {
const slug = getPostBaseSlug(post.id);
return {
params: {
year: String(post.data.date.getFullYear()),
slug,
},
props: { post },
};
});
}
const { post } = Astro.props;
---
<PhotoBlogPostContent post={post} lang="en" />

View file

@ -0,0 +1,5 @@
---
import PhotoBlogIndexContent from '../../../../components/photo/pages/PhotoBlogIndexContent.astro';
---
<PhotoBlogIndexContent lang="en" />

View file

@ -1,14 +1,5 @@
---
import PhotoLayout from '../layouts/PhotoLayout.astro';
import PhotoGallery from '../components/photo/PhotoGallery.astro';
import HeroViewport from '../components/photo/HeroViewport.astro';
import ExploreSection from '../components/photo/ExploreSection.astro';
const title = "Galerie Photo - Jalil Arfaoui";
import PhotoHomeContent from '../components/photo/pages/PhotoHomeContent.astro';
---
<PhotoLayout title={title} enableScroll={true}>
<PhotoGallery category="all" />
<HeroViewport targetSelector="#explore-section" transparent />
<ExploreSection />
</PhotoLayout>
<PhotoHomeContent lang="fr" />

View file

@ -1,36 +0,0 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import CategoryGrid from '../../../components/photo/CategoryGrid.astro';
export async function getStaticPaths() {
const categories = [
'blog', 'portraits', 'places', 'nature',
'cultures', 'music', 'sports', 'engines', 'everyday'
];
return categories.map(category => ({
params: { category },
props: { category }
}));
}
const { category } = Astro.params;
const categoryLabels: Record<string, string> = {
'blog': 'Blog',
'portraits': 'Portraits',
'places': 'Paysages',
'nature': 'Nature',
'cultures': 'Cultures',
'music': 'Musique',
'sports': 'Sports',
'engines': 'Moteurs',
'everyday': 'Quotidien'
};
const title = `Galerie ${category ? categoryLabels[category] || category : ''} - Jalil Arfaoui`;
---
<PhotoLayout title={title} enableScroll={true}>
<CategoryGrid category={category} />
</PhotoLayout>

View file

@ -0,0 +1,19 @@
---
import PhotoAlbumContent from '../../../components/photo/pages/PhotoAlbumContent.astro';
export async function getStaticPaths() {
const categories = [
'portraits', 'places', 'nature',
'cultures', 'music', 'sports', 'engines', 'everyday'
];
return categories.map(category => ({
params: { category },
props: { category }
}));
}
const { category } = Astro.params;
---
<PhotoAlbumContent category={category} lang="fr" />

View file

@ -1,19 +1,14 @@
---
import PhotoLayout from '../../../../layouts/PhotoLayout.astro';
import CategoryNav from '../../../../components/photo/CategoryNav.astro';
import AlbumHeader from '../../../../components/photo/AlbumHeader.astro';
import MasonryGallery from '../../../../components/photo/MasonryGallery.astro';
import Lightbox from '../../../../components/photo/Lightbox.astro';
import PhotoBlogPostContent from '../../../../components/photo/pages/PhotoBlogPostContent.astro';
import { getCollection } from 'astro:content';
// Importer toutes les images du dossier photos
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/blog/**/*.{jpg,jpeg,png,webp}');
import { getPostBaseSlug } from '../../../../utils/i18n';
export async function getStaticPaths() {
const allPhotoBlogPosts = await getCollection('photoBlogPosts');
return allPhotoBlogPosts.map(post => {
// Le slug Astro inclut le préfixe d'année (ex: "2015/enigma.en")
const slug = post.slug.replace(/^\d{4}\//, '');
// Uniquement les posts FR (lang absent ou 'fr')
const frPosts = allPhotoBlogPosts.filter(post => (post.data.lang ?? 'fr') === 'fr');
return frPosts.map(post => {
const slug = getPostBaseSlug(post.id);
return {
params: {
year: String(post.data.date.getFullYear()),
@ -25,104 +20,6 @@ export async function getStaticPaths() {
}
const { post } = Astro.props;
const { Content } = await post.render();
// Slug de base sans préfixe d'année ni suffixe de langue (2015/enigma.en → enigma)
const baseSlug = post.slug.replace(/^\d{4}\//, '').replace(/\.(en|ar)$/, '');
// Construire le chemin de l'album avec l'année
const year = post.data.date.getFullYear();
const albumPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/`;
const albumImages = Object.keys(allImages)
.filter(path => path.startsWith(albumPath))
.sort();
// Résoudre la cover image depuis le glob
const coverPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/${post.data.coverImage}`;
const coverImageLoader = allImages[coverPath];
const coverImage = coverImageLoader ? (await coverImageLoader()).default : undefined;
// Résoudre les images de la galerie
const galleryImages = await Promise.all(
albumImages.map(async (imagePath) => {
const loader = allImages[imagePath];
const img = await loader();
const filename = imagePath.split('/').pop() || '';
return {
src: img.default,
alt: filename.replace(/\.[^/.]+$/, '').replace(/-/g, ' ').replace(/^\d+-/, ''),
};
})
);
// Données pour la lightbox
const lightboxImages = galleryImages.map(img => ({
src: img.src.src,
alt: img.alt
}));
---
<PhotoLayout title={`${post.data.title} - Blog Photo - Jalil Arfaoui`} enableScroll={true} hideFooter={false}>
<div class="album-container">
<CategoryNav currentCategory="blog" opaque={false} />
<AlbumHeader
title={post.data.title}
description={post.data.description}
date={new Date(post.data.date)}
tags={post.data.tags}
coverImage={coverImage}
scrollTarget="#album-content"
/>
<div id="album-content">
{post.body && (
<div class="post-content">
<Content />
</div>
)}
<MasonryGallery images={galleryImages} />
</div>
</div>
<Lightbox images={lightboxImages} albumTitle={post.data.title} />
</PhotoLayout>
<style>
.album-container {
background: #000;
color: #fff;
min-height: 100vh;
padding-top: var(--header-height, 53px);
}
.post-content {
max-width: 800px;
margin: 0 auto;
padding: 0 20px 40px;
line-height: 1.6;
text-align: center;
}
.post-content :global(h1),
.post-content :global(h2),
.post-content :global(h3) {
margin: 2em 0 1em 0;
font-weight: 600;
color: white;
}
.post-content :global(p) {
margin: 1.5em 0;
color: rgba(255, 255, 255, 0.9);
font-size: 16px;
}
.post-content :global(img) {
width: 100%;
height: auto;
border-radius: 8px;
margin: 2em 0;
}
</style>
<PhotoBlogPostContent post={post} lang="fr" />

View file

@ -1,326 +1,5 @@
---
import PhotoLayout from '../../../layouts/PhotoLayout.astro';
import CategoryNav from '../../../components/photo/CategoryNav.astro';
import { getCollection } from 'astro:content';
import { Picture } from 'astro:assets';
// Importer toutes les images pour résoudre les cover images
const allImages = import.meta.glob<{ default: ImageMetadata }>(
'/src/assets/images/photos/blog/**/*.{jpg,jpeg,png,webp}'
);
// Récupération des posts photo (langue par défaut : FR)
const allPhotoBlogPosts = (await getCollection('photoBlogPosts'))
.filter(post => (post.data.lang ?? 'fr') === 'fr');
// Tri par date (plus récent en premier)
const sortedPosts = allPhotoBlogPosts.sort((a, b) =>
new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
);
// Résoudre les cover images via le glob
const postsWithImages = await Promise.all(sortedPosts.map(async (post) => {
const year = post.data.date.getFullYear();
const baseSlug = post.slug.replace(/^\d{4}\//, '').replace(/\.(en|ar)$/, '');
const coverPath = `/src/assets/images/photos/blog/${year}/${baseSlug}/${post.data.coverImage}`;
const loader = allImages[coverPath];
const resolvedCoverImage = loader ? (await loader()).default : undefined;
return { ...post, resolvedCoverImage };
}));
// Séparer les posts à la une des autres
const featuredPosts = postsWithImages.filter(post => post.data.featured);
const regularPosts = postsWithImages.filter(post => !post.data.featured);
import PhotoBlogIndexContent from '../../../components/photo/pages/PhotoBlogIndexContent.astro';
---
<PhotoLayout title="Blog Photo - Jalil Arfaoui" enableScroll={true} hideFooter={false}>
<div class="blog-container">
<CategoryNav currentCategory="blog" opaque={false} />
<!-- Section héro avec posts à la une -->
{featuredPosts.length > 0 && (
<section class="featured-section">
<div class="featured-grid">
{featuredPosts.map(post => (
<article class="featured-post">
<a href={`/photo/blog/${post.slug}`} class="post-link">
<div class="post-image">
{post.resolvedCoverImage && <Picture src={post.resolvedCoverImage} alt={post.data.title} widths={[600, 900, 1200]} formats={['webp']} />}
<div class="post-overlay">
<div class="post-content">
<span class="post-badge">À la une</span>
<h2 class="post-title">{post.data.title}</h2>
<time class="post-date">
{new Date(post.data.date).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
</div>
</div>
</a>
</article>
))}
</div>
</section>
)}
<!-- Grille des autres posts -->
<section class="posts-grid">
<div class="grid-container">
{regularPosts.map(post => (
<article class="post-item">
<a href={`/photo/blog/${post.slug}`} class="post-link">
{post.resolvedCoverImage && <Picture src={post.resolvedCoverImage} alt={post.data.title} widths={[400, 600, 800]} formats={['webp']} />}
<div class="post-overlay">
<div class="overlay-content">
<h3 class="post-title">{post.data.title}</h3>
<time class="post-date">
{new Date(post.data.date).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short'
})}
</time>
</div>
</div>
</a>
</article>
))}
</div>
</section>
</div>
</PhotoLayout>
<style>
.blog-container {
background: #000000;
color: #ffffff;
min-height: 100vh;
padding-top: 53px; /* Hauteur du header fixe */
}
/* Section à la une */
.featured-section {
padding: 16px 16px 0;
}
.featured-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 16px;
}
.featured-post {
position: relative;
height: 450px;
overflow: hidden;
border-radius: 8px;
}
.featured-post .post-image {
width: 100%;
height: 100%;
position: relative;
}
.featured-post img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.featured-post:hover img {
transform: scale(1.05);
}
.featured-post .post-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.7) 100%);
display: flex;
align-items: flex-end;
padding: 40px;
}
.featured-post .post-content {
color: white;
}
.post-badge {
background: rgba(255, 255, 255, 0.2);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
display: inline-block;
}
.featured-post .post-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 4px 0;
line-height: 1.2;
}
.featured-post .post-description {
font-size: 15px;
margin: 0 0 10px 0;
opacity: 0.9;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.featured-post .post-date {
display: block;
font-size: 14px;
opacity: 0.8;
}
/* Grille des posts */
.posts-grid {
padding: 16px 16px 100px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.post-item {
overflow: hidden;
border-radius: 4px;
background: #111;
}
.post-link {
display: block;
position: relative;
aspect-ratio: 3/2;
}
.featured-post .post-link {
aspect-ratio: auto;
height: 100%;
}
.post-link img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.post-item .post-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.5) 25%, rgba(0,0,0,0) 50%);
display: flex;
align-items: flex-end;
padding: 12px 16px;
transition: background 0.3s ease;
}
.post-item:hover .post-overlay {
background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 25%, rgba(0,0,0,0) 50%);
}
.post-item:hover img {
transform: scale(1.02);
}
.post-item .overlay-content {
color: white;
}
.post-item .post-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 2px 0;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
.post-item .post-subtitle {
font-size: 14px;
margin: 0 0 6px 0;
opacity: 0.9;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.post-item .post-date {
font-size: 12px;
opacity: 0.8;
}
/* Responsive */
@media (max-width: 1200px) {
.grid-container {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.featured-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.featured-post {
height: 300px;
}
.featured-post .post-overlay {
padding: 20px;
}
.featured-post .post-title {
font-size: 22px;
}
.grid-container {
grid-template-columns: repeat(2, 1fr);
}
.blog-container {
padding-top: 53px;
}
}
@media (max-width: 480px) {
.grid-container {
grid-template-columns: 1fr;
}
.featured-section {
padding: 12px 12px 0;
}
.posts-grid {
padding: 12px 12px 80px;
}
}
</style>
<PhotoBlogIndexContent lang="fr" />

49
src/pages/theatre.astro Normal file
View file

@ -0,0 +1,49 @@
---
import PageHeading from "../components/page-heading.astro";
import Layout from "../layouts/main.astro";
import Link from "../components/Link.astro";
---
<Layout title="Théâtre - Jalil Arfaoui">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Théâtre"
description="Improvisateur depuis 2008, comédien de théâtre écrit aujourd'hui aussi."
/>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Touchatou</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Tout a commencé en 2008 avec <Link href="https://improchatou78.wordpress.com/" external>les Touchatou</Link>, une troupe d'improvisation dans les Yvelines. Découverte du match d'impro, des exercices, de cette énergie particulière où tout se construit à partir de rien.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">LaTTIFA</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Entre 2013 et 2015, au Maroc, j'ai rejoint la <strong class="text-gray-800 dark:text-neutral-200">LaTTIFA</strong> — une troupe franco-arabe d'improvisation théâtrale. Jouer dans deux langues, naviguer entre deux cultures sur scène.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Les Particules</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
À Albi, j'ai cofondé <Link href="https://les-particules.org/" external>Les Particules</Link>, une troupe d'improvisation avec qui je joue régulièrement. Des spectacles, des matchs, des ateliers.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Lost in Traduction</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Acteur dans <Link href="https://www.e-translation-agency.com/fr/lost-in-traduction-fr/lost-in-traduction-episode-1-the-pilot/" external>Lost in Traduction</Link>, une web série produite par <Link href="https://www.e-translation-agency.com/" external>E translation agency</Link> pour démystifier le secteur de la traduction professionnelle.
</p>
</div>
<h2 class="mt-10 mb-4 text-2xl font-bold dark:text-neutral-200">Théâtre écrit</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">
<p>
Après des années d'improvisation, je m'attaque aujourd'hui au théâtre écrit. Nouvelle direction, même élan.
</p>
</div>
</section>
</Layout>

View file

@ -47,6 +47,7 @@ export const translations = {
uses: { fr: 'Utilise', en: 'Uses', ar: 'يستخدم' },
},
common: {
siteName: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
readMore: { fr: 'Lire la suite', en: 'Read more', ar: 'اقرأ المزيد' },
backToHome: { fr: 'Retour à l\'accueil', en: 'Back to home', ar: 'العودة إلى الرئيسية' },
publishedOn: { fr: 'Publié le', en: 'Published on', ar: 'نشر في' },
@ -65,6 +66,23 @@ export const translations = {
photo: { fr: 'Photographie', en: 'Photography', ar: 'تصوير' },
dev: { fr: 'Développement', en: 'Development', ar: 'تطوير' },
},
photo: {
galleryTitle: { fr: 'Galerie Photo', en: 'Photo Gallery', ar: 'معرض الصور' },
blogTitle: { fr: 'Blog Photo', en: 'Photo Blog', ar: 'مدونة الصور' },
photoFeed: { fr: 'Fil Photo', en: 'Photo Feed', ar: 'سلسلة الصور' },
categories: { fr: 'Catégories', en: 'Categories', ar: 'الفئات' },
browseByTheme: { fr: 'Parcourir les photos par thème', en: 'Browse photos by theme', ar: 'تصفّح الصور حسب الموضوع' },
feedDescription: { fr: 'Parcourir les séries chronologiques, reportages et histoires en images', en: 'Browse chronological series, reports and stories in images', ar: 'تصفّح السلاسل الزمنية والتقارير والقصص المصوّرة' },
viewFeed: { fr: 'Voir le fil', en: 'View feed', ar: 'عرض السلسلة' },
featured: { fr: 'À la une', en: 'Featured', ar: 'مميّز' },
about: { fr: 'À propos', en: 'About', ar: 'نبذة' },
contact: { fr: 'Contact', en: 'Contact', ar: 'تواصل' },
fullscreen: { fr: 'Plein écran', en: 'Fullscreen', ar: 'شاشة كاملة' },
close: { fr: 'Fermer', en: 'Close', ar: 'إغلاق' },
previousImage: { fr: 'Image précédente', en: 'Previous image', ar: 'الصورة السابقة' },
nextImage: { fr: 'Image suivante', en: 'Next image', ar: 'الصورة التالية' },
goToImage: { fr: "Aller à l'image", en: 'Go to image', ar: 'انتقل إلى الصورة' },
},
pages: {
home: {
title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
@ -105,3 +123,54 @@ export const translations = {
}
}
};
/** Helper de traduction typé */
export function t(section: keyof typeof translations, key: string, locale: Locale): string {
const sectionData = translations[section] as Record<string, LocalizedContent>;
return sectionData?.[key]?.[locale] ?? sectionData?.[key]?.fr ?? key;
}
/** Chemin de base photo selon la langue */
export function getPhotoBasePath(locale: Locale): string {
if (locale === 'ar') return '/ar/تصوير';
if (locale === 'en') return '/en/photo';
return '/photo';
}
/** Chemin du blog photo selon la langue */
export function getPhotoBlogPath(locale: Locale): string {
return `${getPhotoBasePath(locale)}/${locale === 'ar' ? 'مدونة' : 'blog'}`;
}
/** Chemin des albums photo selon la langue */
export function getPhotoAlbumsPath(locale: Locale): string {
return `${getPhotoBasePath(locale)}/${locale === 'ar' ? 'ألبومات' : 'albums'}`;
}
/** Chemin "À propos" selon la langue */
export function getAboutPath(locale: Locale): string {
if (locale === 'en') return '/en/about';
if (locale === 'ar') return '/ar/نبذة-عني';
return '/a-propos';
}
/** Locale pour les dates */
export function getDateLocale(locale: Locale): string {
const dateLocales: Record<Locale, string> = { fr: 'fr-FR', en: 'en-US', ar: 'ar-SA' };
return dateLocales[locale];
}
/** ID de catégorie localisé (portraits → portraits.en pour EN) */
export function getCategoryEntryId(categoryId: string, locale: Locale): string {
return locale === 'fr' ? categoryId : `${categoryId}.${locale}`;
}
/** Chemin d'accueil selon la langue */
export function getHomePath(locale: Locale): string {
return locale === 'fr' ? '/' : `/${locale}`;
}
/** Slug de base d'un photo blog post depuis son id (ex: "2015/enigma.en.md" → "enigma") */
export function getPostBaseSlug(postId: string): string {
return postId.replace(/^\d{4}\//, '').replace(/\.(en|ar)?\.mdx?$/, '');
}