Compare commits

..

No commits in common. "73d4d2fa064b876b050d059095ce3c9ca3ae8092" and "cd1ca94b116504b16c1fabc815c805c0a2983740" have entirely different histories.

74 changed files with 603 additions and 1518 deletions

View file

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

9
.gitignore vendored
View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View file

@ -1,7 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import { createClient } from "webdav"; import { createClient } from "webdav";
import { mkdir, writeFile, stat, readdir, rm } from "fs/promises"; import { mkdir, writeFile, stat } from "fs/promises";
import { join, dirname, relative } from "path"; import { join, dirname } from "path";
interface FileStat { interface FileStat {
filename: string; filename: string;
@ -30,25 +30,13 @@ async function main() {
console.log(`Fetching images from ${WEBDAV_URL}${WEBDAV_PATH}...`); console.log(`Fetching images from ${WEBDAV_URL}${WEBDAV_PATH}...`);
// Collecter tous les fichiers/dossiers distants await syncDirectory(client, WEBDAV_PATH, DEST_DIR);
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!"); console.log("Done!");
} }
async function syncDirectory( async function syncDirectory(client: ReturnType<typeof createClient>, remotePath: string, localPath: string) {
client: ReturnType<typeof createClient>,
remotePath: string,
localPath: string,
remoteItems: Set<string>
) {
await mkdir(localPath, { recursive: true }); await mkdir(localPath, { recursive: true });
remoteItems.add(localPath);
const items = (await client.getDirectoryContents(remotePath)) as FileStat[]; const items = (await client.getDirectoryContents(remotePath)) as FileStat[];
@ -58,9 +46,8 @@ async function syncDirectory(
if (item.type === "directory") { if (item.type === "directory") {
console.log(` [dir] ${item.basename}/`); console.log(` [dir] ${item.basename}/`);
await syncDirectory(client, remoteItemPath, localItemPath, remoteItems); await syncDirectory(client, remoteItemPath, localItemPath);
} else if (item.type === "file" && /\.(jpg|jpeg|png|webp)$/i.test(item.basename)) { } else if (item.type === "file" && /\.(jpg|jpeg|png|webp)$/i.test(item.basename)) {
remoteItems.add(localItemPath);
const needsDownload = await shouldDownload(localItemPath, item); const needsDownload = await shouldDownload(localItemPath, item);
if (needsDownload) { if (needsDownload) {
@ -94,72 +81,6 @@ 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) => { main().catch((err) => {
console.error("Error:", err.message); console.error("Error:", err.message);
process.exit(1); process.exit(1);

View file

@ -16,22 +16,6 @@ const translations: Record<string, Record<string, string>> = {
en: '/en/about', en: '/en/about',
ar: '/ar/نبذة-عني' 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 // Page d'accueil
'/': { '/': {
fr: '/', fr: '/',

View file

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

View file

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

View file

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

View file

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

View file

@ -1,42 +1,27 @@
--- ---
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import HomeIcon from '../icons/HomeIcon.astro'; import HomeIcon from '../icons/HomeIcon.astro';
import { t, getPhotoBasePath, getPhotoBlogPath, getPhotoAlbumsPath, getHomePath, type Locale } from '../../utils/i18n';
interface Props { const { currentCategory = '', opaque = false } = Astro.props;
currentCategory?: string;
opaque?: boolean;
lang?: Locale;
}
const { currentCategory = '', opaque = false, lang = 'fr' } = Astro.props; // 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 photoBasePath = getPhotoBasePath(lang); // Catégories photos uniquement
const homePath = getHomePath(lang); const categories = sortedCategories.map(cat => ({ id: cat.id, title: cat.data.title }));
// 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`}> <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"> <div class="nav-container">
<!-- Titre du site à gauche --> <!-- Titre du site à gauche -->
<div class="site-title"> <div class="site-title">
<a href={homePath} class="site-link"> <a href="/" class="site-link">
<HomeIcon size={16} /> <HomeIcon size={16} />
<span class="site-name">{t('common', 'siteName', lang)}</span> <span class="site-name">Jalil Arfaoui</span>
</a> </a>
<span class="nav-separator"></span> <span class="nav-separator"></span>
<a href={photoBasePath} class="nav-link">{t('nav', 'photo', lang)}</a> <a href="/photo" class="nav-link">Photo</a>
</div> </div>
<!-- Bouton hamburger (mobile) --> <!-- Bouton hamburger (mobile) -->
@ -49,10 +34,10 @@ const categories = sortedCategories.map(cat => ({
<!-- Navigation --> <!-- Navigation -->
<div class="nav-menu" id="navMenu"> <div class="nav-menu" id="navMenu">
<a <a
href={getPhotoBlogPath(lang)} href="/photo/blog"
class={`nav-link blog-link ${currentCategory === 'blog' ? 'active' : ''}`} class={`nav-link blog-link ${currentCategory === 'blog' ? 'active' : ''}`}
> >
{t('photo', 'photoFeed', lang)} Fil Photo
</a> </a>
<!-- Séparateur --> <!-- Séparateur -->
@ -63,7 +48,7 @@ const categories = sortedCategories.map(cat => ({
{categories.map(cat => ( {categories.map(cat => (
<li> <li>
<a <a
href={`${getPhotoAlbumsPath(lang)}/${cat.id}`} href={`/photo/albums/${cat.id}`}
class={`nav-link ${currentCategory === cat.id ? 'active' : ''}`} class={`nav-link ${currentCategory === cat.id ? 'active' : ''}`}
data-category={cat.id} data-category={cat.id}
> >

View file

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

View file

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

View file

@ -1,24 +1,12 @@
---
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"> <footer class="photo-footer">
<div class="photo-footer-inner"> <div class="photo-footer-inner">
<div class="photo-footer-links"> <div class="photo-footer-links">
<a href={getAboutPath(lang)}>{t('photo', 'about', lang)}</a> <a href="/a-propos">À propos</a>
<a href="mailto:jalil@arfaoui.net">{t('photo', 'contact', lang)}</a> <a href="mailto:jalil@arfaoui.net">Contact</a>
<a href="https://instagram.com/l.i.l.a.j" target="_blank" rel="noopener noreferrer">Instagram</a> <a href="https://instagram.com/l.i.l.a.j" target="_blank" rel="noopener noreferrer">Instagram</a>
</div> </div>
<PhotoLanguageSwitcher lang={lang} />
<div class="photo-footer-copy"> <div class="photo-footer-copy">
&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> © 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>
</div> </div>
</footer> </footer>
@ -48,12 +36,6 @@ const { lang = 'fr' } = Astro.props;
.photo-footer-links { .photo-footer-links {
display: flex; display: flex;
gap: 15px; gap: 15px;
flex: 1;
}
.photo-footer-copy {
flex: 1;
text-align: end;
} }
.photo-footer-links a, .photo-footer-links a,

View file

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

View file

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

View file

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

View file

@ -1,25 +0,0 @@
---
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

@ -1,338 +0,0 @@
---
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

@ -1,119 +0,0 @@
---
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

@ -1,21 +0,0 @@
---
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,7 +85,6 @@ const photoCategoriesCollection = defineCollection({
title: z.string(), title: z.string(),
subtitle: z.string(), subtitle: z.string(),
order: z.number().optional(), order: z.number().optional(),
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
}), }),
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,68 +0,0 @@
---
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

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

View file

@ -1,19 +0,0 @@
---
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

@ -1,24 +0,0 @@
---
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

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

View file

@ -1,49 +0,0 @@
---
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,9 +1,7 @@
--- ---
import { Image } from "astro:assets";
import PageHeading from "../../components/page-heading.astro"; import PageHeading from "../../components/page-heading.astro";
import Layout from "../../layouts/main.astro"; import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro"; import Link from "../../components/Link.astro";
import jalilPhoto from "../../assets/images/jalil.jpg";
--- ---
<Layout title="نبذة عني - جليل عرفاوي"> <Layout title="نبذة عني - جليل عرفاوي">
@ -13,7 +11,7 @@ import jalilPhoto from "../../assets/images/jalil.jpg";
description="حِرَفي برمجة، ممثل ارتجالي، مصوّر فضولي." description="حِرَفي برمجة، ممثل ارتجالي، مصوّر فضولي."
/> />
<Image src={jalilPhoto} class="relative z-30 w-full my-10 rounded-xl" alt="جليل عرفاوي" /> <img src="/assets/images/jalil.jpg" class="relative z-30 w-full my-10 rounded-xl" alt="جليل عرفاوي" />
<h2 class="mb-4 text-2xl font-bold dark:text-neutral-200">من أنا؟</h2> <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"> <div class="space-y-4 text-gray-600 dark:text-neutral-400 leading-relaxed">

View file

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

View file

@ -1,49 +0,0 @@
---
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>

View file

@ -1,68 +0,0 @@
---
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,7 +1,5 @@
--- ---
import { Image } from "astro:assets";
import Layout from "../../layouts/main.astro"; import Layout from "../../layouts/main.astro";
import jalilPhoto from "../../assets/images/jalil-2.jpg";
--- ---
<Layout title="Jalil Arfaoui - Software Craftsman • Improv Actor • Photographer"> <Layout title="Jalil Arfaoui - Software Craftsman • Improv Actor • Photographer">
@ -20,9 +18,10 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
</p> </p>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<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"> <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">
View my work View my work
</a> <span class="ml-2">🚧</span>
</span>
<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"> <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 Learn more
</a> </a>
@ -33,8 +32,8 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
<div class="relative z-50 w-full max-w-sm mx-auto"> <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="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"> <div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
<Image <img
src={jalilPhoto} src="/assets/images/jalil-2.jpg"
alt="Jalil Arfaoui" alt="Jalil Arfaoui"
loading="eager" loading="eager"
decoding="auto" decoding="auto"
@ -60,9 +59,15 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
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. 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> </p>
<div class="space-y-3"> <div class="space-y-3">
<a href="/en/code" class="block text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium"> <span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="Under construction">
→ Learn more → Professional journey 🚧
</a> </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>
</div> </div>
</div> </div>
</div> </div>
@ -77,9 +82,9 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
Improv actor since 2008, from the Paris suburbs to Morocco before co-founding Les Particules in Albi. Now also taking on scripted theater. Improv actor since 2008, from the Paris suburbs to Morocco before co-founding Les Particules in Albi. Now also taking on scripted theater.
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<a href="/en/acting" class="block text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-200 font-medium"> <span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="Under construction">
→ Artistic journey → Artistic journey 🚧
</a> </span>
</div> </div>
</div> </div>
</div> </div>
@ -94,10 +99,10 @@ import jalilPhoto from "../../assets/images/jalil-2.jpg";
I photograph a bit of everything: faces, concerts, engines, everyday life. No specialty, just curiosity. I photograph a bit of everything: faces, concerts, engines, everyday life. No specialty, just curiosity.
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<a href="/en/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium"> <a href="/photo" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
→ Photo portfolio → Photo portfolio
</a> </a>
<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"> <a href="/photo/blog" class="block text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 font-medium">
→ Photo Feed → Photo Feed
</a> </a>
</div> </div>

View file

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

View file

@ -1,19 +0,0 @@
---
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

@ -1,24 +0,0 @@
---
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

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

View file

@ -1,7 +1,5 @@
--- ---
import { Image } from "astro:assets";
import Layout from "../layouts/main.astro"; 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"> <Layout title="Jalil Arfaoui - Développeur artisan • Comédien improvisateur • Photographe">
@ -20,9 +18,10 @@ import jalilPhoto from "../assets/images/jalil-2.jpg";
</p> </p>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
<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"> <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">
Voir mon travail Voir mon travail
</a> <span class="ml-2">🚧</span>
</span>
<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"> <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 En savoir plus
</a> </a>
@ -33,8 +32,8 @@ import jalilPhoto from "../assets/images/jalil-2.jpg";
<div class="relative z-50 w-full max-w-sm mx-auto"> <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="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"> <div class="bg-white dark:bg-neutral-950 rounded-3xl p-4">
<Image <img
src={jalilPhoto} src="/assets/images/jalil-2.jpg"
alt="Jalil Arfaoui" alt="Jalil Arfaoui"
loading="eager" loading="eager"
decoding="auto" decoding="auto"
@ -60,9 +59,15 @@ import jalilPhoto from "../assets/images/jalil-2.jpg";
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. 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> </p>
<div class="space-y-3"> <div class="space-y-3">
<a href="/code" class="block text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 font-medium"> <span class="block text-blue-400/50 dark:text-blue-600/50 cursor-not-allowed" title="En construction">
→ En savoir plus → Parcours professionnel 🚧
</a> </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>
</div> </div>
</div> </div>
</div> </div>
@ -77,9 +82,9 @@ import jalilPhoto from "../assets/images/jalil-2.jpg";
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. 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> </p>
<div class="space-y-3"> <div class="space-y-3">
<a href="/theatre" class="block text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-200 font-medium"> <span class="block text-orange-400/50 dark:text-orange-600/50 cursor-not-allowed" title="En construction">
→ Parcours artistique → Parcours artistique 🚧
</a> </span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,14 @@
--- ---
import PhotoHomeContent from '../components/photo/pages/PhotoHomeContent.astro'; 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";
--- ---
<PhotoHomeContent lang="fr" /> <PhotoLayout title={title} enableScroll={true}>
<PhotoGallery category="all" />
<HeroViewport targetSelector="#explore-section" transparent />
<ExploreSection />
</PhotoLayout>

View file

@ -0,0 +1,36 @@
---
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

@ -1,19 +0,0 @@
---
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,14 +1,19 @@
--- ---
import PhotoBlogPostContent from '../../../../components/photo/pages/PhotoBlogPostContent.astro'; 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 { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { getPostBaseSlug } from '../../../../utils/i18n';
// Importer toutes les images du dossier photos
const allImages = import.meta.glob<{ default: ImageMetadata }>('/src/assets/images/photos/blog/**/*.{jpg,jpeg,png,webp}');
export async function getStaticPaths() { export async function getStaticPaths() {
const allPhotoBlogPosts = await getCollection('photoBlogPosts'); const allPhotoBlogPosts = await getCollection('photoBlogPosts');
// Uniquement les posts FR (lang absent ou 'fr') return allPhotoBlogPosts.map(post => {
const frPosts = allPhotoBlogPosts.filter(post => (post.data.lang ?? 'fr') === 'fr'); // Le slug Astro inclut le préfixe d'année (ex: "2015/enigma.en")
return frPosts.map(post => { const slug = post.slug.replace(/^\d{4}\//, '');
const slug = getPostBaseSlug(post.id);
return { return {
params: { params: {
year: String(post.data.date.getFullYear()), year: String(post.data.date.getFullYear()),
@ -20,6 +25,104 @@ export async function getStaticPaths() {
} }
const { post } = Astro.props; 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
}));
--- ---
<PhotoBlogPostContent post={post} lang="fr" /> <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>

View file

@ -1,5 +1,326 @@
--- ---
import PhotoBlogIndexContent from '../../../components/photo/pages/PhotoBlogIndexContent.astro'; 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);
--- ---
<PhotoBlogIndexContent lang="fr" /> <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>

View file

@ -1,49 +0,0 @@
---
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,7 +47,6 @@ export const translations = {
uses: { fr: 'Utilise', en: 'Uses', ar: 'يستخدم' }, uses: { fr: 'Utilise', en: 'Uses', ar: 'يستخدم' },
}, },
common: { common: {
siteName: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
readMore: { fr: 'Lire la suite', en: 'Read more', ar: 'اقرأ المزيد' }, readMore: { fr: 'Lire la suite', en: 'Read more', ar: 'اقرأ المزيد' },
backToHome: { fr: 'Retour à l\'accueil', en: 'Back to home', ar: 'العودة إلى الرئيسية' }, backToHome: { fr: 'Retour à l\'accueil', en: 'Back to home', ar: 'العودة إلى الرئيسية' },
publishedOn: { fr: 'Publié le', en: 'Published on', ar: 'نشر في' }, publishedOn: { fr: 'Publié le', en: 'Published on', ar: 'نشر في' },
@ -66,23 +65,6 @@ export const translations = {
photo: { fr: 'Photographie', en: 'Photography', ar: 'تصوير' }, photo: { fr: 'Photographie', en: 'Photography', ar: 'تصوير' },
dev: { fr: 'Développement', en: 'Development', 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: { pages: {
home: { home: {
title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' }, title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
@ -123,54 +105,3 @@ 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?$/, '');
}