Extraction composant CareerTimeline : suppression de la duplication 3x entre pages FR/EN/AR, traductions centralisées dans i18n.ts
This commit is contained in:
parent
5b887c7926
commit
fca4608beb
5 changed files with 159 additions and 387 deletions
141
src/components/code/CareerTimeline.astro
Normal file
141
src/components/code/CareerTimeline.astro
Normal 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>
|
||||||
|
|
@ -1,56 +1,6 @@
|
||||||
---
|
---
|
||||||
import { getCollection, render } from "astro:content";
|
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import Layout from "../../../layouts/main.astro";
|
import Layout from "../../../layouts/main.astro";
|
||||||
import "../../../styles/exp-card.css";
|
import CareerTimeline from "../../../components/code/CareerTimeline.astro";
|
||||||
|
|
||||||
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} أشهر`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<Layout
|
||||||
|
|
@ -70,83 +20,6 @@ function computeDuration(startStr: string, endStr?: string) {
|
||||||
<p class="mt-3 text-white/60 text-lg">أكثر من 20 سنة من الخبرة في تطوير البرمجيات.</p>
|
<p class="mt-3 text-white/60 text-lg">أكثر من 20 سنة من الخبرة في تطوير البرمجيات.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10">
|
<CareerTimeline locale="ar" />
|
||||||
{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>
|
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,6 @@
|
||||||
---
|
---
|
||||||
import { getCollection, render } from "astro:content";
|
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import Layout from "../../layouts/main.astro";
|
import Layout from "../../layouts/main.astro";
|
||||||
import "../../styles/exp-card.css";
|
import CareerTimeline from "../../components/code/CareerTimeline.astro";
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<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>
|
<p class="mt-3 text-white/60 text-lg">Plus de 20 ans d'expérience en développement logiciel.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10">
|
<CareerTimeline locale="fr" />
|
||||||
{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>
|
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,6 @@
|
||||||
---
|
---
|
||||||
import { getCollection, render } from "astro:content";
|
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import Layout from "../../../layouts/main.astro";
|
import Layout from "../../../layouts/main.astro";
|
||||||
import "../../../styles/exp-card.css";
|
import CareerTimeline from "../../../components/code/CareerTimeline.astro";
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout
|
<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>
|
<p class="mt-3 text-white/60 text-lg">Over 20 years of experience in software development.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10">
|
<CareerTimeline locale="en" />
|
||||||
{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>
|
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,18 @@ 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: 'انتقل إلى الصورة' },
|
||||||
},
|
},
|
||||||
|
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: {
|
projects: {
|
||||||
visitSite: { fr: 'Voir le site', en: 'Visit site', ar: 'زيارة الموقع' },
|
visitSite: { fr: 'Voir le site', en: 'Visit site', ar: 'زيارة الموقع' },
|
||||||
viewOnGithub: { fr: 'Voir sur GitHub', en: 'View on GitHub', ar: 'عرض على GitHub' },
|
viewOnGithub: { fr: 'Voir sur GitHub', en: 'View on GitHub', ar: 'عرض على GitHub' },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue