docs: Setup social sharing for blogposts (#8102)

This commit is contained in:
Adewale Abati
2026-03-25 15:37:08 +01:00
committed by GitHub
parent 5b4dd40593
commit 3a9805e255
7 changed files with 340 additions and 0 deletions
@@ -0,0 +1,130 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Share2, Link, Check } from 'lucide-react';
import { IconTwitter } from '@site/src/components/icons/twitter';
import { IconLinkedIn } from '@site/src/components/icons/linkedin';
import { IconFacebook } from '@site/src/components/icons/facebook';
import { IconReddit } from '@site/src/components/icons/reddit';
import styles from './styles.module.css';
const TWITTER_VIA = 'goose_oss';
interface SocialShareProps {
url: string;
title: string;
}
const SocialShare: React.FC<SocialShareProps> = ({ url, title }) => {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const shareOptions = useMemo(() => {
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
return [
{
name: 'Twitter / X',
icon: <IconTwitter />,
url: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}&via=${TWITTER_VIA}`,
},
{
name: 'LinkedIn',
icon: <IconLinkedIn />,
url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
},
{
name: 'Facebook',
icon: <IconFacebook />,
url: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
},
{
name: 'Reddit',
icon: <IconReddit />,
url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
},
];
}, [url, title]);
// Close menu on outside click or Escape
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
const openShareWindow = (shareUrl: string) => {
window.open(shareUrl, '_blank', 'width=600,height=500,noopener,noreferrer');
setOpen(false);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => {
setCopied(false);
setOpen(false);
}, 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className={styles.wrapper} ref={menuRef}>
<button
onClick={() => setOpen((prev) => !prev)}
className={styles.shareButton}
aria-label="Share this post"
aria-expanded={open}
aria-haspopup="true"
title="Share this post"
>
<Share2 size={16} />
<span>Share</span>
</button>
{open && (
<div className={styles.dropdown} role="menu">
{shareOptions.map((option) => (
<button
key={option.name}
className={styles.dropdownItem}
onClick={() => openShareWindow(option.url)}
role="menuitem"
>
{option.icon}
<span>{option.name}</span>
</button>
))}
<div className={styles.divider} />
<button
className={styles.dropdownItem}
onClick={handleCopyLink}
role="menuitem"
>
{copied ? <Check size={16} /> : <Link size={16} />}
<span>{copied ? 'Copied!' : 'Copy link'}</span>
</button>
</div>
)}
</div>
);
};
export default SocialShare;
@@ -0,0 +1,129 @@
.wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
/* ── Trigger button ───────────────────────────────── */
.shareButton {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 6px;
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
line-height: 1;
}
.shareButton:hover {
background: var(--ifm-color-primary);
color: #fff;
border-color: var(--ifm-color-primary);
}
.shareButton:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.shareButton svg {
flex-shrink: 0;
}
/* ── Dropdown menu ────────────────────────────────── */
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 100;
min-width: 180px;
padding: 0.375rem;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 10px;
background: var(--ifm-background-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Menu items ───────────────────────────────────── */
.dropdownItem {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ifm-font-color-base);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease;
text-align: left;
}
.dropdownItem:hover {
background: var(--ifm-color-emphasis-100);
}
.dropdownItem:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: -2px;
}
.dropdownItem svg {
flex-shrink: 0;
opacity: 0.8;
}
.dropdownItem:hover svg {
opacity: 1;
}
/* ── Divider ──────────────────────────────────────── */
.divider {
height: 1px;
margin: 0.375rem 0.75rem;
background: var(--ifm-color-emphasis-200);
}
/* ── Dark mode ────────────────────────────────────── */
[data-theme='dark'] .shareButton {
background: var(--ifm-background-surface-color);
border-color: var(--ifm-color-emphasis-400);
}
[data-theme='dark'] .dropdown {
border-color: var(--ifm-color-emphasis-300);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
[data-theme='dark'] .dropdownItem:hover {
background: var(--ifm-color-emphasis-200);
}
/* ── Print ────────────────────────────────────────── */
@media print {
.wrapper {
display: none;
}
}
@@ -0,0 +1,12 @@
export const IconFacebook = ({ className = "" }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<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" />
</svg>
);
@@ -0,0 +1,12 @@
export const IconLinkedIn = ({ className = "" }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<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.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 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" />
</svg>
);
@@ -0,0 +1,12 @@
export const IconReddit = ({ className = "" }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" />
</svg>
);
@@ -0,0 +1,12 @@
export const IconTwitter = ({ className = "" }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<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" />
</svg>
);
@@ -0,0 +1,33 @@
import React from 'react';
import Info from '@theme-original/BlogPostItem/Header/Info';
import type InfoType from '@theme/BlogPostItem/Header/Info';
import type { WrapperProps } from '@docusaurus/types';
import { useBlogPost } from '@docusaurus/plugin-content-blog/client';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import SocialShare from '@site/src/components/SocialShare';
type Props = WrapperProps<typeof InfoType>;
function buildPostUrl(siteUrl: string, permalink: string): string {
const base = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl;
return `${base}${permalink}`;
}
export default function InfoWrapper(props: Props): JSX.Element {
const { metadata, isBlogPostPage } = useBlogPost();
const { siteConfig } = useDocusaurusContext();
const postUrl = buildPostUrl(siteConfig.url, metadata.permalink);
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '0.25rem' }}>
<Info {...props} />
{isBlogPostPage && (
<>
<span style={{ margin: '0 0.125rem' }}> · </span>
<SocialShare url={postUrl} title={metadata.title} />
</>
)}
</div>
);
}