mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
docs: Setup social sharing for blogposts (#8102)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user