mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 12:19:18 +00:00
docs: add changelog entries for v1.33.1 through v1.37.0
Add missing changelog entries to content/pages/changelog-page.md: v1.34.0 (2025-12-26): Blog page featured layout with hero post - blogFeatured frontmatter field for posts - Hero card displays first featured post with landscape image - 2-column featured row for remaining featured posts - 3-column grid for regular posts v1.35.0 (2025-12-26): Image support at top of posts and pages - showImageAtTop frontmatter field - Full-width image display above post header - Works for both posts and pages v1.36.0 (2025-12-27): Social footer component - Customizable social links (8 platform types) - Copyright with auto-updating year - showSocialFooter frontmatter field for per-page control - Configurable via siteConfig.socialFooter v1.37.0 (2025-12-27): Newsletter Admin UI - Three-column admin interface at /newsletter-admin - Subscriber management with search and filters - Send newsletter panel (post selection or custom email) - Weekly digest automation (Sunday 9am UTC) - Developer notifications (subscriber alerts, weekly stats) - Markdown-to-HTML conversion for custom emails
This commit is contained in:
@@ -7,6 +7,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import NewsletterSignup from "./NewsletterSignup";
|
||||
import ContactForm from "./ContactForm";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Sanitize schema that allows collapsible sections (details/summary)
|
||||
const sanitizeSchema = {
|
||||
@@ -261,6 +264,61 @@ const cursorTanTheme: { [key: string]: React.CSSProperties } = {
|
||||
|
||||
interface BlogPostProps {
|
||||
content: string;
|
||||
slug?: string; // For tracking source of newsletter/contact form signups
|
||||
pageType?: "post" | "page"; // Type of content (for tracking)
|
||||
}
|
||||
|
||||
// Content segment types for inline embeds
|
||||
type ContentSegment =
|
||||
| { type: "content"; value: string }
|
||||
| { type: "newsletter" }
|
||||
| { type: "contactform" };
|
||||
|
||||
// Parse content for inline embed placeholders
|
||||
// Supports: <!-- newsletter --> and <!-- contactform -->
|
||||
function parseContentForEmbeds(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
|
||||
// Pattern matches <!-- newsletter --> or <!-- contactform --> (case insensitive)
|
||||
const pattern = /<!--\s*(newsletter|contactform)\s*-->/gi;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
// Add content before the placeholder
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = content.slice(lastIndex, match.index);
|
||||
if (textBefore.trim()) {
|
||||
segments.push({ type: "content", value: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
// Add the embed placeholder
|
||||
const embedType = match[1].toLowerCase();
|
||||
if (embedType === "newsletter") {
|
||||
segments.push({ type: "newsletter" });
|
||||
} else if (embedType === "contactform") {
|
||||
segments.push({ type: "contactform" });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining content after last placeholder
|
||||
if (lastIndex < content.length) {
|
||||
const remaining = content.slice(lastIndex);
|
||||
if (remaining.trim()) {
|
||||
segments.push({ type: "content", value: remaining });
|
||||
}
|
||||
}
|
||||
|
||||
// If no placeholders found, return single content segment
|
||||
if (segments.length === 0) {
|
||||
segments.push({ type: "content", value: content });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Generate slug from heading text for anchor links
|
||||
@@ -308,7 +366,7 @@ function HeadingAnchor({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPost({ content }: BlogPostProps) {
|
||||
export default function BlogPost({ content, slug, pageType = "post" }: BlogPostProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const getCodeTheme = () => {
|
||||
@@ -324,6 +382,233 @@ export default function BlogPost({ content }: BlogPostProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse content for inline embeds
|
||||
const segments = parseContentForEmbeds(content);
|
||||
const hasInlineEmbeds = segments.some((s) => s.type !== "content");
|
||||
|
||||
// Helper to render a single markdown segment
|
||||
const renderMarkdown = (markdownContent: string, key?: number) => (
|
||||
<ReactMarkdown
|
||||
key={key}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={{
|
||||
code(codeProps) {
|
||||
const { className, children, node, style, ...restProps } = codeProps as {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { tagName?: string; properties?: { className?: string[] } };
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
};
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
// Detect inline code: no language class AND content is short without newlines
|
||||
const codeContent = String(children);
|
||||
const hasNewlines = codeContent.includes('\n');
|
||||
const isShort = codeContent.length < 80;
|
||||
const hasLanguage = !!match || !!className;
|
||||
|
||||
// It's inline only if: no language, short content, no newlines
|
||||
const isInline = !hasLanguage && isShort && !hasNewlines;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="inline-code" style={style} {...restProps}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const codeString = String(children).replace(/\n$/, "");
|
||||
const language = match ? match[1] : "text";
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
const textBlockStyle = isTextBlock ? {
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
wordWrap: "break-word" as const,
|
||||
overflowWrap: "break-word" as const,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<div className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}>
|
||||
{match && <span className="code-language">{match[1]}</span>}
|
||||
<CodeCopyButton code={codeString} />
|
||||
<SyntaxHighlighter
|
||||
style={getCodeTheme()}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={textBlockStyle}
|
||||
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
img({ src, alt }) {
|
||||
return (
|
||||
<span className="blog-image-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="blog-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
{alt && <span className="blog-image-caption">{alt}</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
a({ href, children }) {
|
||||
const isExternal = href?.startsWith("http");
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
className="blog-link"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="blog-blockquote">{children}</blockquote>
|
||||
);
|
||||
},
|
||||
h1({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h1 id={id} className="blog-h1">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h2 id={id} className="blog-h2">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h3 id={id} className="blog-h3">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h4 id={id} className="blog-h4">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
h5({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h5 id={id} className="blog-h5">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
},
|
||||
h6({ children }) {
|
||||
const id = generateSlug(getTextContent(children));
|
||||
return (
|
||||
<h6 id={id} className="blog-h6">
|
||||
<HeadingAnchor id={id} />
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="blog-ul">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="blog-ol">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="blog-li">{children}</li>;
|
||||
},
|
||||
hr() {
|
||||
return <hr className="blog-hr" />;
|
||||
},
|
||||
// Table components for GitHub-style tables
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="blog-table-wrapper">
|
||||
<table className="blog-table">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="blog-thead">{children}</thead>;
|
||||
},
|
||||
tbody({ children }) {
|
||||
return <tbody className="blog-tbody">{children}</tbody>;
|
||||
},
|
||||
tr({ children }) {
|
||||
return <tr className="blog-tr">{children}</tr>;
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="blog-th">{children}</th>;
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="blog-td">{children}</td>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
|
||||
// Build source string for tracking
|
||||
const sourcePrefix = pageType === "page" ? "page" : "post";
|
||||
const source = slug ? `${sourcePrefix}:${slug}` : sourcePrefix;
|
||||
|
||||
// Render with inline embeds if placeholders exist
|
||||
if (hasInlineEmbeds) {
|
||||
return (
|
||||
<article className="blog-post-content">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "newsletter") {
|
||||
// Newsletter signup inline
|
||||
return siteConfig.newsletter?.enabled ? (
|
||||
<NewsletterSignup
|
||||
key={`newsletter-${index}`}
|
||||
source={pageType === "page" ? "post" : "post"}
|
||||
postSlug={slug}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
if (segment.type === "contactform") {
|
||||
// Contact form inline
|
||||
return siteConfig.contactForm?.enabled ? (
|
||||
<ContactForm
|
||||
key={`contactform-${index}`}
|
||||
source={source}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
// Markdown content segment
|
||||
return renderMarkdown(segment.value, index);
|
||||
})}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// No inline embeds, render content normally
|
||||
return (
|
||||
<article className="blog-post-content">
|
||||
<ReactMarkdown
|
||||
|
||||
167
src/components/ContactForm.tsx
Normal file
167
src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Props for the ContactForm component
|
||||
interface ContactFormProps {
|
||||
source: string; // "page:slug" or "post:slug"
|
||||
title?: string; // Optional title override
|
||||
description?: string; // Optional description override
|
||||
}
|
||||
|
||||
// Contact form component
|
||||
// Displays a form with name, email, and message fields
|
||||
// Submits to Convex which sends email via AgentMail
|
||||
export default function ContactForm({
|
||||
source,
|
||||
title,
|
||||
description,
|
||||
}: ContactFormProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
|
||||
const submitContact = useMutation(api.contact.submitContact);
|
||||
|
||||
// Check if contact form is enabled globally
|
||||
if (!siteConfig.contactForm?.enabled) return null;
|
||||
|
||||
// Use provided title/description or fall back to config defaults
|
||||
const displayTitle = title || siteConfig.contactForm.title;
|
||||
const displayDescription = description || siteConfig.contactForm.description;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
if (!name.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter your name.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter your email.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
setStatus("error");
|
||||
setStatusMessage("Please enter a message.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
const result = await submitContact({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
message: message.trim(),
|
||||
source,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setStatus("success");
|
||||
setStatusMessage(result.message);
|
||||
// Clear form on success
|
||||
setName("");
|
||||
setEmail("");
|
||||
setMessage("");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setStatusMessage(result.message);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setStatusMessage("Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="contact-form">
|
||||
<div className="contact-form__content">
|
||||
<h3 className="contact-form__title">{displayTitle}</h3>
|
||||
{displayDescription && (
|
||||
<p className="contact-form__description">{displayDescription}</p>
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="contact-form__success">
|
||||
<p>{statusMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-form__reset-button"
|
||||
onClick={() => setStatus("idle")}
|
||||
>
|
||||
Send another message
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="contact-form__form">
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-name" className="contact-form__label">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="contact-form__input"
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-email" className="contact-form__label">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="contact-form__input"
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="contact-form__field">
|
||||
<label htmlFor="contact-message" className="contact-form__label">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Your message..."
|
||||
className="contact-form__textarea"
|
||||
rows={5}
|
||||
disabled={status === "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="contact-form__button"
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<p className="contact-form__error">{statusMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
src/components/NewsletterSignup.tsx
Normal file
116
src/components/NewsletterSignup.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
// Props for the newsletter signup component
|
||||
interface NewsletterSignupProps {
|
||||
source: "home" | "blog-page" | "post"; // Where the signup form appears
|
||||
postSlug?: string; // For tracking which post they subscribed from
|
||||
title?: string; // Override default title
|
||||
description?: string; // Override default description
|
||||
}
|
||||
|
||||
// Newsletter signup component
|
||||
// Displays email input form for newsletter subscriptions
|
||||
// Integrates with Convex backend for subscriber management
|
||||
export default function NewsletterSignup({
|
||||
source,
|
||||
postSlug,
|
||||
title,
|
||||
description,
|
||||
}: NewsletterSignupProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const subscribe = useMutation(api.newsletter.subscribe);
|
||||
|
||||
// Check if newsletter is enabled globally
|
||||
if (!siteConfig.newsletter?.enabled) return null;
|
||||
|
||||
// Get config for this placement
|
||||
const config =
|
||||
source === "home"
|
||||
? siteConfig.newsletter.signup.home
|
||||
: source === "blog-page"
|
||||
? siteConfig.newsletter.signup.blogPage
|
||||
: siteConfig.newsletter.signup.posts;
|
||||
|
||||
// Check if this specific placement is enabled
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const displayTitle = title || config.title;
|
||||
const displayDescription = description || config.description;
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
setStatus("error");
|
||||
setMessage("Please enter your email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
try {
|
||||
// Include post slug in source for tracking
|
||||
const sourceValue = postSlug ? `post:${postSlug}` : source;
|
||||
const result = await subscribe({ email, source: sourceValue });
|
||||
|
||||
if (result.success) {
|
||||
setStatus("success");
|
||||
setMessage(result.message);
|
||||
setEmail("");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setMessage(result.message);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setMessage("Something went wrong. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="newsletter-signup">
|
||||
<div className="newsletter-signup__content">
|
||||
<h3 className="newsletter-signup__title">{displayTitle}</h3>
|
||||
{displayDescription && (
|
||||
<p className="newsletter-signup__description">{displayDescription}</p>
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="newsletter-signup__success">{message}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="newsletter-signup__form">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="newsletter-signup__input"
|
||||
disabled={status === "loading"}
|
||||
aria-label="Email address"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="newsletter-signup__button"
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
{status === "loading" ? "..." : "Subscribe"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<p className="newsletter-signup__error">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import AIChatView from "./AIChatView";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
interface RightSidebarProps {
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true
|
||||
aiChatEnabled?: boolean; // From frontmatter aiChat: true/false (undefined = not set)
|
||||
pageContent?: string; // Page markdown content for AI context
|
||||
slug?: string; // Page/post slug for chat context ID
|
||||
}
|
||||
@@ -15,9 +15,15 @@ export default function RightSidebar({
|
||||
slug,
|
||||
}: RightSidebarProps) {
|
||||
// Check if AI chat should be shown
|
||||
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
|
||||
// Requires:
|
||||
// 1. Global config enabled (siteConfig.aiChat.enabledOnContent)
|
||||
// 2. Frontmatter explicitly enabled (aiChat: true)
|
||||
// 3. Slug exists for context ID
|
||||
// If aiChat: false is set in frontmatter, chat will be hidden even if global config is enabled
|
||||
const showAIChat =
|
||||
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
|
||||
siteConfig.aiChat.enabledOnContent &&
|
||||
aiChatEnabled === true &&
|
||||
slug;
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
|
||||
75
src/components/SocialFooter.tsx
Normal file
75
src/components/SocialFooter.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import type { SocialLink } from "../config/siteConfig";
|
||||
import {
|
||||
GithubLogo,
|
||||
TwitterLogo,
|
||||
LinkedinLogo,
|
||||
InstagramLogo,
|
||||
YoutubeLogo,
|
||||
TiktokLogo,
|
||||
DiscordLogo,
|
||||
Globe,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
// Map platform names to Phosphor icons
|
||||
const platformIcons: Record<SocialLink["platform"], React.ComponentType<{ size?: number; weight?: "regular" | "bold" | "fill" }>> = {
|
||||
github: GithubLogo,
|
||||
twitter: TwitterLogo,
|
||||
linkedin: LinkedinLogo,
|
||||
instagram: InstagramLogo,
|
||||
youtube: YoutubeLogo,
|
||||
tiktok: TiktokLogo,
|
||||
discord: DiscordLogo,
|
||||
website: Globe,
|
||||
};
|
||||
|
||||
// Social footer component
|
||||
// Displays social icons on left and copyright on right
|
||||
// Visibility controlled by siteConfig.socialFooter settings and frontmatter showSocialFooter field
|
||||
export default function SocialFooter() {
|
||||
const { socialFooter } = siteConfig;
|
||||
|
||||
// Don't render if social footer is globally disabled
|
||||
if (!socialFooter?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get current year for copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<section className="social-footer">
|
||||
<div className="social-footer-content">
|
||||
{/* Social links on the left */}
|
||||
<div className="social-footer-links">
|
||||
{socialFooter.socialLinks.map((link) => {
|
||||
const IconComponent = platformIcons[link.platform];
|
||||
return (
|
||||
<a
|
||||
key={link.platform}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-footer-link"
|
||||
aria-label={`Follow on ${link.platform}`}
|
||||
>
|
||||
<IconComponent size={20} weight="regular" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Copyright on the right */}
|
||||
<div className="social-footer-copyright">
|
||||
<span className="social-footer-copyright-symbol">©</span>
|
||||
<span className="social-footer-copyright-name">
|
||||
{socialFooter.copyright.siteName}
|
||||
</span>
|
||||
{socialFooter.copyright.showYear && (
|
||||
<span className="social-footer-copyright-year">{currentYear}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user