Extraction composant CareerTimeline : suppression de la duplication 3x entre pages FR/EN/AR, traductions centralisées dans i18n.ts

This commit is contained in:
Jalil Arfaoui 2026-03-11 17:20:23 +01:00
parent 5b887c7926
commit fca4608beb
5 changed files with 159 additions and 387 deletions

View file

@ -0,0 +1,141 @@
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import { t, getDateLocale, type Locale } from "../../utils/i18n";
import "../../styles/exp-card.css";
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const logos = import.meta.glob<{ default: ImageMetadata }>('../../assets/images/companies/*.png', { eager: true });
function getLogo(filename?: string) {
if (!filename) return null;
const key = `../../assets/images/companies/${filename}`;
return logos[key]?.default ?? null;
}
function getInitials(company: string) {
return company.split(/[\s-]+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
}
const typeColors: Record<string, { badge: string; border: string; dot: string }> = {
employment: { badge: 'type-badge-blue', border: 'type-border-blue', dot: 'bg-blue-400 border-blue-400/50' },
freelance: { badge: 'type-badge-amber', border: 'type-border-amber', dot: 'bg-amber-400 border-amber-400/50' },
teaching: { badge: 'type-badge-emerald', border: 'type-border-emerald', dot: 'bg-emerald-400 border-emerald-400/50' },
community: { badge: 'type-badge-pink', border: 'type-border-pink', dot: 'bg-pink-400 border-pink-400/50' },
entrepreneurship: { badge: 'type-badge-purple', border: 'type-border-purple', dot: 'bg-purple-400 border-purple-400/50' },
};
function formatMonth(dateStr: string) {
const [year, month] = dateStr.split('-');
return new Date(parseInt(year), parseInt(month) - 1)
.toLocaleDateString(getDateLocale(locale), { year: 'numeric', month: 'short' });
}
function formatDuration(years: number, months: number) {
const yearLabel = years > 1 ? t('career', 'durationYears', locale) : t('career', 'durationYear', locale);
const monthLabel = t('career', 'durationMonth', locale);
if (years === 0) return `${months} ${monthLabel}`;
if (months === 0) return `${years} ${yearLabel}`;
return `${years} ${yearLabel} ${months} ${monthLabel}`;
}
function computeDuration(startStr: string, endStr?: string) {
const [sy, sm] = startStr.split('-').map(Number);
const end = endStr ? endStr.split('-').map(Number) : [new Date().getFullYear(), new Date().getMonth() + 1];
const totalMonths = Math.max(1, (end[0] - sy) * 12 + (end[1] - sm));
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
return formatDuration(years, months);
}
const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft)
.sort((a, b) => (b.data.startDate > a.data.startDate ? 1 : -1));
const presentLabel = t('career', 'present', locale);
const logoAltPrefix = t('career', 'logoAlt', locale);
---
<div class="mt-10">
{experiences.map(async (exp) => {
const { Content } = await render(exp);
const isOngoing = !exp.data.endDate;
const start = formatMonth(exp.data.startDate);
const end = exp.data.endDate ? formatMonth(exp.data.endDate) : presentLabel;
const duration = computeDuration(exp.data.startDate, exp.data.endDate);
const colors = typeColors[exp.data.type] || typeColors.employment;
const label = t('career', exp.data.type, locale);
return (
<div class="relative ps-10 pb-10 border-s-2 border-white/[0.12] last:pb-0">
<div
class:list={[
"absolute -start-[7px] top-2 w-3 h-3 rounded-full border-2",
isOngoing ? colors.dot : "border-white/20 bg-white/10"
]}
>
{isOngoing && <div class="absolute inset-0 rounded-full bg-current opacity-40 animate-ping" />}
</div>
<div class:list={[
"code-card exp-card relative rounded-2xl p-6 transition-colors duration-200 border-s-[3px]",
isOngoing ? "exp-card--ongoing" : "",
colors.border
]}>
{getLogo(exp.data.logo) ? (
<div class="exp-logo absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center p-2">
<Image
src={getLogo(exp.data.logo)!}
alt={`${logoAltPrefix} ${exp.data.company}`}
width={52}
height={52}
class="rounded-lg object-contain"
/>
</div>
) : (
<div class="exp-logo-fallback absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center">
<span class="text-base font-bold">{getInitials(exp.data.company)}</span>
</div>
)}
<div class="mb-3 flex items-center gap-2 flex-wrap pe-20">
<span class:list={["inline-block px-2 py-0.5 text-[11px] font-medium rounded-full border", colors.badge]}>
{label}
</span>
<span class="exp-meta text-xs">
{start} — {end}
</span>
<span class="exp-meta text-xs">· {duration}</span>
{exp.data.location && <span class="exp-meta text-xs">· {exp.data.location}</span>}
</div>
<h3 class="text-xl font-bold pe-20">{exp.data.role}</h3>
<p class="exp-company text-sm font-medium mt-1 mb-3">
{exp.data.companyUrl ? (
<a href={exp.data.companyUrl} target="_blank" rel="noopener noreferrer" class="transition-colors">{exp.data.company}</a>
) : exp.data.company}
</p>
<div class="exp-content text-sm leading-relaxed mb-4 prose prose-sm max-w-none">
<Content />
</div>
{exp.data.technologies && exp.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{exp.data.technologies.map((tech: string) => (
<span class="exp-tech inline-block px-2.5 py-0.5 text-xs rounded-full border">
{tech}
</span>
))}
</div>
)}
</div>
</div>
);
})}
</div>

View file

@ -1,56 +1,6 @@
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import Layout from "../../../layouts/main.astro";
import "../../../styles/exp-card.css";
const logos = import.meta.glob<{ default: ImageMetadata }>('../../../assets/images/companies/*.png', { eager: true });
function getLogo(filename?: string) {
if (!filename) return null;
const key = `../../../assets/images/companies/${filename}`;
return logos[key]?.default ?? null;
}
function getInitials(company: string) {
return company.split(/[\s-]+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
}
const locale = "ar";
const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft)
.sort((a, b) => (b.data.startDate > a.data.startDate ? 1 : -1));
const typeLabels: Record<string, string> = {
employment: 'وظيفة', freelance: 'مستقل', teaching: 'تدريس',
community: 'مجتمع', entrepreneurship: 'ريادة أعمال',
};
const typeColors: Record<string, { badge: string; border: string; dot: string }> = {
employment: { badge: 'type-badge-blue', border: 'type-border-blue', dot: 'bg-blue-400 border-blue-400/50' },
freelance: { badge: 'type-badge-amber', border: 'type-border-amber', dot: 'bg-amber-400 border-amber-400/50' },
teaching: { badge: 'type-badge-emerald', border: 'type-border-emerald', dot: 'bg-emerald-400 border-emerald-400/50' },
community: { badge: 'type-badge-pink', border: 'type-border-pink', dot: 'bg-pink-400 border-pink-400/50' },
entrepreneurship: { badge: 'type-badge-purple', border: 'type-border-purple', dot: 'bg-purple-400 border-purple-400/50' },
};
function formatMonth(dateStr: string) {
const [year, month] = dateStr.split('-');
return new Date(parseInt(year), parseInt(month) - 1)
.toLocaleDateString('ar-SA', { year: 'numeric', month: 'short' });
}
function computeDuration(startStr: string, endStr?: string) {
const [sy, sm] = startStr.split('-').map(Number);
const end = endStr ? endStr.split('-').map(Number) : [new Date().getFullYear(), new Date().getMonth() + 1];
const totalMonths = (end[0] - sy) * 12 + (end[1] - sm);
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
if (years === 0) return `${months} أشهر`;
if (months === 0) return `${years} سنة`;
return `${years} سنة ${months} أشهر`;
}
import CareerTimeline from "../../../components/code/CareerTimeline.astro";
---
<Layout
@ -70,83 +20,6 @@ function computeDuration(startStr: string, endStr?: string) {
<p class="mt-3 text-white/60 text-lg">أكثر من 20 سنة من الخبرة في تطوير البرمجيات.</p>
</div>
<div class="mt-10">
{experiences.map(async (exp) => {
const { Content } = await render(exp);
const isOngoing = !exp.data.endDate;
const start = formatMonth(exp.data.startDate);
const end = exp.data.endDate ? formatMonth(exp.data.endDate) : 'الحاضر';
const duration = computeDuration(exp.data.startDate, exp.data.endDate);
const colors = typeColors[exp.data.type] || typeColors.employment;
const label = typeLabels[exp.data.type] || exp.data.type;
return (
<div class="relative pr-10 pb-10 border-r-2 border-white/[0.12] last:pb-0">
<div
class:list={[
"absolute -right-[7px] top-2 w-3 h-3 rounded-full border-2",
isOngoing ? colors.dot : "border-white/20 bg-white/10"
]}
>
{isOngoing && <div class="absolute inset-0 rounded-full bg-current opacity-40 animate-ping" />}
</div>
<div class:list={[
"code-card exp-card relative rounded-2xl p-6 transition-colors duration-200 border-s-[3px]",
isOngoing ? "exp-card--ongoing" : "",
colors.border
]}>
{getLogo(exp.data.logo) ? (
<div class="exp-logo absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center p-2">
<Image
src={getLogo(exp.data.logo)!}
alt={`شعار ${exp.data.company}`}
width={52}
height={52}
class="rounded-lg object-contain"
/>
</div>
) : (
<div class="exp-logo-fallback absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center">
<span class="text-base font-bold">{getInitials(exp.data.company)}</span>
</div>
)}
<div class="mb-3 flex items-center gap-2 flex-wrap pe-20">
<span class:list={["inline-block px-2 py-0.5 text-[11px] font-medium rounded-full border", colors.badge]}>
{label}
</span>
<span class="exp-meta text-xs">
{start} — {end}
</span>
<span class="exp-meta text-xs">· {duration}</span>
{exp.data.location && <span class="exp-meta text-xs">· {exp.data.location}</span>}
</div>
<h3 class="text-xl font-bold pe-20">{exp.data.role}</h3>
<p class="exp-company text-sm font-medium mt-1 mb-3">
{exp.data.companyUrl ? (
<a href={exp.data.companyUrl} target="_blank" rel="noopener noreferrer" class="transition-colors">{exp.data.company}</a>
) : exp.data.company}
</p>
<div class="exp-content text-sm leading-relaxed mb-4 prose prose-sm max-w-none">
<Content />
</div>
{exp.data.technologies && exp.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{exp.data.technologies.map((tech: string) => (
<span class="exp-tech inline-block px-2.5 py-0.5 text-xs rounded-full border">
{tech}
</span>
))}
</div>
)}
</div>
</div>
);
})}
</div>
<CareerTimeline locale="ar" />
</section>
</Layout>

View file

@ -1,56 +1,6 @@
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import Layout from "../../layouts/main.astro";
import "../../styles/exp-card.css";
const logos = import.meta.glob<{ default: ImageMetadata }>('../../assets/images/companies/*.png', { eager: true });
function getLogo(filename?: string) {
if (!filename) return null;
const key = `../../assets/images/companies/${filename}`;
return logos[key]?.default ?? null;
}
function getInitials(company: string) {
return company.split(/[\s-]+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
}
const locale = "fr";
const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft)
.sort((a, b) => (b.data.startDate > a.data.startDate ? 1 : -1));
const typeLabels: Record<string, string> = {
employment: 'Emploi', freelance: 'Freelance', teaching: 'Enseignement',
community: 'Communauté', entrepreneurship: 'Entrepreneuriat',
};
const typeColors: Record<string, { badge: string; border: string; dot: string }> = {
employment: { badge: 'type-badge-blue', border: 'type-border-blue', dot: 'bg-blue-400 border-blue-400/50' },
freelance: { badge: 'type-badge-amber', border: 'type-border-amber', dot: 'bg-amber-400 border-amber-400/50' },
teaching: { badge: 'type-badge-emerald', border: 'type-border-emerald', dot: 'bg-emerald-400 border-emerald-400/50' },
community: { badge: 'type-badge-pink', border: 'type-border-pink', dot: 'bg-pink-400 border-pink-400/50' },
entrepreneurship: { badge: 'type-badge-purple', border: 'type-border-purple', dot: 'bg-purple-400 border-purple-400/50' },
};
function formatMonth(dateStr: string) {
const [year, month] = dateStr.split('-');
return new Date(parseInt(year), parseInt(month) - 1)
.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short' });
}
function computeDuration(startStr: string, endStr?: string) {
const [sy, sm] = startStr.split('-').map(Number);
const end = endStr ? endStr.split('-').map(Number) : [new Date().getFullYear(), new Date().getMonth() + 1];
const totalMonths = (end[0] - sy) * 12 + (end[1] - sm);
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
if (years === 0) return `${months} mois`;
if (months === 0) return `${years} an${years > 1 ? 's' : ''}`;
return `${years} an${years > 1 ? 's' : ''} ${months} mois`;
}
import CareerTimeline from "../../components/code/CareerTimeline.astro";
---
<Layout
@ -70,83 +20,6 @@ function computeDuration(startStr: string, endStr?: string) {
<p class="mt-3 text-white/60 text-lg">Plus de 20 ans d'expérience en développement logiciel.</p>
</div>
<div class="mt-10">
{experiences.map(async (exp) => {
const { Content } = await render(exp);
const isOngoing = !exp.data.endDate;
const start = formatMonth(exp.data.startDate);
const end = exp.data.endDate ? formatMonth(exp.data.endDate) : 'Présent';
const duration = computeDuration(exp.data.startDate, exp.data.endDate);
const colors = typeColors[exp.data.type] || typeColors.employment;
const label = typeLabels[exp.data.type] || exp.data.type;
return (
<div class="relative pl-10 pb-10 border-l-2 border-white/[0.12] last:pb-0">
<div
class:list={[
"absolute -left-[7px] top-2 w-3 h-3 rounded-full border-2",
isOngoing ? colors.dot : "border-white/20 bg-white/10"
]}
>
{isOngoing && <div class="absolute inset-0 rounded-full bg-current opacity-40 animate-ping" />}
</div>
<div class:list={[
"code-card exp-card relative rounded-2xl p-6 transition-colors duration-200 border-s-[3px]",
isOngoing ? "exp-card--ongoing" : "",
colors.border
]}>
{getLogo(exp.data.logo) ? (
<div class="exp-logo absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center p-2">
<Image
src={getLogo(exp.data.logo)!}
alt={`Logo ${exp.data.company}`}
width={52}
height={52}
class="rounded-lg object-contain"
/>
</div>
) : (
<div class="exp-logo-fallback absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center">
<span class="text-base font-bold">{getInitials(exp.data.company)}</span>
</div>
)}
<div class="mb-3 flex items-center gap-2 flex-wrap pe-20">
<span class:list={["inline-block px-2 py-0.5 text-[11px] font-medium rounded-full border", colors.badge]}>
{label}
</span>
<span class="exp-meta text-xs">
{start} — {end}
</span>
<span class="exp-meta text-xs">· {duration}</span>
{exp.data.location && <span class="exp-meta text-xs">· {exp.data.location}</span>}
</div>
<h3 class="text-xl font-bold pe-20">{exp.data.role}</h3>
<p class="exp-company text-sm font-medium mt-1 mb-3">
{exp.data.companyUrl ? (
<a href={exp.data.companyUrl} target="_blank" rel="noopener noreferrer" class="transition-colors">{exp.data.company}</a>
) : exp.data.company}
</p>
<div class="exp-content text-sm leading-relaxed mb-4 prose prose-sm max-w-none">
<Content />
</div>
{exp.data.technologies && exp.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{exp.data.technologies.map((tech: string) => (
<span class="exp-tech inline-block px-2.5 py-0.5 text-xs rounded-full border">
{tech}
</span>
))}
</div>
)}
</div>
</div>
);
})}
</div>
<CareerTimeline locale="fr" />
</section>
</Layout>

View file

@ -1,56 +1,6 @@
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import Layout from "../../../layouts/main.astro";
import "../../../styles/exp-card.css";
const logos = import.meta.glob<{ default: ImageMetadata }>('../../../assets/images/companies/*.png', { eager: true });
function getLogo(filename?: string) {
if (!filename) return null;
const key = `../../../assets/images/companies/${filename}`;
return logos[key]?.default ?? null;
}
function getInitials(company: string) {
return company.split(/[\s-]+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase();
}
const locale = "en";
const experiences = (await getCollection("experiences"))
.filter((e) => e.data.lang === locale && !e.data.draft)
.sort((a, b) => (b.data.startDate > a.data.startDate ? 1 : -1));
const typeLabels: Record<string, string> = {
employment: 'Employment', freelance: 'Freelance', teaching: 'Teaching',
community: 'Community', entrepreneurship: 'Entrepreneurship',
};
const typeColors: Record<string, { badge: string; border: string; dot: string }> = {
employment: { badge: 'type-badge-blue', border: 'type-border-blue', dot: 'bg-blue-400 border-blue-400/50' },
freelance: { badge: 'type-badge-amber', border: 'type-border-amber', dot: 'bg-amber-400 border-amber-400/50' },
teaching: { badge: 'type-badge-emerald', border: 'type-border-emerald', dot: 'bg-emerald-400 border-emerald-400/50' },
community: { badge: 'type-badge-pink', border: 'type-border-pink', dot: 'bg-pink-400 border-pink-400/50' },
entrepreneurship: { badge: 'type-badge-purple', border: 'type-border-purple', dot: 'bg-purple-400 border-purple-400/50' },
};
function formatMonth(dateStr: string) {
const [year, month] = dateStr.split('-');
return new Date(parseInt(year), parseInt(month) - 1)
.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
}
function computeDuration(startStr: string, endStr?: string) {
const [sy, sm] = startStr.split('-').map(Number);
const end = endStr ? endStr.split('-').map(Number) : [new Date().getFullYear(), new Date().getMonth() + 1];
const totalMonths = (end[0] - sy) * 12 + (end[1] - sm);
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
if (years === 0) return `${months} mo`;
if (months === 0) return `${years} yr${years > 1 ? 's' : ''}`;
return `${years} yr${years > 1 ? 's' : ''} ${months} mo`;
}
import CareerTimeline from "../../../components/code/CareerTimeline.astro";
---
<Layout
@ -70,83 +20,6 @@ function computeDuration(startStr: string, endStr?: string) {
<p class="mt-3 text-white/60 text-lg">Over 20 years of experience in software development.</p>
</div>
<div class="mt-10">
{experiences.map(async (exp) => {
const { Content } = await render(exp);
const isOngoing = !exp.data.endDate;
const start = formatMonth(exp.data.startDate);
const end = exp.data.endDate ? formatMonth(exp.data.endDate) : 'Present';
const duration = computeDuration(exp.data.startDate, exp.data.endDate);
const colors = typeColors[exp.data.type] || typeColors.employment;
const label = typeLabels[exp.data.type] || exp.data.type;
return (
<div class="relative pl-10 pb-10 border-l-2 border-white/[0.12] last:pb-0">
<div
class:list={[
"absolute -left-[7px] top-2 w-3 h-3 rounded-full border-2",
isOngoing ? colors.dot : "border-white/20 bg-white/10"
]}
>
{isOngoing && <div class="absolute inset-0 rounded-full bg-current opacity-40 animate-ping" />}
</div>
<div class:list={[
"code-card exp-card relative rounded-2xl p-6 transition-colors duration-200 border-s-[3px]",
isOngoing ? "exp-card--ongoing" : "",
colors.border
]}>
{getLogo(exp.data.logo) ? (
<div class="exp-logo absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center p-2">
<Image
src={getLogo(exp.data.logo)!}
alt={`${exp.data.company} logo`}
width={52}
height={52}
class="rounded-lg object-contain"
/>
</div>
) : (
<div class="exp-logo-fallback absolute top-5 end-5 w-16 h-16 rounded-xl flex items-center justify-center">
<span class="text-base font-bold">{getInitials(exp.data.company)}</span>
</div>
)}
<div class="mb-3 flex items-center gap-2 flex-wrap pe-20">
<span class:list={["inline-block px-2 py-0.5 text-[11px] font-medium rounded-full border", colors.badge]}>
{label}
</span>
<span class="exp-meta text-xs">
{start} — {end}
</span>
<span class="exp-meta text-xs">· {duration}</span>
{exp.data.location && <span class="exp-meta text-xs">· {exp.data.location}</span>}
</div>
<h3 class="text-xl font-bold pe-20">{exp.data.role}</h3>
<p class="exp-company text-sm font-medium mt-1 mb-3">
{exp.data.companyUrl ? (
<a href={exp.data.companyUrl} target="_blank" rel="noopener noreferrer" class="transition-colors">{exp.data.company}</a>
) : exp.data.company}
</p>
<div class="exp-content text-sm leading-relaxed mb-4 prose prose-sm max-w-none">
<Content />
</div>
{exp.data.technologies && exp.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{exp.data.technologies.map((tech: string) => (
<span class="exp-tech inline-block px-2.5 py-0.5 text-xs rounded-full border">
{tech}
</span>
))}
</div>
)}
</div>
</div>
);
})}
</div>
<CareerTimeline locale="en" />
</section>
</Layout>

View file

@ -93,6 +93,18 @@ export const translations = {
nextImage: { fr: 'Image suivante', en: 'Next image', ar: 'الصورة التالية' },
goToImage: { fr: "Aller à l'image", en: 'Go to image', ar: 'انتقل إلى الصورة' },
},
career: {
present: { fr: 'Présent', en: 'Present', ar: 'الحاضر' },
logoAlt: { fr: 'Logo', en: 'Logo', ar: 'شعار' },
employment: { fr: 'Emploi', en: 'Employment', ar: 'وظيفة' },
freelance: { fr: 'Freelance', en: 'Freelance', ar: 'مستقل' },
teaching: { fr: 'Enseignement', en: 'Teaching', ar: 'تدريس' },
community: { fr: 'Communauté', en: 'Community', ar: 'مجتمع' },
entrepreneurship: { fr: 'Entrepreneuriat', en: 'Entrepreneurship', ar: 'ريادة أعمال' },
durationYear: { fr: 'an', en: 'yr', ar: 'سنة' },
durationYears: { fr: 'ans', en: 'yrs', ar: 'سنة' },
durationMonth: { fr: 'mois', en: 'mo', ar: 'أشهر' },
},
projects: {
visitSite: { fr: 'Voir le site', en: 'Visit site', ar: 'زيارة الموقع' },
viewOnGithub: { fr: 'Voir sur GitHub', en: 'View on GitHub', ar: 'عرض على GitHub' },