Talk React Academy Conf : redux-saga vs redux-observable avec Moïse Fiscal (FR/EN/AR)

Collection talks avec copresenters dans le schema, dateFormatted calculé, vidéo YouTube embeddée.
Composants TalkCard et TalksSection extraits pour éviter la duplication entre les 3 pages code/index.
This commit is contained in:
Jalil Arfaoui 2026-03-16 18:08:30 +01:00
parent d97c92a20b
commit 6f749f0790
9 changed files with 151 additions and 7 deletions

View file

@ -0,0 +1,68 @@
---
import type { Locale } from '../../utils/i18n';
interface Props {
title: string;
description: string;
event?: string;
dateFormatted?: string;
location?: string;
video?: string;
tags?: string[];
copresenters?: { name: string; url: string }[];
lang?: Locale;
}
const { title, description, event, dateFormatted, location, video, tags, copresenters, lang = 'fr' } = Astro.props;
const copresenterLabel = { fr: 'Co-présenté avec', en: 'Co-presented with', ar: 'محاضرة مشتركة مع' }[lang];
function getYouTubeId(url: string): string | null {
const match = url.match(/(?:youtu\.be\/|youtube\.com\/watch\?v=)([^&]+)/);
return match?.[1] ?? null;
}
const videoId = video ? getYouTubeId(video) : null;
---
<div class="rounded-2xl border bg-white/[0.05] border-white/[0.08] p-6">
{(event || dateFormatted || location) && (
<div class="mb-2">
<span class="text-sm text-purple-200/70">{[event, dateFormatted, location].filter(Boolean).join(' · ')}</span>
</div>
)}
<h3 class="text-xl font-bold text-white mb-3">{title}</h3>
<p class="text-white/60 leading-relaxed mb-3">{description}</p>
{copresenters && copresenters.length > 0 && (
<p class="text-sm text-white/50 mb-5">
{copresenterLabel}{' '}
{copresenters.map((cp, i) => (
<>
{i > 0 && ', '}
<a href={cp.url} target="_blank" rel="noopener noreferrer" class="text-purple-200 hover:text-white transition-colors">{cp.name}</a>
</>
))}
</p>
)}
{tags && tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-5">
{tags.map((tag) => (
<span class="inline-block px-2.5 py-1 text-xs rounded-full bg-purple-400/15 text-purple-200 border border-purple-300/15">
{tag}
</span>
))}
</div>
)}
{videoId && (
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
class="w-full aspect-video rounded-xl"
/>
)}
</div>

View file

@ -0,0 +1,38 @@
---
import { getLocalizedCollection } from "../../utils/content-i18n";
import TalkCard from "./TalkCard.astro";
import type { Locale } from "../../utils/i18n";
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const talks = (await getLocalizedCollection("talks", locale))
.filter((t) => !t.data.draft)
.sort((a, b) => b.data.date!.getTime() - a.data.date!.getTime());
const sectionTitle = { fr: 'Talks', en: 'Talks', ar: 'محاضرات' }[locale];
---
{talks.length > 0 && (
<div class="mb-20 -mx-7 px-7 py-8 bg-white/[0.03] rounded-2xl">
<h2 class="text-2xl font-bold text-white mb-6">{sectionTitle}</h2>
<div class="grid grid-cols-1 gap-5">
{talks.map((talk) => (
<TalkCard
title={talk.data.title}
description={talk.data.description}
event={talk.data.event}
dateFormatted={talk.data.dateFormatted}
location={talk.data.location}
video={talk.data.video}
tags={talk.data.tags}
copresenters={talk.data.copresenters}
lang={locale}
/>
))}
</div>
</div>
)}

View file

@ -87,22 +87,28 @@ const recommendationsCollection = defineCollection({
});
const talksCollection = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/talks" }),
loader: glob({ pattern: "**/*.md", base: "./src/content/talks", ...stripExtension('md') }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.date(),
dateFormatted: z.string(),
event: z.string(),
location: z.string(),
date: z.date().optional(),
event: z.string().optional(),
location: z.string().optional(),
copresenters: z.array(z.object({
name: z.string(),
url: z.string().url(),
})).optional(),
slides: z.string().url().optional(),
video: z.string().url().optional(),
image: z.string().optional(),
imageAlt: z.string().optional(),
tags: z.array(z.string()).optional(),
draft: z.boolean().default(false),
draft: z.boolean().optional(),
lang: z.enum(['fr', 'en', 'ar']).default('fr'),
}),
}).transform((data) => ({
...data,
dateFormatted: data.date ? formatDate(data.date, data.lang) : undefined,
})),
});
const photoBlogPostsCollection = defineCollection({

View file

@ -0,0 +1,5 @@
---
title: "Redux-saga مقابل Redux-observable"
description: "مقارنة بين طريقتين لإدارة العمليات غير المتزامنة مع Redux: redux-saga و redux-observable."
lang: ar
---

View file

@ -0,0 +1,5 @@
---
title: "Redux-saga vs Redux-observable"
description: "Comparison of two approaches to handling asynchronous operations with Redux: redux-saga and redux-observable."
lang: en
---

View file

@ -0,0 +1,13 @@
---
title: "Redux-saga vs Redux-observable"
description: "Comparaison de deux approches pour gérer l'asynchronisme avec Redux : redux-saga et redux-observable."
date: 2018-11-01
event: "React Academy Conf"
location: "Paris, École 42"
video: "https://www.youtube.com/watch?v=u9wULmOQ7sU"
copresenters:
- name: "Moïse Fiscal"
url: "https://www.linkedin.com/in/mo%C3%AFse-fiscal-35634622/"
tags: ["Redux", "RxJS", "redux-observable", "redux-saga", "React"]
lang: fr
---

View file

@ -12,6 +12,7 @@ import MaltIcon from "../../../components/icons/MaltIcon.astro";
import StackOverflowIcon from "../../../components/icons/StackOverflowIcon.astro";
import GitHubIcon from "../../../components/icons/GitHubIcon.astro";
import ForgejoIcon from "../../../components/icons/ForgejoIcon.astro";
import TalksSection from "../../../components/code/TalksSection.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png";
@ -175,6 +176,8 @@ function formatMonth(dateStr: string) {
</div>
)}
<TalksSection locale={locale} />
<div class="mb-20">
<h2 class="text-2xl font-bold text-white mb-6">القيم والمنهج</h2>
<ul class="space-y-4 text-white/80">

View file

@ -12,6 +12,7 @@ import MaltIcon from "../../components/icons/MaltIcon.astro";
import StackOverflowIcon from "../../components/icons/StackOverflowIcon.astro";
import GitHubIcon from "../../components/icons/GitHubIcon.astro";
import ForgejoIcon from "../../components/icons/ForgejoIcon.astro";
import TalksSection from "../../components/code/TalksSection.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../utils/i18n";
import logoTiqa from "../../assets/images/logo-tiqa-blanc.png";
@ -175,6 +176,8 @@ function formatMonth(dateStr: string) {
</div>
)}
<TalksSection locale={locale} />
<div class="mb-20">
<h2 class="text-2xl font-bold text-white mb-6">Valeurs & Approche</h2>
<ul class="space-y-4 text-white/80">

View file

@ -12,6 +12,7 @@ import MaltIcon from "../../../components/icons/MaltIcon.astro";
import StackOverflowIcon from "../../../components/icons/StackOverflowIcon.astro";
import GitHubIcon from "../../../components/icons/GitHubIcon.astro";
import ForgejoIcon from "../../../components/icons/ForgejoIcon.astro";
import TalksSection from "../../../components/code/TalksSection.astro";
import { getProjectBaseSlug, getProjectsBasePath } from "../../../utils/i18n";
import logoTiqa from "../../../assets/images/logo-tiqa-blanc.png";
@ -175,6 +176,8 @@ function formatMonth(dateStr: string) {
</div>
)}
<TalksSection locale={locale} />
<div class="mb-20">
<h2 class="text-2xl font-bold text-white mb-6">Values & Approach</h2>
<ul class="space-y-4 text-white/80">