Add image support to footer component with size control via HTML attributes

This commit is contained in:
Wayne Sutton
2025-12-25 23:01:58 -08:00
parent d00f204fa7
commit b94b26116a
20 changed files with 602 additions and 139 deletions

View File

@@ -285,6 +285,29 @@ function getTextContent(children: React.ReactNode): string {
return "";
}
// Anchor link component for headings
function HeadingAnchor({ id }: { id: string }) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Copy URL to clipboard, but allow default scroll behavior
const url = `${window.location.origin}${window.location.pathname}#${id}`;
navigator.clipboard.writeText(url).catch(() => {
// Silently fail if clipboard API is not available
});
};
return (
<a
href={`#${id}`}
className="heading-anchor"
onClick={handleClick}
aria-label="Copy link to heading"
title="Copy link to heading"
>
#
</a>
);
}
export default function BlogPost({ content }: BlogPostProps) {
const { theme } = useTheme();
@@ -397,6 +420,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h1 id={id} className="blog-h1">
<HeadingAnchor id={id} />
{children}
</h1>
);
@@ -405,6 +429,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
<HeadingAnchor id={id} />
{children}
</h2>
);
@@ -413,6 +438,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
<HeadingAnchor id={id} />
{children}
</h3>
);
@@ -421,6 +447,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
<HeadingAnchor id={id} />
{children}
</h4>
);
@@ -429,6 +456,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
<HeadingAnchor id={id} />
{children}
</h5>
);
@@ -437,6 +465,7 @@ export default function BlogPost({ content }: BlogPostProps) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
<HeadingAnchor id={id} />
{children}
</h6>
);

91
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,91 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import siteConfig from "../config/siteConfig";
// Sanitize schema for footer markdown (allows links, paragraphs, line breaks, images)
// style attribute is sanitized by rehypeSanitize to remove dangerous CSS
const footerSanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), "br", "img"],
attributes: {
...defaultSchema.attributes,
img: ["src", "alt", "loading", "width", "height", "style", "class"],
},
};
// Footer component
// Renders markdown content from frontmatter footer field
// Falls back to siteConfig.footer.defaultContent if no frontmatter footer provided
// Visibility controlled by siteConfig.footer settings and frontmatter showFooter field
interface FooterProps {
content?: string; // Markdown content from frontmatter
}
export default function Footer({ content }: FooterProps) {
const { footer } = siteConfig;
// Don't render if footer is globally disabled
if (!footer.enabled) {
return null;
}
// Use frontmatter content if provided, otherwise fall back to siteConfig default
const footerContent = content || footer.defaultContent;
// Don't render if no content available
if (!footerContent) {
return null;
}
return (
<section className="site-footer">
<div className="site-footer-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, footerSanitizeSchema]]}
components={{
p({ children }) {
return <p className="site-footer-text">{children}</p>;
},
img({ src, alt, width, height, style, className }) {
return (
<span className="site-footer-image-wrapper">
<img
src={src}
alt={alt || ""}
className={className || "site-footer-image"}
loading="lazy"
width={width}
height={height}
style={style}
/>
{alt && (
<span className="site-footer-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="site-footer-link"
>
{children}
</a>
);
},
}}
>
{footerContent}
</ReactMarkdown>
</div>
</section>
);
}

View File

@@ -1,21 +1,10 @@
import CopyPageDropdown from "./CopyPageDropdown";
interface RightSidebarProps {
title: string;
content: string;
url: string;
slug: string;
description?: string;
date?: string;
tags?: string[];
readTime?: string;
}
export default function RightSidebar(props: RightSidebarProps) {
// Right sidebar component - maintains layout spacing when sidebars are enabled
// CopyPageDropdown is now rendered in the main content area instead
export default function RightSidebar() {
return (
<aside className="post-sidebar-right">
<div className="right-sidebar-content">
<CopyPageDropdown {...props} />
{/* Empty - CopyPageDropdown moved to main content area */}
</div>
</aside>
);

View File

@@ -87,6 +87,17 @@ export interface RightSidebarConfig {
minWidth?: number; // Minimum viewport width to show sidebar (default: 1135)
}
// Footer configuration
// Footer content can be set in frontmatter (footer field) or use defaultContent here
// Footer can be enabled/disabled globally and per-page via frontmatter showFooter field
export interface FooterConfig {
enabled: boolean; // Global toggle for footer
showOnHomepage: boolean; // Show footer on homepage
showOnPosts: boolean; // Default: show footer on blog posts
showOnPages: boolean; // Default: show footer on static pages
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
}
// Site configuration interface
export interface SiteConfig {
// Basic site info
@@ -136,6 +147,9 @@ export interface SiteConfig {
// Right sidebar configuration
rightSidebar: RightSidebarConfig;
// Footer configuration
footer: FooterConfig;
}
// Default site configuration
@@ -289,6 +303,20 @@ export const siteConfig: SiteConfig = {
enabled: true, // Set to false to disable right sidebar globally
minWidth: 1135, // Minimum viewport width in pixels to show sidebar
},
// Footer configuration
// Footer content can be set in frontmatter (footer field) or use defaultContent here
// Use showFooter: false in frontmatter to hide footer on specific posts/pages
footer: {
enabled: true, // Global toggle for footer
showOnHomepage: true, // Show footer on homepage
showOnPosts: true, // Default: show footer on blog posts (override with frontmatter)
showOnPages: true, // Default: show footer on static pages (override with frontmatter)
// Default footer markdown (used when frontmatter footer field is not provided)
defaultContent: `Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats).
Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Opus 4.5. Follow on [Twitter/X](https://x.com/waynesutton), [LinkedIn](https://www.linkedin.com/in/waynesutton/), and [GitHub](https://github.com/waynesutton). This project is licensed under the MIT [License](https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file).`,
},
};
// Export the config as default for easy importing

View File

@@ -6,6 +6,7 @@ import PostList from "../components/PostList";
import FeaturedCards from "../components/FeaturedCards";
import LogoMarquee from "../components/LogoMarquee";
import GitHubContributions from "../components/GitHubContributions";
import Footer from "../components/Footer";
import siteConfig from "../config/siteConfig";
// Local storage key for view mode preference
@@ -223,82 +224,9 @@ export default function Home() {
{renderLogoGallery("above-footer")}
{/* Footer section */}
<section className="home-footer">
<p className="home-footer-text">
Built with{" "}
<a
href={siteConfig.links.convex}
target="_blank"
rel="noopener noreferrer"
>
Convex
</a>{" "}
for real-time sync and deployed on{" "}
<a
href={siteConfig.links.netlify}
target="_blank"
rel="noopener noreferrer"
>
Netlify
</a>
. Read the{" "}
<a
href="https://github.com/waynesutton/markdown-site"
target="_blank"
rel="noopener noreferrer"
>
project on GitHub
</a>{" "}
to fork and deploy your own. View{" "}
<Link to="/stats" className="home-text-link">
real-time site stats
</Link>
.
</p>
<p></p>
<br></br>
<p className="home-footer-text">
Created by{" "}
<a
href="https://x.com/waynesutton"
target="_blank"
rel="noopener noreferrer"
>
Wayne
</a>{" "}
with Convex, Cursor, and Claude Opus 4.5. Follow on{" "}
<a
href="https://x.com/waynesutton"
target="_blank"
rel="noopener noreferrer"
>
Twitter/X
</a>
,{" "}
<a
href="https://www.linkedin.com/in/waynesutton/"
target="_blank"
rel="noopener noreferrer"
>
LinkedIn
</a>
, and{" "}
<a
href="https://github.com/waynesutton"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
. This project is licensed under the MIT{" "}
<a
href="https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file"
className="home-text-link"
>
License.
</a>{" "}
</p>
</section>
{siteConfig.footer.enabled && siteConfig.footer.showOnHomepage && (
<Footer content={siteConfig.footer.defaultContent} />
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar";
import RightSidebar from "../components/RightSidebar";
import Footer from "../components/Footer";
import { extractHeadings } from "../utils/extractHeadings";
import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns";
@@ -195,12 +196,15 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Only show CopyPageDropdown in nav if right sidebar is disabled */}
{!hasRightSidebar && (
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
)}
{/* Only show CopyPageDropdown in nav if no sidebars are enabled */}
{!hasAnySidebar && (
<CopyPageDropdown
title={page.title}
content={page.content}
@@ -222,7 +226,21 @@ export default function Post() {
{/* Main content */}
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""}`}>
<header className="post-header">
<h1 className="post-title">{page.title}</h1>
<div className="post-title-row">
<h1 className="post-title">{page.title}</h1>
{/* Show CopyPageDropdown aligned with title when sidebars are enabled */}
{hasAnySidebar && (
<div className="post-header-actions">
<CopyPageDropdown
title={page.title}
content={page.content}
url={window.location.href}
slug={page.slug}
description={page.excerpt}
/>
</div>
)}
</div>
{/* Author avatar and name for pages (optional) */}
{(page.authorImage || page.authorName) && (
<div className="post-meta-header">
@@ -243,18 +261,16 @@ export default function Post() {
</header>
<BlogPost content={page.content} />
{/* Footer - shown inside article at bottom for pages */}
{siteConfig.footer.enabled &&
(page.showFooter !== undefined ? page.showFooter : siteConfig.footer.showOnPages) && (
<Footer content={page.footer} />
)}
</article>
{/* Right sidebar - CopyPageDropdown */}
{hasRightSidebar && (
<RightSidebar
title={page.title}
content={page.content}
url={window.location.href}
slug={page.slug}
description={page.excerpt}
/>
)}
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
</div>
</div>
);
@@ -303,12 +319,15 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Only show CopyPageDropdown in nav if right sidebar is disabled */}
{!hasRightSidebar && (
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
)}
{/* Only show CopyPageDropdown in nav if no sidebars are enabled */}
{!hasAnySidebar && (
<CopyPageDropdown
title={post.title}
content={post.content}
@@ -332,7 +351,24 @@ export default function Post() {
<article className={`post-article ${hasAnySidebar ? "post-article-with-sidebar" : ""}`}>
<header className="post-header">
<h1 className="post-title">{post.title}</h1>
<div className="post-title-row">
<h1 className="post-title">{post.title}</h1>
{/* Show CopyPageDropdown aligned with title when sidebars are enabled */}
{hasAnySidebar && (
<div className="post-header-actions">
<CopyPageDropdown
title={post.title}
content={post.content}
url={window.location.href}
slug={post.slug}
description={post.description}
date={post.date}
tags={post.tags}
readTime={post.readTime}
/>
</div>
)}
</div>
<div className="post-meta-header">
{/* Author avatar and name (optional) */}
{(post.authorImage || post.authorName) && (
@@ -431,21 +467,16 @@ export default function Post() {
</div>
)}
</footer>
{/* Footer - shown inside article at bottom for posts */}
{siteConfig.footer.enabled &&
(post.showFooter !== undefined ? post.showFooter : siteConfig.footer.showOnPosts) && (
<Footer content={post.footer} />
)}
</article>
{/* Right sidebar - CopyPageDropdown */}
{hasRightSidebar && (
<RightSidebar
title={post.title}
content={post.content}
url={window.location.href}
slug={post.slug}
description={post.description}
date={post.date}
tags={post.tags}
readTime={post.readTime}
/>
)}
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
</div>
</div>
);

View File

@@ -40,6 +40,8 @@ const POST_FIELDS = [
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
];
// Frontmatter field definitions for pages
@@ -57,6 +59,8 @@ const PAGE_FIELDS = [
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
];
// Generate frontmatter template based on content type

View File

@@ -314,7 +314,7 @@ body {
max-width: 800px;
width: 100%;
margin: 0 auto;
padding: 40px 24px;
/* padding: 40px 24px; */
}
/* Wide content layout for pages that need more space (stats, etc.) */
@@ -648,7 +648,7 @@ body {
/* Post page styles */
.post-page {
padding-top: 20px;
padding-top: 55px;
}
/* Full-width sidebar layout - breaks out of .main-content constraints */
@@ -656,7 +656,7 @@ body {
padding-top: 20px;
/* Break out of the 680px max-width container */
width: calc(100vw - 48px);
max-width: 1400px;
max-width: none;
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
position: relative;
/* Add left padding to align content with sidebar edge
@@ -862,6 +862,22 @@ body {
margin-bottom: 48px;
}
/* Title row container - flex layout for title and CopyPageDropdown */
.post-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
/* Header actions container for CopyPageDropdown when sidebars are enabled */
.post-header-actions {
display: flex;
flex-shrink: 0;
align-items: flex-start;
}
/* Sidebar layout for pages - two-column grid with docs-style sidebar */
.post-content-with-sidebar {
display: grid;
@@ -888,7 +904,7 @@ body {
.post-sidebar-wrapper {
position: sticky;
top: 80px;
align-self: flex-start;
align-self: start;
max-height: calc(100vh - 100px);
overflow-y: auto;
/* Hide scrollbar while keeping scroll functionality */
@@ -902,7 +918,7 @@ body {
padding-bottom: 24px;
margin-top: -24px;
border-radius: 6px;
/* Extend background to fill height */
/* Extend background to fill height - ensures sidebars flush to bottom */
min-height: calc(100vh - 80px);
/* Top border using CSS variable for theme consistency */
border-top: 1px solid var(--border-sidebar);
@@ -923,7 +939,7 @@ body {
.post-sidebar-right {
position: sticky;
top: 80px;
align-self: flex-start;
align-self: start;
max-height: calc(100vh - 100px);
overflow-y: auto;
/* Hide scrollbar while keeping scroll functionality */
@@ -937,7 +953,7 @@ body {
padding-bottom: 24px;
margin-top: -24px;
border-radius: 6px;
/* Extend background to fill height */
/* Extend background to fill height - ensures sidebars flush to bottom */
min-height: calc(100vh - 80px);
/* Top border using CSS variable for theme consistency */
border-top: 1px solid var(--border-sidebar);
@@ -1177,8 +1193,10 @@ body {
font-size: var(--font-size-post-title);
font-weight: 300;
letter-spacing: -0.02em;
margin-bottom: 16px;
margin-bottom: 0;
line-height: 1.2;
flex: 1;
min-width: 0;
}
.post-meta-header {
@@ -1238,6 +1256,7 @@ body {
margin: 48px 0 24px;
letter-spacing: -0.02em;
line-height: 1.3;
position: relative;
}
.blog-h2 {
@@ -1246,6 +1265,7 @@ body {
margin: 40px 0 20px;
letter-spacing: -0.01em;
line-height: 1.3;
position: relative;
}
.blog-h3 {
@@ -1253,6 +1273,7 @@ body {
font-weight: 300;
margin: 32px 0 16px;
line-height: 1.4;
position: relative;
}
.blog-h4 {
@@ -1260,6 +1281,7 @@ body {
font-weight: 300;
margin: 24px 0 12px;
line-height: 1.4;
position: relative;
}
.blog-h5 {
@@ -1267,6 +1289,7 @@ body {
font-weight: 300;
margin: 20px 0 10px;
line-height: 1.4;
position: relative;
}
.blog-h6 {
@@ -1274,6 +1297,33 @@ body {
font-weight: 300;
margin: 16px 0 8px;
line-height: 1.4;
position: relative;
}
/* Anchor links for headings */
.heading-anchor {
position: absolute;
left: -1.5em;
opacity: 0;
transition: opacity 0.2s ease;
color: var(--text-secondary);
text-decoration: none;
font-weight: normal;
font-size: 0.9em;
padding-right: 0.5em;
}
.blog-h1:hover .heading-anchor,
.blog-h2:hover .heading-anchor,
.blog-h3:hover .heading-anchor,
.blog-h4:hover .heading-anchor,
.blog-h5:hover .heading-anchor,
.blog-h6:hover .heading-anchor {
opacity: 1;
}
.heading-anchor:hover {
color: var(--link-color);
}
.blog-link {
@@ -1716,6 +1766,72 @@ body {
text-decoration: underline;
}
/* Site footer styles (used by Footer component) */
.site-footer {
margin-top: 4rem;
margin-bottom: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.site-footer-content {
color: var(--text-secondary);
font-size: var(--font-size-footer-text);
line-height: 1.7;
}
.site-footer-text {
color: var(--text-secondary);
font-size: var(--font-size-footer-text);
line-height: 1.7;
margin-bottom: 1rem;
}
.site-footer-text:last-child {
margin-bottom: 0;
}
.site-footer-text a {
color: var(--link-color);
text-decoration: none;
}
.site-footer-text a:hover {
color: var(--link-hover);
text-decoration: underline;
}
.site-footer-link {
color: var(--link-color);
text-decoration: none;
}
.site-footer-link:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Site footer image styles */
.site-footer-image-wrapper {
/* display: block;*/
margin: 5px;
}
.site-footer-image {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.site-footer-image-caption {
display: block;
margin-top: 0.5rem;
font-size: var(--font-size-sm);
color: var(--text-muted);
font-style: italic;
text-align: center;
}
/* ============================================
Blog Page Styles
Dedicated /blog page that shows all posts
@@ -2009,6 +2125,12 @@ body {
font-size: var(--font-size-blog-h6);
}
/* Adjust anchor link position on mobile */
.heading-anchor {
left: -1.2em;
font-size: 0.85em;
}
/* Table mobile styles */
.blog-table {
font-size: var(--font-size-table);