From 815cfbf9288887352069f1ab991801864aa910f6 Mon Sep 17 00:00:00 2001 From: Legrand Thomas Date: Wed, 1 Jul 2026 11:26:37 +0200 Subject: [PATCH 1/6] feat: filter pills on light background --- .gitignore | 2 + frontend/src/app/[locale]/blog/blog.tsx | 313 +++++++++++++++--- frontend/src/app/[locale]/blog/page.tsx | 19 +- frontend/src/app/[locale]/globals.css | 4 + .../src/components/atoms/Filter/Filter.tsx | 23 +- frontend/tailwind.config.ts | 2 +- 6 files changed, 298 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 19008f347..c2674aa9d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .DS_Store .pi/extensions/emdash-hook.ts .factory/settings.json +.claude/settings.local.json +.factory/hooks.json diff --git a/frontend/src/app/[locale]/blog/blog.tsx b/frontend/src/app/[locale]/blog/blog.tsx index 0d2d31b53..6de44c724 100644 --- a/frontend/src/app/[locale]/blog/blog.tsx +++ b/frontend/src/app/[locale]/blog/blog.tsx @@ -2,62 +2,224 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { Title, BaseCardsBlock, Pagination, SearchInput } from '@/components'; +import { Title, BaseCardsBlock, Pagination, SearchInput, Filter, Button } from '@/components'; import { BlogsPageMeta, BlogsPageData } from './page'; -import { usePagination } from '@/hooks/usePagination'; -import { useState, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { getPressReleaseLink } from '@/lib/utils'; +import clsx from 'clsx'; -function transformBlogsData(resources: NonNullable) { +function getTagsAsStrings(tags: unknown): string[] { + if (!tags) return []; + if (!Array.isArray(tags)) return []; + return tags.map((tag: unknown) => { + if (typeof tag === 'string') return tag; + if (tag && typeof tag === 'object' && 'name' in (tag as object)) return (tag as { name: string }).name; + if (tag && typeof tag === 'object' && 'tag' in (tag as object)) return (tag as { tag: string }).tag; + return String(tag); + }); +} + +function determineType(element: Record | null, tagStrings: string[]): string { + // Check tags first + const types = ['podcast', 'article', 'rapport', 'prise de position', 'autre']; + for (const t of tagStrings) { + const lower = t.toLowerCase(); + if (types.includes(lower)) return lower; + } + // Check media_name (it sometimes contains the type, e.g. "Rapport") + if (element?.media_name) { + const lower = element.media_name.toLowerCase(); + if (types.includes(lower)) return lower; + } + // Check title for clues + if (element?.title) { + const lower = element.title.toLowerCase(); + if (lower.startsWith('podcast') || lower.startsWith('vidéo') || lower.startsWith('video')) return 'article'; + if (lower.startsWith('rapport')) return 'rapport'; + if (lower.startsWith('communiqué')) return 'article'; + } + // Default + return 'autre'; +} + +function determineNewsletter(element: Record | null, tagStrings: string[]): string | null { + const newsletters = ['Techno-lucides', "Vers l'autonomie et au-delà"]; + // Check tags + for (const n of newsletters) { + if (tagStrings.includes(n)) return n; + } + // Check media_name (e.g. "Newsletter - Technolucide", "Newsletter - Vers l'autonomie et au-delà") + if (element?.media_name) { + if (element.media_name === 'Newsletter - Technolucide') return 'Techno-lucides'; + if (element.media_name === "Newsletter - Vers l'autonomie et au-delà") return "Vers l'autonomie et au-delà"; + } + // Check title + if (element?.title) { + for (const n of newsletters) { + if (element.title.includes(n)) return n; + } + if (element.title.startsWith('Techno-Lucide')) return 'Techno-lucides'; + if (element.title.startsWith("Vers l'autonomie")) return "Vers l'autonomie et au-delà"; + } + return null; +} + +interface TransformedResource { + id: number | undefined; + title: string; + rawDate: string; + date: string; + image: string; + link: string | undefined; + subInfos: string[]; + tags: string[]; + isBlank: boolean; + year: number; + type: string; + newsletter: string | null; +} + +function transformBlogsData(resources: NonNullable): TransformedResource[] { return resources.map(resource => { const isBlog = !!resource.blog; - const element = isBlog ? resource.blog : resource.press_release; + const element = (isBlog ? resource.blog : resource.press_release) as Record | null; + + const tagStrings = getTagsAsStrings(element?.tags); - return ({ + const publishedDate = element?.published_date ?? ''; + const parsedDate = new Date(publishedDate); + + return { id: resource.id, - title: element.title, - rawDate: resource.published_date, - date: new Date(resource.published_date).toLocaleString(undefined, {dateStyle: 'medium'}), - image: element.thumbnail?.url ?? "/images/dataforgood.svg", - link: isBlog ? `/blog/${element.slug}` : getPressReleaseLink(element as { press_release_type: string; article_link: string; article_file: {url: string;}}), - subInfos: element.tags ? element.tags.map(tag => tag.name) : [], - tags: [new Date(element.published_date).toLocaleDateString(undefined, {dateStyle: 'long'}), element.media_name], + title: element?.title ?? '', + rawDate: publishedDate, + date: parsedDate instanceof Date && !isNaN(parsedDate.getTime()) + ? parsedDate.toLocaleString(undefined, {dateStyle: 'medium'}) + : '', + image: element?.thumbnail?.url ?? '/images/dataforgood.svg', + link: isBlog ? `/blog/${element?.slug ?? ''}` : getPressReleaseLink(element as any), + subInfos: tagStrings, + tags: [ + parsedDate instanceof Date && !isNaN(parsedDate.getTime()) + ? parsedDate.toLocaleDateString(undefined, {dateStyle: 'long'}) + : null, + element?.media_name, + ].filter(Boolean), isBlank: true, - }) - }) + year: parsedDate instanceof Date && !isNaN(parsedDate.getTime()) ? parsedDate.getFullYear() : 0, + type: determineType(element, tagStrings), + newsletter: determineNewsletter(element, tagStrings), + }; + }); } type BlogsPageProps = { data: BlogsPageData pagination: BlogsPageMeta - currentPage: number } -export default function BlogPage({data, pagination, currentPage}: BlogsPageProps) { +export default function BlogPage({data, pagination: _pagination}: BlogsPageProps) { const t = useTranslations('blog'); - const resources = transformBlogsData(data) - const [filteredResources, setFilteredResources] = useState(resources); + const allResources = useMemo(() => transformBlogsData(data), [data]); + const [searchQuery, setSearchQuery] = useState(''); + const [activeYears, setActiveYears] = useState([]); + const [activeTypes, setActiveTypes] = useState([]); + const [activeNewsletters, setActiveNewsletters] = useState([]); + const [hideFilters, setHideFilters] = useState(true); + const [currentPage, setCurrentPage] = useState(1); - const handleInputChange = (event: React.ChangeEvent) => { - setSearchQuery(event.value); - }; + const pageSize = 12; - const handleSearch = (query: string) => { - const filtered = resources.filter(resource => { - const title = resource.title.toLowerCase(); - const queryLower = query.toLowerCase(); - return title.includes(queryLower); + const filteredResources = useMemo(() => { + return allResources.filter(resource => { + if (searchQuery && !resource.title.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + if (activeYears.length > 0 && !activeYears.some(y => { + if (resource.year === 0) return false; + if (y === '2020') return resource.year <= 2020; + return resource.year === parseInt(y); + })) { + return false; + } + if (activeTypes.length > 0 && !activeTypes.includes(resource.type)) { + return false; + } + if (activeNewsletters.length > 0) { + if (!resource.newsletter || !activeNewsletters.includes(resource.newsletter)) { + return false; + } + } + return true; }); - setFilteredResources(filtered); + }, [allResources, searchQuery, activeYears, activeTypes, activeNewsletters]); + + const displayResources = useMemo(() => { + const start = (currentPage - 1) * pageSize; + return filteredResources.slice(start, start + pageSize); + }, [filteredResources, currentPage]); + + const pageCount = Math.ceil(filteredResources.length / pageSize); + + const handleSearchChange = (e: any) => { + if (searchQuery !== e.value) { + setSearchQuery(e.value); + setCurrentPage(1); + } + }; + + const handleFilterClick = (e: any) => { + const filterValue = e.value as string; + const filterType = e.getAttribute('data-type'); + + if (filterType === 'year') { + setActiveYears(prev => + prev.includes(filterValue) + ? prev.filter(v => v !== filterValue) + : [...prev, filterValue] + ); + } else if (filterType === 'type') { + setActiveTypes(prev => + prev.includes(filterValue) + ? prev.filter(v => v !== filterValue) + : [...prev, filterValue] + ); + } else if (filterType === 'newsletter') { + setActiveNewsletters(prev => + prev.includes(filterValue) + ? prev.filter(v => v !== filterValue) + : [...prev, filterValue] + ); + } + setCurrentPage(1); }; - useEffect(() => { - handleSearch(searchQuery); - }, [searchQuery]); + const yearFilters = [ + { filterName: '2020 et avant', filterValue: '2020' }, + { filterName: '2021', filterValue: '2021' }, + { filterName: '2022', filterValue: '2022' }, + { filterName: '2023', filterValue: '2023' }, + { filterName: '2024', filterValue: '2024' }, + { filterName: '2025', filterValue: '2025' }, + { filterName: '2026', filterValue: '2026' }, + ]; - const { handlePageChange } = usePagination(currentPage); + const typeFilters = [ + { filterName: 'Podcast', filterValue: 'podcast' }, + { filterName: 'Article', filterValue: 'article' }, + { filterName: 'Rapport', filterValue: 'rapport' }, + { filterName: 'Prise de position', filterValue: 'prise de position' }, + { filterName: 'Autre', filterValue: 'autre' }, + ]; + + const newsletterFilters = [ + { filterName: 'Techno-lucides', filterValue: 'Techno-lucides' }, + { filterName: "Vers l'autonomie et au-delà", filterValue: "Vers l'autonomie et au-delà" }, + ]; + + const allActiveFilterValues = [...activeYears, ...activeTypes, ...activeNewsletters]; return (
@@ -65,24 +227,79 @@ export default function BlogPage({data, pagination, currentPage}: BlogsPageProps {t('title')} - +
+ + +
+ {yearFilters.map((filter, index) => ( + + ))} + {typeFilters.map((filter, index) => ( + + ))} + {newsletterFilters.map((filter, index) => ( + + ))} +
+
+ +
+ +
+
+ + - - - - + + ); } diff --git a/frontend/src/app/[locale]/blog/page.tsx b/frontend/src/app/[locale]/blog/page.tsx index fe38b9db7..d91b1e2cf 100644 --- a/frontend/src/app/[locale]/blog/page.tsx +++ b/frontend/src/app/[locale]/blog/page.tsx @@ -27,12 +27,11 @@ export async function generateMetadata({ return generateMetadataFromSeo(data.data.seo_meta); } -async function fetchBlogsPageData(page: number, pageSize: number) { +async function fetchBlogsPageData() { return await client.GET('/resources', { params: { query: { - "pagination[page]": page, - "pagination[pageSize]": pageSize, + "pagination[pageSize]": 200, "sort": "createdAt:desc", populate: { blog: { @@ -51,18 +50,12 @@ export type BlogsPageResponse = NonNullable; export type BlogsPageMeta = NonNullable["pagination"]>; -export default async function Page({ - searchParams, -}: { - searchParams: { [key: string]: string | string[] | undefined }; -}) { - const page = typeof searchParams.page === 'string' ? Number(searchParams.page) : 1; - const pageSize = 12; - const response = await fetchBlogsPageData(page, pageSize); +export default async function Page() { + const response = await fetchBlogsPageData(); - if (!response?.data || !response?.data.meta?.pagination) { + if (!response?.data || !response?.data?.meta?.pagination) { return null; } - return ; + return ; }; diff --git a/frontend/src/app/[locale]/globals.css b/frontend/src/app/[locale]/globals.css index b65f76b72..1b7b6a6fd 100644 --- a/frontend/src/app/[locale]/globals.css +++ b/frontend/src/app/[locale]/globals.css @@ -220,6 +220,10 @@ /* Others */ + .filter-pill:hover .checkbox::after { + border-color: hsl(266 100% 92%); + } + .mask-papper { mask-image: url('/images/mask-papper.svg'); mask-size: cover; diff --git a/frontend/src/components/atoms/Filter/Filter.tsx b/frontend/src/components/atoms/Filter/Filter.tsx index 92c4930d2..c7ffeacad 100644 --- a/frontend/src/components/atoms/Filter/Filter.tsx +++ b/frontend/src/components/atoms/Filter/Filter.tsx @@ -11,9 +11,20 @@ export type FilterProps = HTMLAttributes & { checked: boolean; onClick: (e: any) => void; className?: string; + /** @default 'dark' */ + variant?: 'dark' | 'light'; }; - +const variantStyles: Record<'dark' | 'light', { base: string; checked: string }> = { + dark: { + base: 'text-violet-light bg-opacity-0 border-building', + checked: 'bg-building', + }, + light: { + base: 'text-building bg-white border-building', + checked: 'bg-building text-white border-building', + }, +}; const Filter = ({ filterName, @@ -23,6 +34,7 @@ const Filter = ({ checked, onClick, className = '', + variant = 'dark', ...props }: FilterProps) => { const handleClick = (e: any) => { @@ -33,15 +45,20 @@ const Filter = ({ return null; } + const vs = variantStyles[variant]; + return (