Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 37 additions & 89 deletions apps/blog/src/components/PostCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,102 +12,50 @@ interface Props {
locale?: string
readingTime: number
author?: Author
featured?: boolean
class?: string
}

const { title, description, date, tags, cover, href, locale, readingTime, author, class: className } = Astro.props
const { title, description, date, tags, cover, href, locale, author, featured = false, class: className } = Astro.props
const category = tags[0]
---

<article class:list={['post-card group', className]}>
<a href={href} class="post-card-link block rounded-xl overflow-hidden relative border border-transparent hover:border-primary/20 transition-colors duration-400">
<div class="pointer-events-none absolute inset-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-linear-to-b from-primary/20 via-primary/10 to-transparent" />
<div class="post-card-inner transition-transform duration-300 ease-out">
<!-- Image -->
<div class="post-card-img rounded-t-xl bg-muted aspect-16/10 shrink-0 rounded-xl">
{cover ? (
<img src={cover} alt={title} class="w-full h-full rounded-xl object-cover" />
) : (
<div class="w-full h-full rounded-xl bg-linear-to-br from-primary/20 to-primary/5" />
)}
</div>
<!-- Body -->
<div class="post-card-body relative z-30 p-3 flex flex-col">
<div class="flex flex-wrap items-center gap-2 mb-2">
{tags.slice(0, 3).map((tag) => (
<span class="rounded border border-primary/30 bg-primary/10 text-primary group-hover:border-border group-hover:bg-background group-hover:text-foreground px-2 py-px text-[9px] font-semibold uppercase tracking-wider transition-colors duration-300">
{tag}
</span>
))}

<article class:list={['post-card group flex flex-col', featured && 'post-card-featured', className]}>
<a href={href} class="post-card-link flex flex-col h-full border border-border hover:border-primary/40 transition-colors duration-300">
<!-- Image -->
<div class:list={['bg-muted shrink-0 overflow-hidden', featured ? 'aspect-16/10 sm:aspect-21/9' : 'aspect-16/10']}>
{cover ? (
<img src={cover} alt={title} class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" />
) : (
<div class="w-full h-full bg-linear-to-br from-primary/20 to-primary/5" />
)}
</div>
<!-- Body -->
<div class="flex flex-col flex-1 p-5 sm:p-6">
{category && (
<span class="self-start border border-primary/30 bg-primary/10 text-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider mb-3">
{category}
</span>
)}
<h2 class:list={['font-bold leading-snug', featured ? 'text-xl sm:text-2xl' : 'text-lg']}>
{title}
</h2>
{description && <p class="text-sm text-muted-foreground mt-2 line-clamp-2 flex-1">{description}</p>}

<div class="flex items-center justify-between gap-3 mt-5 pt-4 border-t border-dashed border-border text-xs">
<div class="flex items-center gap-2 text-muted-foreground min-w-0">
<time datetime={date.toISOString()} class="whitespace-nowrap">{formatDate(date, locale)}</time>
{author && (
<>
<span aria-hidden="true">&middot;</span>
<span class="truncate">{author.name}</span>
</>
)}
</div>
<time datetime={date.toISOString()} class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{formatDate(date, locale)}
</time>
<h2 class="font-bold text-lg leading-snug mt-1.5 line-clamp-2">
{title}
</h2>
{description && <p class="post-card-desc text-sm text-muted-foreground mt-3 line-clamp-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">{description}</p>}
{author && (
<div class="post-card-author flex items-center gap-3 mt-auto pt-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-150">
<img src={author.avatar} alt={author.name} class="size-9 rounded-full object-cover" />
<div>
<p class="text-sm font-semibold leading-tight">{author.name}</p>
<p class="text-xs text-muted-foreground">{author.title}</p>
</div>
</div>
)}
<svg class="size-4 text-muted-foreground shrink-0 transition-transform duration-300 group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
</div>
</div>
</a>
</article>

<style>
.post-card:hover .post-card-inner {
transform: translateY(var(--slide));
}
</style>

<script>
function initPostCards() {
const cards = document.querySelectorAll('.post-card')
const measurements: { card: HTMLElement; link: HTMLElement; body: HTMLElement; imgHeight: number; bodyNoHidden: number }[] = []

// First pass: reset and measure
cards.forEach((card) => {
const link = card.querySelector('.post-card-link') as HTMLElement | null
const img = card.querySelector('.post-card-img') as HTMLElement | null
const body = card.querySelector('.post-card-body') as HTMLElement | null
if (!link || !img || !body) return

link.style.height = ''
body.style.minHeight = ''

const imgHeight = img.offsetHeight
const desc = card.querySelector('.post-card-desc') as HTMLElement | null
const author = card.querySelector('.post-card-author') as HTMLElement | null

if (desc) desc.style.display = 'none'
if (author) author.style.display = 'none'
const bodyNoHidden = body.offsetHeight
if (desc) desc.style.display = ''
if (author) author.style.display = ''

measurements.push({ card: card as HTMLElement, link, body, imgHeight, bodyNoHidden })
})

// Find max card height
const maxCardHeight = Math.max(...measurements.map((m) => m.imgHeight + m.bodyNoHidden))

// Second pass: apply uniform height
measurements.forEach(({ card, link, body, imgHeight }) => {
link.style.height = `${maxCardHeight}px`
body.style.minHeight = `${maxCardHeight}px`
card.style.setProperty('--slide', `-${imgHeight}px`)
})
}

initPostCards()
window.addEventListener('resize', initPostCards)
document.addEventListener('astro:after-swap', initPostCards)
document.addEventListener('tags:filter', initPostCards)
</script>
65 changes: 31 additions & 34 deletions apps/blog/src/components/TagFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,11 @@ import { useTranslations } from '../i18n/utils';

interface TagFilterProps {
tags: { name: string; count: number }[]
totalCount: number
initialTags?: string[]
locale?: string
}

function TagIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</svg>
)
}

function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
Expand All @@ -41,7 +33,7 @@ function writeURL(selectedTags: string[], query: string) {
window.history.replaceState(null, '', url)
}

export function TagFilter({ tags, initialTags = [], locale = 'en' }: TagFilterProps) {
export function TagFilter({ tags, totalCount, initialTags = [], locale = 'en' }: TagFilterProps) {
const t = useTranslations(locale)
const [selectedTags, setSelectedTags] = useState<string[]>(initialTags)
const [query, setQuery] = useState<string>('')
Expand Down Expand Up @@ -88,38 +80,43 @@ export function TagFilter({ tags, initialTags = [], locale = 'en' }: TagFilterPr

return (
<>
<div className="mb-10 space-y-4">
<div className="relative">
<div className="mb-10 pb-6 border-b border-border flex flex-col lg:flex-row lg:items-center gap-4 lg:gap-6">
<div className="flex items-center gap-3 flex-wrap min-w-0">
<span className="text-sm text-muted-foreground shrink-0">{t('index.filterLabel')}</span>
<span className="text-sm text-muted-foreground/60 shrink-0">{totalCount} {t('index.articleCount')}</span>
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide">
{tags.map((tag) => {
const active = selectedTags.includes(tag.name)
return (
<button
key={tag.name}
type="button"
role="tab"
aria-selected={active}
onClick={() => toggleTag(tag.name)}
className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-wide shrink-0 transition-colors cursor-pointer ${
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground'
}`}
>
{tag.name}
</button>
)
})}
</div>
</div>

<div className="relative lg:ml-auto lg:shrink-0">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('tagFilter.placeholder')}
className="w-full rounded-md border border-border bg-transparent pl-10 pr-4 py-2 max-w-sm text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary/50"
className="w-full lg:w-64 rounded-md border border-border bg-transparent pl-10 pr-4 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary/50"
/>
</div>

<div className="flex items-center gap-3 overflow-x-auto scrollbar-hide">
{tags.map((tag) => {
const active = selectedTags.includes(tag.name)
return (
<button
key={tag.name}
type="button"
onClick={() => toggleTag(tag.name)}
className={`flex items-center gap-1.5 rounded-md border px-4 py-1.5 text-sm shrink-0 transition-colors cursor-pointer border-dashed ${
active
? 'border-primary/50 bg-primary/5 text-primary font-medium'
: 'border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground'
}`}
>
<TagIcon className="size-3.5" />
{tag.name}
</button>
)
})}
</div>
</div>

{hasActiveFilter && visibleCount === 0 && (
Expand Down
14 changes: 10 additions & 4 deletions apps/blog/src/i18n/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ export const ui = {
en: {
// Index page
'index.title': 'Articles',
'index.heading.prefix': 'Latest',
'index.heading.highlight': 'articles',
'index.label': 'Blog',
'index.heading.prefix': 'Latest updates from',
'index.heading.highlight': 'FerrisKey.',
'index.empty': 'No articles yet.',
'index.noResults': 'No articles match your search.',
'index.filterLabel': 'Filter by category',
'index.articleCount': 'articles',

// RSS
'rss.title': 'Explainer Blog',
Expand Down Expand Up @@ -49,10 +52,13 @@ export const ui = {
fr: {
// Index page
'index.title': 'Articles',
'index.heading.prefix': 'Derniers',
'index.heading.highlight': 'articles',
'index.label': 'Blog',
'index.heading.prefix': 'Les dernières nouvelles de',
'index.heading.highlight': 'FerrisKey.',
'index.empty': 'Aucun article pour le moment.',
'index.noResults': 'Aucun article ne correspond à votre recherche.',
'index.filterLabel': 'Filtrer par catégorie',
'index.articleCount': 'articles',

// RSS
'rss.title': 'Blog Explainer',
Expand Down
74 changes: 56 additions & 18 deletions apps/blog/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getCollection } from 'astro:content'
import Base from '../layouts/base.astro'
import { TagFilter } from '../components/TagFilter'
import PostCard from '../components/PostCard.astro'
import { getPublishedPosts, getPostHref, getPostLocale, getLocales, getAllTags, getReadingTime } from '../lib/posts'
import { getPublishedPosts, getPostHref, getPostLocale, getPostsByLocale, getLocales, getAllTags, getReadingTime, getFeaturedPosts, getNonFeaturedPosts } from '../lib/posts'
import { getAuthor } from '../lib/authors'
import { defaultLang } from '../i18n/ui'
import { useTranslations } from '../i18n/utils'
Expand All @@ -14,35 +14,73 @@ const posts = getPublishedPosts(allPosts)
const tags = getAllTags(posts)
const locale = defaultLang
const t = useTranslations(locale)

// Split into featured/rest per locale, not across the mixed-locale array —
// otherwise an EN and FR copy of the same post can both land in the 2
// featured slots, and hiding the wrong-locale one leaves an empty grid gap.
const postsByLocale = locales.map((loc) => {
const localePosts = getPostsByLocale(posts, loc)
return {
loc,
featured: getFeaturedPosts(localePosts, 2),
rest: getNonFeaturedPosts(localePosts, 2),
}
})

function cardProps(post: (typeof posts)[number]) {
return {
title: post.data.title,
description: post.data.short_description ?? post.data.description,
date: post.data.date,
tags: post.data.tags,
cover: post.data.cover ?? `${getPostHref(post)}/thumbnail.png`,
href: getPostHref(post),
locale,
readingTime: getReadingTime(post.body ?? ''),
author: post.data.author ? getAuthor(post.data.author) : undefined,
}
}
---

<Base title={t('index.title')} locale={locale} locales={locales}>
<!-- Page header -->
<section class="mb-8">
<h1 class="text-4xl md:text-5xl font-bold tracking-tight">
<section class="mb-10">
<p class="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
<span class="inline-block size-1.5 rounded-full bg-primary"></span>
{t('index.label')}
</p>
<h1 class="text-4xl md:text-6xl font-bold tracking-tight text-balance">
{t('index.heading.prefix')} <span class="text-primary">{t('index.heading.highlight')}</span>
</h1>
</section>

<!-- Tag filter -->
<TagFilter tags={tags} locale={locale} client:load />
<TagFilter tags={tags} totalCount={posts.length} locale={locale} client:load />

<!-- Articles grid -->
{posts.length > 0 && (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<div data-tags={JSON.stringify(post.data.tags)} data-title={post.data.title} data-description={post.data.description ?? ''} data-locale={getPostLocale(post)}>
<PostCard
title={post.data.title}
description={post.data.short_description ?? post.data.description}
date={post.data.date}
tags={post.data.tags}
cover={post.data.cover ?? `${getPostHref(post)}/thumbnail.png`}
href={getPostHref(post)}
locale={locale}
readingTime={getReadingTime(post.body ?? '')}
author={post.data.author ? getAuthor(post.data.author) : undefined}
/>
<div class="space-y-8">
{postsByLocale.map(({ loc, featured, rest }) => (
<div data-locale={loc} class="space-y-8">
{featured.length > 0 && (
<div class:list={['grid grid-cols-1 gap-6', featured.length > 1 && 'lg:grid-cols-2']}>
{featured.map((post) => (
<div data-tags={JSON.stringify(post.data.tags)} data-title={post.data.title} data-description={post.data.description ?? ''} data-locale={getPostLocale(post)}>
<PostCard {...cardProps(post)} featured />
</div>
))}
</div>
)}

{rest.length > 0 && (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{rest.map((post) => (
<div data-tags={JSON.stringify(post.data.tags)} data-title={post.data.title} data-description={post.data.description ?? ''} data-locale={getPostLocale(post)}>
<PostCard {...cardProps(post)} />
</div>
))}
</div>
)}
</div>
))}
</div>
Expand Down
Loading
Loading