Skip to content

Commit 10ffc31

Browse files
committed
feat: enhance design and blog page
1 parent 81c8b5e commit 10ffc31

31 files changed

+766
-343
lines changed

apps/blog/astro.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig({
2020
],
2121
vite: {
2222
plugins: [tailwindcss()],
23+
envDir: '../../',
2324
},
2425
markdown: {
2526
shikiConfig,

apps/blog/public/logo.svg

Lines changed: 5 additions & 0 deletions
Loading

apps/blog/src/components/PostCard.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,5 @@ const { title, description, date, tags, cover, slug, readingTime, author, class:
114114
initPostCards()
115115
window.addEventListener('resize', initPostCards)
116116
document.addEventListener('astro:after-swap', initPostCards)
117+
document.addEventListener('tags:filter', initPostCards)
117118
</script>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as React from 'react'
2+
import { cn } from '@explainer/ui'
3+
4+
interface ShareButtonsProps {
5+
url: string
6+
title: string
7+
horizontal?: boolean
8+
}
9+
10+
function LinkedInIcon() {
11+
return (
12+
<svg viewBox="0 0 24 24" fill="currentColor" className="size-4">
13+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
14+
</svg>
15+
)
16+
}
17+
18+
function TwitterIcon() {
19+
return (
20+
<svg viewBox="0 0 24 24" fill="currentColor" className="size-4">
21+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
22+
</svg>
23+
)
24+
}
25+
26+
function FacebookIcon() {
27+
return (
28+
<svg viewBox="0 0 24 24" fill="currentColor" className="size-4">
29+
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
30+
</svg>
31+
)
32+
}
33+
34+
function LinkIcon() {
35+
return (
36+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4">
37+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
38+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
39+
</svg>
40+
)
41+
}
42+
43+
function CheckIcon() {
44+
return (
45+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4">
46+
<polyline points="20 6 9 17 4 12" />
47+
</svg>
48+
)
49+
}
50+
51+
export function ShareButtons({ url, title, horizontal }: ShareButtonsProps) {
52+
const [copied, setCopied] = React.useState(false)
53+
54+
const handleCopy = async () => {
55+
await navigator.clipboard.writeText(url)
56+
setCopied(true)
57+
setTimeout(() => setCopied(false), 2000)
58+
}
59+
60+
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
61+
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
62+
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
63+
64+
const buttonClass = cn(
65+
'flex items-center justify-center rounded-full size-10 border text-muted-foreground transition-colors',
66+
'hover:bg-primary/10 hover:text-primary hover:border-primary/30',
67+
)
68+
69+
const buttons = (
70+
<>
71+
<a href={linkedinUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on LinkedIn">
72+
<LinkedInIcon />
73+
</a>
74+
<a href={twitterUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on Twitter">
75+
<TwitterIcon />
76+
</a>
77+
<a href={facebookUrl} target="_blank" rel="noopener noreferrer" className={buttonClass} aria-label="Share on Facebook">
78+
<FacebookIcon />
79+
</a>
80+
<button onClick={handleCopy} className={buttonClass} aria-label="Copy link">
81+
{copied ? <CheckIcon /> : <LinkIcon />}
82+
</button>
83+
</>
84+
)
85+
86+
if (horizontal) {
87+
return <div className="flex gap-3 justify-center">{buttons}</div>
88+
}
89+
90+
return (
91+
<div className="sticky top-24 flex flex-col gap-3">
92+
{buttons}
93+
</div>
94+
)
95+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as React from 'react'
2+
import { cn } from '@explainer/ui'
3+
4+
export interface TocHeading {
5+
depth: number
6+
slug: string
7+
text: string
8+
}
9+
10+
interface TableOfContentsProps {
11+
headings: TocHeading[]
12+
}
13+
14+
export function TableOfContents({ headings }: TableOfContentsProps) {
15+
const filtered = headings.filter((h) => h.depth >= 2 && h.depth <= 3)
16+
const [activeId, setActiveId] = React.useState<string>('')
17+
18+
React.useEffect(() => {
19+
const elements = filtered.map((h) => document.getElementById(h.slug)).filter(Boolean) as HTMLElement[]
20+
if (elements.length === 0) return
21+
22+
const observer = new IntersectionObserver(
23+
(entries) => {
24+
for (const entry of entries) {
25+
if (entry.isIntersecting) {
26+
setActiveId(entry.target.id)
27+
break
28+
}
29+
}
30+
},
31+
{ rootMargin: '-80px 0px -75% 0px' },
32+
)
33+
34+
for (const el of elements) {
35+
observer.observe(el)
36+
}
37+
38+
return () => observer.disconnect()
39+
}, [filtered.map((h) => h.slug).join(',')])
40+
41+
if (filtered.length === 0) return null
42+
43+
return (
44+
<nav className="sticky top-24 h-fit">
45+
<p className="text-sm font-medium mb-3">On this page</p>
46+
<ul className="border-l border-border space-y-0.5">
47+
{filtered.map((heading) => (
48+
<li key={heading.slug}>
49+
<a
50+
href={`#${heading.slug}`}
51+
className={cn(
52+
'block -ml-px border-l-2 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:text-foreground',
53+
heading.depth === 3 ? 'pl-6' : 'pl-3',
54+
activeId === heading.slug
55+
? 'border-primary text-primary font-medium'
56+
: 'border-transparent text-muted-foreground hover:text-foreground',
57+
)}
58+
>
59+
{heading.text}
60+
</a>
61+
</li>
62+
))}
63+
</ul>
64+
</nav>
65+
)
66+
}

apps/blog/src/components/TagFilter.astro

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useEffect, useState } from 'react';
2+
3+
interface TagFilterProps {
4+
tags: { name: string; count: number }[]
5+
initialTags?: string[]
6+
}
7+
8+
function TagIcon({ className }: { className?: string }) {
9+
return (
10+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
11+
<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" />
12+
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
13+
</svg>
14+
)
15+
}
16+
17+
function SearchIcon({ className }: { className?: string }) {
18+
return (
19+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
20+
<circle cx="11" cy="11" r="8" />
21+
<path d="m21 21-4.3-4.3" />
22+
</svg>
23+
)
24+
}
25+
26+
function readURL() {
27+
const params = new URLSearchParams(window.location.search)
28+
const tags = params.get('tags')?.split(',').filter(Boolean) ?? []
29+
const q = params.get('q') ?? ''
30+
return { tags, q }
31+
}
32+
33+
function writeURL(selectedTags: string[], query: string) {
34+
const params = new URLSearchParams()
35+
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','))
36+
if (query) params.set('q', query)
37+
const search = params.toString()
38+
const url = `${window.location.pathname}${search ? `?${search}` : ''}`
39+
window.history.replaceState(null, '', url)
40+
}
41+
42+
export function TagFilter({ tags, initialTags = [] }: TagFilterProps) {
43+
const [selectedTags, setSelectedTags] = useState<string[]>(initialTags)
44+
const [query, setQuery] = useState<string>('')
45+
46+
useEffect(() => {
47+
const { tags: urlTags, q } = readURL()
48+
if (urlTags.length > 0) setSelectedTags(urlTags)
49+
if (q) setQuery(q)
50+
}, [])
51+
52+
function toggleTag(tag: string) {
53+
setSelectedTags((prev) =>
54+
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
55+
)
56+
}
57+
58+
useEffect(() => {
59+
const cards = document.querySelectorAll<HTMLElement>('[data-tags]')
60+
const normalizedQuery = query.toLowerCase().trim()
61+
62+
cards.forEach((card) => {
63+
const cardTags: string[] = JSON.parse(card.dataset.tags ?? '[]')
64+
const matchesTags =
65+
selectedTags.length === 0 || cardTags.some((t) => selectedTags.includes(t))
66+
67+
const matchesQuery =
68+
!normalizedQuery ||
69+
(card.dataset.title ?? '').toLowerCase().includes(normalizedQuery) ||
70+
(card.dataset.description ?? '').toLowerCase().includes(normalizedQuery)
71+
72+
card.hidden = !matchesTags || !matchesQuery
73+
})
74+
75+
writeURL(selectedTags, query)
76+
window.dispatchEvent(new Event('tags:filter'))
77+
}, [selectedTags, query])
78+
79+
return (
80+
<div className="mb-10 space-y-4">
81+
<div className="relative">
82+
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
83+
<input
84+
type="text"
85+
value={query}
86+
onChange={(e) => setQuery(e.target.value)}
87+
placeholder="Rechercher un article..."
88+
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"
89+
/>
90+
</div>
91+
92+
<div className="flex items-center gap-3 overflow-x-auto scrollbar-hide">
93+
{tags.map((tag) => {
94+
const active = selectedTags.includes(tag.name)
95+
return (
96+
<button
97+
key={tag.name}
98+
type="button"
99+
onClick={() => toggleTag(tag.name)}
100+
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 ${
101+
active
102+
? 'border-primary/50 bg-primary/5 text-primary font-medium'
103+
: 'border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground'
104+
}`}
105+
>
106+
<TagIcon className="size-3.5" />
107+
{tag.name}
108+
</button>
109+
)
110+
})}
111+
</div>
112+
</div>
113+
)
114+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Navbar, MobileMenu, MobileNavLinks, LocaleSwitcher, getAppLinks } from '@explainer/ui'
2+
import type { NavbarLink } from '@explainer/ui'
3+
4+
const blogLinks: NavbarLink[] = [
5+
{ label: 'Tous les articles', href: '/', icon: 'lucide:newspaper' },
6+
{ label: 'Catégories', href: '/tags', icon: 'lucide:folder' },
7+
{ label: 'RSS', href: '/rss.xml', icon: 'lucide:rss' },
8+
]
9+
10+
interface BlogNavbarProps {
11+
activePath: string
12+
appUrlOverrides?: Partial<Record<string, string>>
13+
}
14+
15+
export function BlogNavbar({ activePath, appUrlOverrides }: BlogNavbarProps) {
16+
const appLinks = getAppLinks('blog', appUrlOverrides)
17+
18+
return (
19+
<Navbar
20+
currentApp="blog"
21+
appUrlOverrides={appUrlOverrides}
22+
brandHref={appUrlOverrides?.website ?? '/'}
23+
links={blogLinks}
24+
activePath={activePath}
25+
leftSlot={
26+
<MobileMenu>
27+
<MobileNavLinks
28+
links={blogLinks}
29+
appLinks={appLinks}
30+
activePath={activePath}
31+
/>
32+
</MobileMenu>
33+
}
34+
rightSlot={
35+
<LocaleSwitcher locales={['en']} currentLocale="en" switchUrls={{}} />
36+
}
37+
/>
38+
)
39+
}

0 commit comments

Comments
 (0)