Compare commits

...

7 commits

Author SHA1 Message Date
73d4d2fa06 Ajout de .claude/, .direnv/ et .clever.json au .gitignore 2026-02-18 16:13:08 +01:00
d8f71a3040 Mise à jour du chemin WebDAV dans .env.example 2026-02-18 16:13:08 +01:00
220a29957a Suppression des images inutilisées dans public/assets/images 2026-02-18 16:13:08 +01:00
5ff1b23545 Ajout du nettoyage des fichiers orphelins dans fetch-images 2026-02-18 16:13:08 +01:00
b5964cdc78 Ajout du sélecteur de langue dans le footer photo 2026-02-18 16:13:08 +01:00
3d23e84b34 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/تصوير/ألبومات).
2026-02-18 16:12:53 +01:00
4c6f57cd6a Utilisation du composant Image d'Astro pour les photos de profil
Déplacement de jalil.jpg et jalil-2.jpg de public/ vers src/assets/images/ pour permettre l'optimisation automatique (webp, redimensionnement). Mise à jour des 6 pages (index et à propos en FR, EN, AR) pour utiliser <Image> au lieu de <img>.
2026-02-18 11:22:05 +01:00
74 changed files with 1518 additions and 603 deletions

View file

@ -1,4 +1,4 @@
WEBDAV_URL=https://nas.arfaoui.net:6006
WEBDAV_PATH=/photo/Portfolio
WEBDAV_USER=your_username
WEBDAV_PASS=your_password
WEBDAV_PATH=/jalil.arfaoui.net/photos
WEBDAV_USER=CleverCloud
WEBDAV_PASS=pwd

9
.gitignore vendored
View file

@ -28,3 +28,12 @@ src/assets/images/photos/
# jetbrains setting folder
.idea/
# claude code
.claude/
# direnv cache
.direnv/
# clever cloud
.clever.json

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View file

@ -1,7 +1,7 @@
import "dotenv/config";
import { createClient } from "webdav";
import { mkdir, writeFile, stat } from "fs/promises";
import { join, dirname } from "path";
import { mkdir, writeFile, stat, readdir, rm } from "fs/promises";
import { join, dirname, relative } from "path";
interface FileStat {
filename: string;
@ -30,13 +30,25 @@ async function main() {
console.log(`Fetching images from ${WEBDAV_URL}${WEBDAV_PATH}...`);
await syncDirectory(client, WEBDAV_PATH, DEST_DIR);
// Collecter tous les fichiers/dossiers distants
const remoteItems = new Set<string>();
await syncDirectory(client, WEBDAV_PATH, DEST_DIR, remoteItems);
// Supprimer les fichiers locaux qui n'existent plus sur le NAS
await cleanupLocalFiles(DEST_DIR, remoteItems);
console.log("Done!");
}
async function syncDirectory(client: ReturnType<typeof createClient>, remotePath: string, localPath: string) {
async function syncDirectory(
client: ReturnType<typeof createClient>,
remotePath: string,
localPath: string,
remoteItems: Set<string>
) {
await mkdir(localPath, { recursive: true });
remoteItems.add(localPath);
const items = (await client.getDirectoryContents(remotePath)) as FileStat[];
@ -46,8 +58,9 @@ async function syncDirectory(client: ReturnType<typeof createClient>, remotePath
if (item.type === "directory") {
console.log(` [dir] ${item.basename}/`);
await syncDirectory(client, remoteItemPath, localItemPath);
await syncDirectory(client, remoteItemPath, localItemPath, remoteItems);
} else if (item.type === "file" && /\.(jpg|jpeg|png|webp)$/i.test(item.basename)) {
remoteItems.add(localItemPath);
const needsDownload = await shouldDownload(localItemPath, item);
if (needsDownload) {
@ -81,6 +94,72 @@ async function shouldDownload(localPath: string, remoteItem: FileStat): Promise<
}
}
async function cleanupLocalFiles(localDir: string, remoteItems: Set<string>) {
const localFiles = await collectLocalFiles(localDir);
let deletedCount = 0;
for (const localFile of localFiles) {
if (!remoteItems.has(localFile)) {
const relativePath = relative(localDir, localFile);
console.log(` [delete] ${relativePath}`);
await rm(localFile, { recursive: true, force: true });
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`Deleted ${deletedCount} orphaned file(s)/folder(s)`);
// Nettoyer les dossiers vides
await cleanupEmptyDirs(localDir);
}
}
async function collectLocalFiles(dir: string): Promise<string[]> {
const files: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(fullPath);
const subFiles = await collectLocalFiles(fullPath);
files.push(...subFiles);
} else if (/\.(jpg|jpeg|png|webp)$/i.test(entry.name)) {
files.push(fullPath);
}
}
} catch {
// Dossier n'existe pas encore
}
return files;
}
async function cleanupEmptyDirs(dir: string) {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subDir = join(dir, entry.name);
await cleanupEmptyDirs(subDir);
// Vérifier si le dossier est vide après nettoyage récursif
const subEntries = await readdir(subDir);
if (subEntries.length === 0) {
console.log(` [delete] ${relative(DEST_DIR, subDir)}/ (empty)`);
await rm(subDir, { recursive: true });
}
}
}
} catch {
// Ignore errors
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);

View file

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View file

Before

Width:  |  Height:  |  Size: 862 KiB

After

Width:  |  Height:  |  Size: 862 KiB

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,24 @@
---
import PhotoLanguageSwitcher from './PhotoLanguageSwitcher.astro';
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>
<PhotoLanguageSwitcher lang={lang} />
<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>
@ -36,6 +48,12 @@
.photo-footer-links {
display: flex;
gap: 15px;
flex: 1;
}
.photo-footer-copy {
flex: 1;
text-align: end;
}
.photo-footer-links a,

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

@ -0,0 +1,55 @@
---
import { getPhotoBasePath, type Locale } from '../../utils/i18n';
interface Props {
lang?: Locale;
}
const { lang = 'fr' } = Astro.props;
const languages = [
{ code: 'fr' as const, label: 'FR', name: 'Français' },
{ code: 'en' as const, label: 'EN', name: 'English' },
{ code: 'ar' as const, label: 'ع', name: 'العربية' },
];
---
<div class="photo-lang-switcher">
{languages.map((l, i) => (
<>
{i > 0 && <span class="lang-sep">·</span>}
{l.code === lang ? (
<span class="lang-active" title={l.name}>{l.label}</span>
) : (
<a href={getPhotoBasePath(l.code)} class="lang-link" title={l.name} hreflang={l.code}>{l.label}</a>
)}
</>
))}
</div>
<style>
.photo-lang-switcher {
display: flex;
align-items: center;
gap: 6px;
}
.lang-sep {
color: rgba(255, 255, 255, 0.3);
}
.lang-active {
color: white;
font-weight: 600;
}
.lang-link {
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
transition: color 0.2s;
}
.lang-link:hover {
color: white;
}
</style>

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

@ -1,7 +1,9 @@
---
import { Image } from "astro:assets";
import PageHeading from "../components/page-heading.astro";
import Layout from "../layouts/main.astro";
import Link from "../components/Link.astro";
import jalilPhoto from "../assets/images/jalil.jpg";
---
<Layout title="À propos - Jalil Arfaoui">
@ -11,7 +13,7 @@ import Link from "../components/Link.astro";
description="Développeur artisan, comédien improvisateur, photographe curieux."
/>
<img src="/assets/images/jalil.jpg" class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
<Image src={jalilPhoto} class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">Qui suis-je ?</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">

View file

@ -1,5 +1,7 @@
---
import { Image } from "astro:assets";
import Layout from "../../layouts/main.astro";
import jalilPhoto from "../../assets/images/jalil-2.jpg";
---
<Layout title="جليل عرفاوي - حِرَفي برمجة • ممثل ارتجالي • مصوّر">
@ -18,10 +20,9 @@ import Layout from "../../layouts/main.astro";
</p>
<div class="flex flex-wrap gap-4">
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="قيد الإنشاء">
<a href="/ar/برمجة" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-white bg-blue-600 rounded-full hover:bg-blue-700 transition-colors duration-200">
اكتشف أعمالي
<span class="mr-2">🚧</span>
</span>
</a>
<a href="/ar/نبذة-عني" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
تعرّف عليّ أكثر
</a>
@ -32,8 +33,8 @@ import Layout from "../../layouts/main.astro";
<div class="relative z-50 w-full max-w-sm mx-auto">
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
<img
src="/assets/images/jalil-2.jpg"
<Image
src={jalilPhoto}
alt="جليل عرفاوي"
loading="eager"
decoding="auto"
@ -59,15 +60,9 @@ import Layout from "../../layouts/main.astro";
أكثر من 20 سنة في بناء البرمجيات. اليوم، أختار مشاريعي: برمجيات حرّة، أدوات مفيدة، لا شيء يُقيّد. Craftsmanship، DDD، TypeScript — وهاجس التحيّزات التي نضعها في الكود دون أن ندري. أُدرّس البرمجة وأنشّط مجتمع Software Crafters في ألبي.
</p>
<div class="space-y-3">
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
← المسار المهني 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
← مشاريعي 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="قيد الإنشاء">
← المدوّنة التقنية 🚧
</span>
<a href="/ar/برمجة" class="block text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium">
← اكتشف المزيد
</a>
</div>
</div>
</div>
@ -82,9 +77,9 @@ import Layout from "../../layouts/main.astro";
ممثل ارتجالي منذ 2008، من ضواحي باريس إلى المغرب قبل تأسيس فرقة Les Particules في ألبي. اليوم أخوض أيضًا غمار المسرح المكتوب.
</p>
<div class="space-y-3">
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="قيد الإنشاء">
← المسار الفني 🚧
</span>
<a href="/ar/مسرح" class="block text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-200 font-medium">
← المسار الفني
</a>
</div>
</div>
</div>
@ -99,10 +94,10 @@ import Layout from "../../layouts/main.astro";
أصوّر كل شيء تقريبًا: وجوه، حفلات، محرّكات، الحياة اليومية. لا تخصّص، مجرّد فضول.
</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>

View file

@ -1,7 +1,9 @@
---
import { Image } from "astro:assets";
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
import jalilPhoto from "../../assets/images/jalil.jpg";
---
<Layout title="نبذة عني - جليل عرفاوي">
@ -11,7 +13,7 @@ import Link from "../../components/Link.astro";
description="حِرَفي برمجة، ممثل ارتجالي، مصوّر فضولي."
/>
<img src="/assets/images/jalil.jpg" class="relative z-30 w-full my-10 rounded-xl" alt="جليل عرفاوي" />
<Image src={jalilPhoto} class="relative z-30 w-full my-10 rounded-xl" alt="جليل عرفاوي" />
<h2 class="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">

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>

View file

@ -1,7 +1,9 @@
---
import { Image } from "astro:assets";
import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro";
import jalilPhoto from "../../assets/images/jalil.jpg";
---
<Layout title="About - Jalil Arfaoui">
@ -11,7 +13,7 @@ import Link from "../../components/Link.astro";
description="Software craftsman, improv actor, curious photographer."
/>
<img src="/assets/images/jalil.jpg" class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
<Image src={jalilPhoto} class="relative z-30 w-full my-10 rounded-xl" alt="Jalil Arfaoui" />
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">Who am I?</h2>
<div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">

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

@ -1,5 +1,7 @@
---
import { Image } from "astro:assets";
import Layout from "../../layouts/main.astro";
import jalilPhoto from "../../assets/images/jalil-2.jpg";
---
<Layout title="Jalil Arfaoui - Software Craftsman • Improv Actor • Photographer">
@ -18,10 +20,9 @@ import Layout from "../../layouts/main.astro";
</p>
<div class="flex flex-wrap gap-4">
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="Under construction">
<a href="/en/code" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-white bg-blue-600 rounded-full hover:bg-blue-700 transition-colors duration-200">
View my work
<span class="ml-2">🚧</span>
</span>
</a>
<a href="/en/about" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
Learn more
</a>
@ -32,8 +33,8 @@ import Layout from "../../layouts/main.astro";
<div class="relative z-50 w-full max-w-sm mx-auto">
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
<img
src="/assets/images/jalil-2.jpg"
<Image
src={jalilPhoto}
alt="Jalil Arfaoui"
loading="eager"
decoding="auto"
@ -59,15 +60,9 @@ import Layout from "../../layouts/main.astro";
Over 20 years building software. Today, I choose my projects: free software, useful tools, nothing that alienates. Craftsmanship, DDD, TypeScript — and an obsession with the biases we unknowingly put into code. I teach programming and run the Software Crafters meetup in Albi.
</p>
<div class="space-y-3">
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
→ Professional journey 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
→ My projects 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
→ Technical blog 🚧
</span>
<a href="/en/code" class="block text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium">
→ Learn more
</a>
</div>
</div>
</div>
@ -82,9 +77,9 @@ import Layout from "../../layouts/main.astro";
Improv actor since 2008, from the Paris suburbs to Morocco before co-founding Les Particules in Albi. Now also taking on scripted theater.
</p>
<div class="space-y-3">
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="Under construction">
→ Artistic journey 🚧
</span>
<a href="/en/acting" class="block text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-200 font-medium">
→ Artistic journey
</a>
</div>
</div>
</div>
@ -99,10 +94,10 @@ import Layout from "../../layouts/main.astro";
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,5 +1,7 @@
---
import { Image } from "astro:assets";
import Layout from "../layouts/main.astro";
import jalilPhoto from "../assets/images/jalil-2.jpg";
---
<Layout title="Jalil Arfaoui - Développeur artisan • Comédien improvisateur • Photographe">
@ -18,10 +20,9 @@ import Layout from "../layouts/main.astro";
</p>
<div class="flex flex-wrap gap-4">
<span class="wip-link inline-flex items-center px-6 py-3 text-sm font-semibold text-white/60 bg-blue-600/50 rounded-full cursor-not-allowed" title="En construction">
<a href="/code" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-white bg-blue-600 rounded-full hover:bg-blue-700 transition-colors duration-200">
Voir mon travail
<span class="ml-2">🚧</span>
</span>
</a>
<a href="/a-propos" class="inline-flex items-center px-6 py-3 text-sm font-semibold text-neutral-700 bg-neutral-100 dark:bg-neutral-800 dark:text-neutral-200 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200">
En savoir plus
</a>
@ -32,8 +33,8 @@ import Layout from "../layouts/main.astro";
<div class="relative z-50 w-full max-w-sm mx-auto">
<div class="relative z-30 p-1 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-3xl">
<div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
<img
src="/assets/images/jalil-2.jpg"
<Image
src={jalilPhoto}
alt="Jalil Arfaoui"
loading="eager"
decoding="auto"
@ -59,15 +60,9 @@ import Layout from "../layouts/main.astro";
Plus de 20 ans à construire du logiciel. Aujourd'hui, je choisis mes projets : du libre, de l'utile, rien qui aliène. Craftsmanship, DDD, TypeScript — et une obsession pour les biais qu'on met dans le code sans le savoir. J'enseigne la programmation et j'anime les Software Crafters d'Albi.
</p>
<div class="space-y-3">
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
→ Parcours professionnel 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
→ Mes projets 🚧
</span>
<span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
→ Blog technique 🚧
</span>
<a href="/code" class="block text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium">
→ En savoir plus
</a>
</div>
</div>
</div>
@ -82,9 +77,9 @@ import Layout from "../layouts/main.astro";
Improvisateur depuis 2008, passé par les Yvelines et le Maroc avant de cofonder Les Particules à Albi. Aujourd'hui je m'attaque aussi au théâtre écrit.
</p>
<div class="space-y-3">
<span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="En construction">
→ Parcours artistique 🚧
</span>
<a href="/theatre" class="block text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-200 font-medium">
→ Parcours artistique
</a>
</div>
</div>
</div>

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?$/, '');
}