Pages de détail individuelles pour les projets (FR, EN, AR)

Les cartes projet sont désormais cliquables et mènent vers une page de détail qui affiche le contenu markdown riche (historique, architecture, impact) avec les technologies, liens externes et hreflang.
This commit is contained in:
Jalil Arfaoui 2026-02-27 01:33:57 +01:00
parent 6f13800716
commit 578c6733db
13 changed files with 257 additions and 55 deletions

View file

@ -1,18 +1,23 @@
--- ---
import { t, type Locale } from '../../utils/i18n';
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
technologies?: string[]; technologies?: string[];
url?: string;
github?: string;
featured?: boolean; featured?: boolean;
detailUrl: string;
lang?: Locale;
} }
const { title, description, technologies, url, github, featured = false } = Astro.props; const { title, description, technologies, featured = false, detailUrl, lang = 'fr' } = Astro.props;
const isRtl = lang === 'ar';
const readMoreLabel = t('projects', 'readMore', lang);
--- ---
<div class:list={[ <a href={detailUrl} class:list={[
"facet-card rounded-2xl border p-6 flex flex-col h-full transition-all duration-300 hover:scale-[1.02]", "facet-card rounded-2xl border p-6 flex flex-col h-full transition-all duration-300 hover:scale-[1.02] no-underline",
featured featured
? "bg-white/[0.12] border-purple-300/20 hover:bg-white/[0.18] hover:border-purple-300/30" ? "bg-white/[0.12] border-purple-300/20 hover:bg-white/[0.18] hover:border-purple-300/30"
: "bg-white/[0.06] border-white/[0.1] hover:bg-white/[0.12] hover:border-white/[0.2]", : "bg-white/[0.06] border-white/[0.1] hover:bg-white/[0.12] hover:border-white/[0.2]",
@ -37,16 +42,11 @@ const { title, description, technologies, url, github, featured = false } = Astr
</div> </div>
)} )}
<div class="flex gap-4"> <span class="text-sm font-medium text-purple-200 group-hover:text-white transition-colors">
{url && ( {isRtl ? (
<a href={url} target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-purple-200 hover:text-white transition-colors"> <>{readMoreLabel} &larr;</>
Voir le site &rarr; ) : (
</a> <>{readMoreLabel} &rarr;</>
)} )}
{github && ( </span>
<a href={github} target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-white/50 hover:text-white transition-colors">
GitHub &rarr;
</a> </a>
)}
</div>
</div>

View file

@ -0,0 +1,104 @@
---
import Layout from '../../layouts/main.astro';
import { render } from 'astro:content';
import { t, getProjectsBasePath, getDateLocale, type Locale } from '../../utils/i18n';
interface Props {
project: any;
lang: Locale;
}
const { project, lang } = Astro.props;
const { Content } = await render(project);
const projectsPath = getProjectsBasePath(lang);
const isRtl = lang === 'ar';
const year = project.data.date.getFullYear();
---
<Layout
title={`${project.data.title} - Jalil Arfaoui`}
facet="code"
description={project.data.description}
>
<section
dir={isRtl ? 'rtl' : undefined}
lang={isRtl ? 'ar' : undefined}
class="relative z-20 max-w-3xl mx-auto my-12 px-7 lg:px-0"
>
<a
href={projectsPath}
class="inline-flex items-center gap-1.5 text-sm text-white/50 hover:text-white/80 transition-colors mb-8 group"
>
{isRtl ? (
<>
{t('projects', 'backToProjects', lang)}
<svg class="w-4 h-4 group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</>
) : (
<>
<svg class="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{t('projects', 'backToProjects', lang)}
</>
)}
</a>
<header class="mb-10">
<h1 class="text-3xl sm:text-4xl font-bold text-white mb-3">{project.data.title}</h1>
<p class="text-lg text-white/60 leading-relaxed mb-5">{project.data.description}</p>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-white/45 mb-5">
<span>{t('projects', 'startedIn', lang)} {year}</span>
</div>
{project.data.technologies && project.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-5">
{project.data.technologies.map((tech: string) => (
<span class="inline-block px-2.5 py-1 text-xs rounded-full bg-white/[0.08] text-white/60 border border-white/[0.08]">
{tech}
</span>
))}
</div>
)}
<div class="flex flex-wrap gap-4">
{project.data.url && (
<a
href={project.data.url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-purple-200 hover:text-white transition-colors"
>
{t('projects', 'visitSite', lang)}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{project.data.github && (
<a
href={project.data.github}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-white/50 hover:text-white transition-colors"
>
{t('projects', 'viewOnGithub', lang)}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
</header>
{project.body && (
<div class="prose prose-invert prose-purple max-w-none">
<Content />
</div>
)}
</section>
</Layout>

View file

@ -6,10 +6,12 @@ import Link from "../../../components/Link.astro";
import FeaturedRecommendation from "../../../components/code/FeaturedRecommendation.astro"; import FeaturedRecommendation from "../../../components/code/FeaturedRecommendation.astro";
import ProjectCard from "../../../components/code/ProjectCard.astro"; import ProjectCard from "../../../components/code/ProjectCard.astro";
import SkillBadge from "../../../components/code/SkillBadge.astro"; import SkillBadge from "../../../components/code/SkillBadge.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png"; import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png";
import skillsData from "../../../data/skills.json"; import skillsData from "../../../data/skills.json";
const locale = "ar"; const locale = "ar";
const projectsBasePath = getProjectsBasePath(locale);
const experiences = (await getCollection("experiences")) const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft) .filter((e) => e.data.lang === locale && !e.data.draft)
@ -103,9 +105,9 @@ function formatMonth(dateStr: string) {
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -2,8 +2,10 @@
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import Layout from "../../../layouts/main.astro"; import Layout from "../../../layouts/main.astro";
import ProjectCard from "../../../components/code/ProjectCard.astro"; import ProjectCard from "../../../components/code/ProjectCard.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
const locale = "ar"; const locale = "ar";
const projectsBasePath = getProjectsBasePath(locale);
const projects = (await getCollection("projects")) const projects = (await getCollection("projects"))
.filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev") .filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev")
@ -36,9 +38,9 @@ const projects = (await getCollection("projects"))
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -0,0 +1,18 @@
---
import ProjectDetailContent from '../../../../components/code/ProjectDetailContent.astro';
import { getCollection } from 'astro:content';
import { getProjectBaseSlug } from '../../../../utils/i18n';
export async function getStaticPaths() {
const allProjects = await getCollection('projects');
const arProjects = allProjects.filter(p => p.data.lang === 'ar' && !p.data.draft && p.data.category === 'dev');
return arProjects.map(project => ({
params: { slug: getProjectBaseSlug(project.id) },
props: { project },
}));
}
const { project } = Astro.props;
---
<ProjectDetailContent project={project} lang="ar" />

View file

@ -5,9 +5,11 @@ import Layout from "../../layouts/main.astro";
import Link from "../../components/Link.astro"; import Link from "../../components/Link.astro";
import FeaturedRecommendation from "../../components/code/FeaturedRecommendation.astro"; import FeaturedRecommendation from "../../components/code/FeaturedRecommendation.astro";
import ProjectCard from "../../components/code/ProjectCard.astro"; import ProjectCard from "../../components/code/ProjectCard.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../utils/i18n";
import logoTiqa from "../../assets/images/logo-tiqa-blanc.png"; import logoTiqa from "../../assets/images/logo-tiqa-blanc.png";
const locale = "fr"; const locale = "fr";
const projectsBasePath = getProjectsBasePath(locale);
const experiences = (await getCollection("experiences")) const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft) .filter((e) => e.data.lang === locale && !e.data.draft)
@ -99,9 +101,9 @@ function formatMonth(dateStr: string) {
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -2,8 +2,10 @@
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import Layout from "../../layouts/main.astro"; import Layout from "../../layouts/main.astro";
import ProjectCard from "../../components/code/ProjectCard.astro"; import ProjectCard from "../../components/code/ProjectCard.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../utils/i18n";
const locale = "fr"; const locale = "fr";
const projectsBasePath = getProjectsBasePath(locale);
const projects = (await getCollection("projects")) const projects = (await getCollection("projects"))
.filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev") .filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev")
@ -36,9 +38,9 @@ const projects = (await getCollection("projects"))
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -0,0 +1,18 @@
---
import ProjectDetailContent from '../../../components/code/ProjectDetailContent.astro';
import { getCollection } from 'astro:content';
import { getProjectBaseSlug } from '../../../utils/i18n';
export async function getStaticPaths() {
const allProjects = await getCollection('projects');
const frProjects = allProjects.filter(p => p.data.lang === 'fr' && !p.data.draft && p.data.category === 'dev');
return frProjects.map(project => ({
params: { slug: getProjectBaseSlug(project.id) },
props: { project },
}));
}
const { project } = Astro.props;
---
<ProjectDetailContent project={project} lang="fr" />

View file

@ -6,10 +6,12 @@ import Link from "../../../components/Link.astro";
import FeaturedRecommendation from "../../../components/code/FeaturedRecommendation.astro"; import FeaturedRecommendation from "../../../components/code/FeaturedRecommendation.astro";
import ProjectCard from "../../../components/code/ProjectCard.astro"; import ProjectCard from "../../../components/code/ProjectCard.astro";
import SkillBadge from "../../../components/code/SkillBadge.astro"; import SkillBadge from "../../../components/code/SkillBadge.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png"; import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png";
import skillsData from "../../../data/skills.json"; import skillsData from "../../../data/skills.json";
const locale = "en"; const locale = "en";
const projectsBasePath = getProjectsBasePath(locale);
const experiences = (await getCollection("experiences")) const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft) .filter((e) => e.data.lang === locale && !e.data.draft)
@ -103,9 +105,9 @@ function formatMonth(dateStr: string) {
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -2,8 +2,10 @@
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import Layout from "../../../layouts/main.astro"; import Layout from "../../../layouts/main.astro";
import ProjectCard from "../../../components/code/ProjectCard.astro"; import ProjectCard from "../../../components/code/ProjectCard.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
const locale = "en"; const locale = "en";
const projectsBasePath = getProjectsBasePath(locale);
const projects = (await getCollection("projects")) const projects = (await getCollection("projects"))
.filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev") .filter((p) => p.data.lang === locale && !p.data.draft && p.data.category === "dev")
@ -36,9 +38,9 @@ const projects = (await getCollection("projects"))
title={project.data.title} title={project.data.title}
description={project.data.description} description={project.data.description}
technologies={project.data.technologies} technologies={project.data.technologies}
url={project.data.url}
github={project.data.github}
featured={project.data.featured} featured={project.data.featured}
detailUrl={`${projectsBasePath}/${getProjectBaseSlug(project.id)}`}
lang={locale}
/> />
))} ))}
</div> </div>

View file

@ -0,0 +1,18 @@
---
import ProjectDetailContent from '../../../../components/code/ProjectDetailContent.astro';
import { getCollection } from 'astro:content';
import { getProjectBaseSlug } from '../../../../utils/i18n';
export async function getStaticPaths() {
const allProjects = await getCollection('projects');
const enProjects = allProjects.filter(p => p.data.lang === 'en' && !p.data.draft && p.data.category === 'dev');
return enProjects.map(project => ({
params: { slug: getProjectBaseSlug(project.id) },
props: { project },
}));
}
const { project } = Astro.props;
---
<ProjectDetailContent project={project} lang="en" />

View file

@ -93,6 +93,14 @@ export const translations = {
nextImage: { fr: 'Image suivante', en: 'Next image', ar: 'الصورة التالية' }, nextImage: { fr: 'Image suivante', en: 'Next image', ar: 'الصورة التالية' },
goToImage: { fr: "Aller à l'image", en: 'Go to image', ar: 'انتقل إلى الصورة' }, goToImage: { fr: "Aller à l'image", en: 'Go to image', ar: 'انتقل إلى الصورة' },
}, },
projects: {
visitSite: { fr: 'Voir le site', en: 'Visit site', ar: 'زيارة الموقع' },
viewOnGithub: { fr: 'Voir sur GitHub', en: 'View on GitHub', ar: 'عرض على GitHub' },
backToProjects: { fr: 'Projets', en: 'Projects', ar: 'المشاريع' },
technologies: { fr: 'Technologies', en: 'Technologies', ar: 'التقنيات' },
startedIn: { fr: 'Démarré en', en: 'Started in', ar: 'بدأ في' },
readMore: { fr: 'Lire plus', en: 'Read more', ar: 'اقرأ المزيد' },
},
pages: { pages: {
home: { home: {
title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' }, title: { fr: 'Jalil Arfaoui', en: 'Jalil Arfaoui', ar: 'جليل عرفاوي' },
@ -216,3 +224,15 @@ export function getHomePath(locale: Locale): string {
export function getPostBaseSlug(postId: string): string { export function getPostBaseSlug(postId: string): string {
return postId.replace(/^\d{4}\//, '').replace(/\.(en|ar)(\.md)?$/, ''); return postId.replace(/^\d{4}\//, '').replace(/\.(en|ar)(\.md)?$/, '');
} }
/** Slug de base d'un projet depuis son id (ex: "debats.en" → "debats") */
export function getProjectBaseSlug(projectId: string): string {
return projectId.replace(/\.(en|ar)$/, '');
}
/** Chemin de base des projets selon la langue */
export function getProjectsBasePath(locale: Locale): string {
if (locale === 'ar') return '/ar/برمجة/مشاريع';
if (locale === 'en') return '/en/code/projects';
return '/code/projets';
}

View file

@ -1,5 +1,5 @@
import type { Locale } from "./i18n"; import type { Locale } from "./i18n";
import { getPhotoAlbumsPath, getPhotoBlogPath } from "./i18n"; import { getPhotoAlbumsPath, getPhotoBlogPath, getProjectsBasePath } from "./i18n";
/** /**
* Groupes de pages traduites. * Groupes de pages traduites.
@ -57,6 +57,18 @@ const dynamicPatterns: DynamicPattern[] = [
}; };
}, },
}, },
{
// Projects: /code/projets/{slug}, /en/code/projects/{slug}, /ar/برمجة/مشاريع/{slug}
regex: /^(?:\/code\/projets|\/en\/code\/projects|\/ar\/برمجة\/مشاريع)\/([^/]+)$/,
buildUrls: (match) => {
const slug = match[1];
return {
fr: `${getProjectsBasePath("fr")}/${slug}`,
en: `${getProjectsBasePath("en")}/${slug}`,
ar: `${getProjectsBasePath("ar")}/${slug}`,
};
},
},
]; ];
function matchDynamicPattern( function matchDynamicPattern(