docs: update blog post and TASK.md with v1.9.0 scroll-to-top and v1.10.0 fork configuration

Updated:
- content/blog/raw-markdown-and-copy-improvements.md
  - Changed title from 'v1.7 and v1.8' to 'v1.7 to v1.10'
  - Added Fork configuration section (v1.10.0) with 9-file table
  - Added Scroll-to-top section (v1.9.0) with configuration options
  - Updated summary to include all features from v1.7 to v1.10
  - Fixed image path to /images/v17.png
  - Updated sync command guidance for dev vs prod
- TASK.md
  - Added new To Do items for future features
  - Removed duplicate Future Enhancements section
- content/pages/docs.md
  - Added Mobile menu section
  - Added Copy Page dropdown table with all options
  - Added Markdown tables section
- content/pages/about.md
  - Updated Features list with new v1.8.0 features
- content/blog/setup-guide.md
  - Added image field to pages schema
  - Updated Project structure with new directories
  - Added /raw/{slug}.md to API endpoints
  - Added Mobile Navigation and Copy Page Dropdown sections
  - Added featured image documentation with ordering details

Documentation now covers all features from v1.7.0 through v1.10.0.
This commit is contained in:
Wayne Sutton
2025-12-20 11:05:38 -08:00
parent d3d9c8055d
commit 997b9cad21
63 changed files with 5221 additions and 303 deletions

View File

@@ -404,6 +404,29 @@ export default function BlogPost({ content }: BlogPostProps) {
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>;
},
}}
>
{content}

View File

@@ -1,25 +1,143 @@
import { useState, useRef, useEffect } from "react";
import { Copy, MessageSquare, Sparkles } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
import { Copy, MessageSquare, Sparkles, Search, Check, AlertCircle, FileText, Download } from "lucide-react";
// Maximum URL length for query parameters (conservative limit)
const MAX_URL_LENGTH = 6000;
// AI service configurations
interface AIService {
id: string;
name: string;
icon: typeof Copy;
baseUrl: string;
description: string;
supportsUrlPrefill: boolean;
// Custom URL builder for services with special formats
buildUrl?: (prompt: string) => string;
}
// All services send the full markdown content directly
const AI_SERVICES: AIService[] = [
{
id: "chatgpt",
name: "ChatGPT",
icon: MessageSquare,
baseUrl: "https://chatgpt.com/",
description: "Analyze with ChatGPT",
supportsUrlPrefill: true,
// ChatGPT accepts ?q= with full text content
buildUrl: (prompt) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`,
},
{
id: "claude",
name: "Claude",
icon: Sparkles,
baseUrl: "https://claude.ai/new",
description: "Analyze with Claude",
supportsUrlPrefill: true,
buildUrl: (prompt) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}`,
},
{
id: "perplexity",
name: "Perplexity",
icon: Search,
baseUrl: "https://www.perplexity.ai/search",
description: "Research with Perplexity",
supportsUrlPrefill: true,
buildUrl: (prompt) => `https://www.perplexity.ai/search?q=${encodeURIComponent(prompt)}`,
},
];
// Extended props interface with optional metadata
interface CopyPageDropdownProps {
title: string;
content: string;
url: string;
slug: string;
description?: string;
date?: string;
tags?: string[];
readTime?: string;
}
// Converts the blog post to markdown format for LLMs
function formatAsMarkdown(title: string, content: string, url: string): string {
return `# ${title}\n\nSource: ${url}\n\n${content}`;
// Enhanced markdown format for better LLM parsing
function formatAsMarkdown(props: CopyPageDropdownProps): string {
const { title, content, url, description, date, tags, readTime } = props;
// Build metadata section
const metadataLines: string[] = [];
metadataLines.push(`Source: ${url}`);
if (date) metadataLines.push(`Date: ${date}`);
if (readTime) metadataLines.push(`Reading time: ${readTime}`);
if (tags && tags.length > 0) metadataLines.push(`Tags: ${tags.join(", ")}`);
// Build the full markdown document
let markdown = `# ${title}\n\n`;
// Add description if available
if (description) {
markdown += `> ${description}\n\n`;
}
// Add metadata block
markdown += `---\n${metadataLines.join("\n")}\n---\n\n`;
// Add main content
markdown += content;
return markdown;
}
export default function CopyPageDropdown({
title,
content,
url,
}: CopyPageDropdownProps) {
// Format content as an Agent Skill file for AI agents
function formatAsSkill(props: CopyPageDropdownProps): string {
const { title, content, url, description, tags } = props;
const generatedDate = new Date().toISOString().split("T")[0];
const tagList = tags && tags.length > 0 ? tags.join(", ") : "none";
let skill = `# ${title}\n\n`;
skill += `## Metadata\n`;
skill += `- Source: ${url}\n`;
skill += `- Tags: ${tagList}\n`;
skill += `- Generated: ${generatedDate}\n\n`;
if (description) {
skill += `## When to use this skill\n`;
skill += `${description}\n\n`;
}
skill += `## Instructions\n`;
skill += content;
return skill;
}
// Check if URL length exceeds safe limits
function isUrlTooLong(url: string): boolean {
return url.length > MAX_URL_LENGTH;
}
// Feedback state type
type FeedbackState = "idle" | "copied" | "error" | "url-too-long";
export default function CopyPageDropdown(props: CopyPageDropdownProps) {
const { title } = props;
const [isOpen, setIsOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>("idle");
const [feedbackMessage, setFeedbackMessage] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstItemRef = useRef<HTMLButtonElement>(null);
// Clear feedback after delay
const clearFeedback = useCallback(() => {
setTimeout(() => {
setFeedback("idle");
setFeedbackMessage("");
}, 2000);
}, []);
// Close dropdown when clicking outside
useEffect(() => {
@@ -35,47 +153,203 @@ export default function CopyPageDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Handle copy page action
// Handle keyboard navigation
useEffect(() => {
if (!isOpen || !menuRef.current) return;
function handleKeyDown(event: KeyboardEvent) {
const menu = menuRef.current;
if (!menu) return;
const items = menu.querySelectorAll<HTMLButtonElement>(".copy-page-item");
const currentIndex = Array.from(items).findIndex(
(item) => item === document.activeElement
);
switch (event.key) {
case "Escape":
setIsOpen(false);
triggerRef.current?.focus();
break;
case "ArrowDown":
event.preventDefault();
if (currentIndex < items.length - 1) {
items[currentIndex + 1].focus();
} else {
items[0].focus();
}
break;
case "ArrowUp":
event.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1].focus();
} else {
items[items.length - 1].focus();
}
break;
case "Home":
event.preventDefault();
items[0]?.focus();
break;
case "End":
event.preventDefault();
items[items.length - 1]?.focus();
break;
case "Tab":
// Close dropdown on tab out
if (!event.shiftKey && currentIndex === items.length - 1) {
setIsOpen(false);
}
break;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
// Focus first item when dropdown opens
useEffect(() => {
if (isOpen && firstItemRef.current) {
// Small delay to ensure menu is rendered
setTimeout(() => firstItemRef.current?.focus(), 10);
}
}, [isOpen]);
// Safe clipboard write with error handling
const writeToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
// Fallback for older browsers or permission issues
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return true;
} catch {
console.error("Failed to copy to clipboard:", error);
return false;
}
}
};
// Handle copy page action with error handling
const handleCopyPage = async () => {
const markdown = formatAsMarkdown(title, content, url);
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => {
setCopied(false);
const markdown = formatAsMarkdown(props);
const success = await writeToClipboard(markdown);
if (success) {
setFeedback("copied");
setFeedbackMessage("Copied!");
} else {
setFeedback("error");
setFeedbackMessage("Failed to copy");
}
clearFeedback();
setTimeout(() => setIsOpen(false), 1500);
};
// Generic handler for opening AI services
// All services receive the full markdown content directly
const handleOpenInAI = async (service: AIService) => {
const markdown = formatAsMarkdown(props);
const prompt = `Please analyze this article:\n\n${markdown}`;
// Build the target URL using the service's buildUrl function
if (!service.buildUrl) {
// Fallback: copy to clipboard and open base URL
const success = await writeToClipboard(markdown);
if (success) {
setFeedback("url-too-long");
setFeedbackMessage("Copied! Paste in " + service.name);
window.open(service.baseUrl, "_blank");
} else {
setFeedback("error");
setFeedbackMessage("Failed to copy content");
}
clearFeedback();
return;
}
const targetUrl = service.buildUrl(prompt);
// Check URL length - if too long, copy to clipboard instead
if (isUrlTooLong(targetUrl)) {
const success = await writeToClipboard(markdown);
if (success) {
setFeedback("url-too-long");
setFeedbackMessage("Copied! Paste in " + service.name);
window.open(service.baseUrl, "_blank");
} else {
setFeedback("error");
setFeedbackMessage("Failed to copy content");
}
clearFeedback();
} else {
// URL is within limits, open directly with prefilled content
window.open(targetUrl, "_blank");
setIsOpen(false);
}, 1500);
}
};
// Open in ChatGPT with the page content
const handleOpenInChatGPT = () => {
const markdown = formatAsMarkdown(title, content, url);
const encodedText = encodeURIComponent(
`Please analyze this article:\n\n${markdown}`,
);
window.open(`https://chat.openai.com/?q=${encodedText}`, "_blank");
setIsOpen(false);
// Handle download skill file
const handleDownloadSkill = () => {
const skillContent = formatAsSkill(props);
const blob = new Blob([skillContent], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
// Create temporary link and trigger download
const link = document.createElement("a");
link.href = url;
link.download = `${props.slug}-skill.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up object URL
URL.revokeObjectURL(url);
setFeedback("copied");
setFeedbackMessage("Downloaded!");
clearFeedback();
setTimeout(() => setIsOpen(false), 1500);
};
// Open in Claude with the page content
const handleOpenInClaude = () => {
const markdown = formatAsMarkdown(title, content, url);
const encodedText = encodeURIComponent(
`Please analyze this article:\n\n${markdown}`,
);
window.open(`https://claude.ai/new?q=${encodedText}`, "_blank");
setIsOpen(false);
// Get feedback icon
const getFeedbackIcon = () => {
switch (feedback) {
case "copied":
return <Check size={16} className="copy-page-icon feedback-success" />;
case "error":
return <AlertCircle size={16} className="copy-page-icon feedback-error" />;
case "url-too-long":
return <Check size={16} className="copy-page-icon feedback-warning" />;
default:
return <Copy size={16} className="copy-page-icon" />;
}
};
return (
<div className="copy-page-dropdown" ref={dropdownRef}>
{/* Trigger button */}
{/* Trigger button with ARIA attributes */}
<button
ref={triggerRef}
className="copy-page-trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
aria-haspopup="menu"
aria-controls="copy-page-menu"
aria-label={`Copy or share: ${title}`}
>
<Copy size={14} />
<Copy size={14} aria-hidden="true" />
<span>Copy page</span>
<svg
className={`dropdown-chevron ${isOpen ? "open" : ""}`}
@@ -84,6 +358,7 @@ export default function CopyPageDropdown({
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M2.5 4L5 6.5L7.5 4"
@@ -95,46 +370,95 @@ export default function CopyPageDropdown({
</svg>
</button>
{/* Dropdown menu */}
{/* Dropdown menu with ARIA role */}
{isOpen && (
<div className="copy-page-menu">
<div
ref={menuRef}
id="copy-page-menu"
className="copy-page-menu"
role="menu"
aria-label="Copy and share options"
>
{/* Copy page option */}
<button className="copy-page-item" onClick={handleCopyPage}>
<Copy size={16} className="copy-page-icon" />
<button
ref={firstItemRef}
className="copy-page-item"
onClick={handleCopyPage}
role="menuitem"
tabIndex={0}
>
{getFeedbackIcon()}
<div className="copy-page-item-content">
<span className="copy-page-item-title">
{copied ? "Copied!" : "Copy page"}
{feedback !== "idle" ? feedbackMessage : "Copy page"}
</span>
<span className="copy-page-item-desc">
Copy page as Markdown for LLMs
Copy as Markdown for LLMs
</span>
</div>
</button>
{/* Open in ChatGPT */}
<button className="copy-page-item" onClick={handleOpenInChatGPT}>
<MessageSquare size={16} className="copy-page-icon" />
{/* AI service options */}
{AI_SERVICES.map((service) => {
const Icon = service.icon;
return (
<button
key={service.id}
className="copy-page-item"
onClick={() => handleOpenInAI(service)}
role="menuitem"
tabIndex={0}
>
<Icon size={16} className="copy-page-icon" aria-hidden="true" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in {service.name}
<span className="external-arrow" aria-hidden="true"></span>
</span>
<span className="copy-page-item-desc">
{service.description}
</span>
</div>
</button>
);
})}
{/* View as Markdown option */}
<button
className="copy-page-item"
onClick={() => {
window.open(`/raw/${props.slug}.md`, "_blank");
setIsOpen(false);
}}
role="menuitem"
tabIndex={0}
>
<FileText size={16} className="copy-page-icon" aria-hidden="true" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in ChatGPT
<span className="external-arrow"></span>
View as Markdown
<span className="external-arrow" aria-hidden="true"></span>
</span>
<span className="copy-page-item-desc">
Ask questions about this page
Open raw .md file
</span>
</div>
</button>
{/* Open in Claude */}
<button className="copy-page-item" onClick={handleOpenInClaude}>
<Sparkles size={16} className="copy-page-icon" />
{/* Generate Skill option */}
<button
className="copy-page-item"
onClick={handleDownloadSkill}
role="menuitem"
tabIndex={0}
>
<Download size={16} className="copy-page-icon" aria-hidden="true" />
<div className="copy-page-item-content">
<span className="copy-page-item-title">
Open in Claude
<span className="external-arrow"></span>
Generate Skill
</span>
<span className="copy-page-item-desc">
Ask questions about this page
Download as AI agent skill
</span>
</div>
</button>

View File

@@ -12,6 +12,7 @@ interface FeaturedData {
slug: string;
title: string;
excerpt: string;
image?: string; // Thumbnail image for card view
type: "post" | "page";
}
@@ -50,6 +51,7 @@ export default function FeaturedCards({
slug: p.slug,
title: p.title,
excerpt: p.excerpt || p.description,
image: p.image,
type: "post" as const,
featuredOrder: p.featuredOrder,
})),
@@ -57,13 +59,21 @@ export default function FeaturedCards({
slug: p.slug,
title: p.title,
excerpt: p.excerpt || "",
image: p.image,
type: "page" as const,
featuredOrder: p.featuredOrder,
})),
];
// Sort by featuredOrder (lower first)
// Sort: items with images first, then by featuredOrder within each group
return combined.sort((a, b) => {
// Primary sort: items with images come first
const hasImageA = a.image ? 0 : 1;
const hasImageB = b.image ? 0 : 1;
if (hasImageA !== hasImageB) {
return hasImageA - hasImageB;
}
// Secondary sort: by featuredOrder (lower first)
const orderA = a.featuredOrder ?? 999;
const orderB = b.featuredOrder ?? 999;
return orderA - orderB;
@@ -85,6 +95,7 @@ export default function FeaturedCards({
result.push({
title: post.title,
excerpt: post.excerpt || post.description,
image: post.image,
slug: post.slug,
type: "post",
});
@@ -96,6 +107,7 @@ export default function FeaturedCards({
result.push({
title: page.title,
excerpt: page.excerpt || "",
image: page.image,
slug: page.slug,
type: "page",
});
@@ -131,10 +143,23 @@ export default function FeaturedCards({
<div className="featured-cards">
{featuredData.map((item) => (
<a key={item.slug} href={`/${item.slug}`} className="featured-card">
<h3 className="featured-card-title">{item.title}</h3>
{item.excerpt && (
<p className="featured-card-excerpt">{item.excerpt}</p>
{/* Thumbnail image displayed as square using object-fit: cover */}
{item.image && (
<div className="featured-card-image-wrapper">
<img
src={item.image}
alt={item.title}
className="featured-card-image"
loading="lazy"
/>
</div>
)}
<div className="featured-card-content">
<h3 className="featured-card-title">{item.title}</h3>
{item.excerpt && (
<p className="featured-card-excerpt">{item.excerpt}</p>
)}
</div>
</a>
))}
</div>

View File

@@ -1,10 +1,20 @@
import { ReactNode, useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { MagnifyingGlass } from "@phosphor-icons/react";
import ThemeToggle from "./ThemeToggle";
import SearchModal from "./SearchModal";
import MobileMenu, { HamburgerButton } from "./MobileMenu";
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
// Scroll-to-top configuration - enabled by default
// Customize threshold (pixels) to control when button appears
const scrollToTopConfig: Partial<ScrollToTopConfig> = {
enabled: true, // Set to false to disable
threshold: 300, // Show after scrolling 300px
smooth: true, // Smooth scroll animation
};
interface LayoutProps {
children: ReactNode;
@@ -14,6 +24,8 @@ export default function Layout({ children }: LayoutProps) {
// Fetch published pages for navigation
const pages = useQuery(api.pages.getAllPages);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation();
// Open search modal
const openSearch = useCallback(() => {
@@ -25,6 +37,20 @@ export default function Layout({ children }: LayoutProps) {
setIsSearchOpen(false);
}, []);
// Mobile menu handlers
const openMobileMenu = useCallback(() => {
setIsMobileMenuOpen(true);
}, []);
const closeMobileMenu = useCallback(() => {
setIsMobileMenuOpen(false);
}, []);
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
// Handle Command+K / Ctrl+K keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -47,9 +73,17 @@ export default function Layout({ children }: LayoutProps) {
<div className="layout">
{/* Top navigation bar with page links, search, and theme toggle */}
<div className="top-nav">
{/* Page navigation links (optional pages like About, Projects, Contact) */}
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
<div className="mobile-menu-trigger">
<HamburgerButton
onClick={openMobileMenu}
isOpen={isMobileMenuOpen}
/>
</div>
{/* Page navigation links (visible on desktop only) */}
{pages && pages.length > 0 && (
<nav className="page-nav">
<nav className="page-nav desktop-only">
{pages.map((page) => (
<Link
key={page.slug}
@@ -76,10 +110,32 @@ export default function Layout({ children }: LayoutProps) {
</div>
</div>
{/* Mobile menu drawer */}
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
{/* Page navigation links in mobile menu */}
{pages && pages.length > 0 && (
<nav className="mobile-nav-links">
{pages.map((page) => (
<Link
key={page.slug}
to={`/${page.slug}`}
className="mobile-nav-link"
onClick={closeMobileMenu}
>
{page.title}
</Link>
))}
</nav>
)}
</MobileMenu>
<main className="main-content">{children}</main>
{/* Search modal */}
<SearchModal isOpen={isSearchOpen} onClose={closeSearch} />
{/* Scroll to top button */}
<ScrollToTop config={scrollToTopConfig} />
</div>
);
}

View File

@@ -56,8 +56,8 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
{logo.href ? (
<a
href={logo.href}
target="_blank"
rel="noopener noreferrer"
target={logo.href.startsWith("http") ? "_blank" : undefined}
rel={logo.href.startsWith("http") ? "noopener noreferrer" : undefined}
className="logo-marquee-link"
>
<img

View File

@@ -0,0 +1,144 @@
import { ReactNode, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
/**
* Mobile menu drawer component
* Opens from the left side on mobile/tablet views
* Uses CSS transforms for smooth 60fps animations
*/
export default function MobileMenu({
isOpen,
onClose,
children,
}: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// Handle escape key to close menu
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
// Prevent body scroll when menu is open
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
// Focus trap - keep focus within menu when open
useEffect(() => {
if (isOpen && menuRef.current) {
const firstFocusable = menuRef.current.querySelector<HTMLElement>(
'button, a, input, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
}
}, [isOpen]);
// Handle backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<>
{/* Backdrop overlay */}
<div
className={`mobile-menu-backdrop ${isOpen ? "open" : ""}`}
onClick={handleBackdropClick}
aria-hidden="true"
/>
{/* Drawer panel */}
<div
ref={menuRef}
className={`mobile-menu-drawer ${isOpen ? "open" : ""}`}
role="dialog"
aria-modal="true"
aria-label="Site navigation"
>
{/* Close button */}
<button
className="mobile-menu-close"
onClick={onClose}
aria-label="Close menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Menu content */}
<div className="mobile-menu-content">{children}</div>
{/* Home link at bottom */}
<div className="mobile-menu-footer">
<Link to="/" className="mobile-menu-home-link" onClick={onClose}>
Home
</Link>
</div>
</div>
</>
);
}
/**
* Hamburger button component for opening the mobile menu
*/
interface HamburgerButtonProps {
onClick: () => void;
isOpen: boolean;
}
export function HamburgerButton({ onClick, isOpen }: HamburgerButtonProps) {
return (
<button
className="hamburger-button"
onClick={onClick}
aria-label={isOpen ? "Close menu" : "Open menu"}
aria-expanded={isOpen}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
);
}

View File

@@ -0,0 +1,81 @@
import { useState, useEffect, useCallback } from "react";
import { ArrowUp } from "@phosphor-icons/react";
// Scroll-to-top configuration
export interface ScrollToTopConfig {
enabled: boolean; // Show/hide the button
threshold: number; // Pixels scrolled before button appears
smooth: boolean; // Use smooth scrolling animation
}
// Default configuration - enabled by default
export const defaultScrollToTopConfig: ScrollToTopConfig = {
enabled: true,
threshold: 300, // Show after scrolling 300px
smooth: true,
};
interface ScrollToTopProps {
config?: Partial<ScrollToTopConfig>;
}
/**
* Scroll-to-top button component
* Appears after user scrolls past threshold
* Uses Phosphor ArrowUp icon and theme-aware styling
*/
export default function ScrollToTop({ config }: ScrollToTopProps) {
// Merge provided config with defaults
const mergedConfig: ScrollToTopConfig = {
...defaultScrollToTopConfig,
...config,
};
const [isVisible, setIsVisible] = useState(false);
// Check scroll position and update visibility
const checkScrollPosition = useCallback(() => {
const scrollY = window.scrollY || document.documentElement.scrollTop;
setIsVisible(scrollY > mergedConfig.threshold);
}, [mergedConfig.threshold]);
// Set up scroll listener
useEffect(() => {
if (!mergedConfig.enabled) return;
// Check initial position
checkScrollPosition();
// Add scroll listener with passive flag for performance
window.addEventListener("scroll", checkScrollPosition, { passive: true });
return () => {
window.removeEventListener("scroll", checkScrollPosition);
};
}, [mergedConfig.enabled, checkScrollPosition]);
// Scroll to top handler
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: mergedConfig.smooth ? "smooth" : "auto",
});
};
// Don't render if disabled or not visible
if (!mergedConfig.enabled || !isVisible) {
return null;
}
return (
<button
className="scroll-to-top"
onClick={scrollToTop}
aria-label="Scroll to top"
title="Scroll to top"
>
<ArrowUp size={20} weight="bold" />
</button>
);
}

View File

@@ -6,8 +6,8 @@ import { api } from "../../convex/_generated/api";
// Heartbeat interval: 30 seconds
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
// Minimum time between heartbeats to prevent write conflicts: 5 seconds
const HEARTBEAT_DEBOUNCE_MS = 5 * 1000;
// Minimum time between heartbeats to prevent write conflicts: 10 seconds (matches backend dedup window)
const HEARTBEAT_DEBOUNCE_MS = 10 * 1000;
// Session ID key in localStorage
const SESSION_ID_KEY = "markdown_blog_session_id";

View File

@@ -13,12 +13,13 @@ import LogoMarquee, {
const siteConfig = {
// Basic site info
name: 'markdown "sync" site',
title: "Real-time Site with Convex",
title: "markdown sync site",
// Optional logo/header image (place in public/images/, set to null to hide)
logo: "/images/logo.svg" as string | null,
intro: (
<>
An open source markdown blog powered by Convex and deployed on Netlify.{" "}
An open-source markdown "sync" site you publish from the terminal with npm
run sync.{" "}
<a
href="https://github.com/waynesutton/markdown-site"
target="_blank"
@@ -30,11 +31,11 @@ const siteConfig = {
, customize it, ship it.
</>
),
bio: `Write in markdown, sync to a real-time database, and deploy in minutes. Every time you sync new posts, they appear immediately without redeploying. Built with React, TypeScript, and Convex for instant updates.`,
bio: `Write locally, sync instantly, skip the build. Powered by Convex and Netlify.`,
// Featured section configuration
// viewMode: 'list' shows bullet list, 'cards' shows card grid with excerpts
featuredViewMode: "list" as "cards" | "list",
featuredViewMode: "cards" as "cards" | "list",
// Allow users to toggle between list and card views
showViewToggle: true,
@@ -50,8 +51,8 @@ const siteConfig = {
href: "https://markdowncms.netlify.app/",
},
{
src: "/images/logos/sample-logo-2.svg",
href: "https://markdowncms.netlify.app/",
src: "/images/logos/convex-wordmark-black.svg",
href: "/about#the-real-time-twist",
},
{
src: "/images/logos/sample-logo-3.svg",
@@ -68,7 +69,7 @@ const siteConfig = {
] as LogoItem[],
position: "above-footer", // 'above-footer' or 'below-featured'
speed: 30, // Seconds for one complete scroll cycle
title: "Trusted by", // Optional title above the marquee (set to undefined to hide)
title: "Trusted by (sample logos)", // Optional title above the marquee (set to undefined to hide)
} as LogoGalleryConfig,
// Links for footer section

View File

@@ -1,4 +1,4 @@
import { useParams, Link, useNavigate } from "react-router-dom";
import { useParams, Link, useNavigate, useLocation } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import BlogPost from "../components/BlogPost";
@@ -9,17 +9,35 @@ import { useState, useEffect } from "react";
// Site configuration
const SITE_URL = "https://markdowncms.netlify.app";
const SITE_NAME = "Markdown Site";
const SITE_NAME = "markdown sync site";
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
export default function Post() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const location = useLocation();
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
const [copied, setCopied] = useState(false);
// Scroll to hash anchor after content loads
useEffect(() => {
if (!location.hash) return;
if (page === undefined && post === undefined) return;
// Small delay to ensure content is rendered
const timer = setTimeout(() => {
const id = location.hash.slice(1);
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
}, 100);
return () => clearTimeout(timer);
}, [location.hash, page, post]);
// Update page title for static pages
useEffect(() => {
if (!page) return;
@@ -137,6 +155,8 @@ export default function Post() {
title={page.title}
content={page.content}
url={window.location.href}
slug={page.slug}
description={page.excerpt}
/>
</nav>
@@ -191,11 +211,16 @@ export default function Post() {
<ArrowLeft size={16} />
<span>Back</span>
</button>
{/* Copy page dropdown for sharing */}
{/* Copy page dropdown for sharing with full metadata */}
<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}
/>
</nav>

View File

@@ -133,12 +133,16 @@ body {
/* Top navigation bar */
.top-nav {
position: fixed;
top: 24px;
right: 24px;
top: 10px;
right: 13px;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
/* Themed background to prevent content overlap on scroll */
background-color: var(--bg-primary);
padding: 8px 12px;
border-radius: 8px;
}
/* Page navigation links (About, Projects, Contact, etc.) */
@@ -475,6 +479,19 @@ body {
color: var(--text-secondary);
}
/* Feedback states for copy/share actions */
.copy-page-icon.feedback-success {
color: #22c55e;
}
.copy-page-icon.feedback-error {
color: #ef4444;
}
.copy-page-icon.feedback-warning {
color: #f59e0b;
}
.copy-page-item-content {
display: flex;
flex-direction: column;
@@ -650,6 +667,67 @@ body {
margin: 48px 0;
}
/* Table styles - GitHub-style tables */
.blog-table-wrapper {
overflow-x: auto;
margin: 24px 0;
-webkit-overflow-scrolling: touch;
}
.blog-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-size: 14px;
line-height: 1.5;
}
.blog-thead {
background-color: var(--bg-secondary);
}
.blog-th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border: 1px solid var(--border-color);
white-space: nowrap;
}
.blog-td {
padding: 12px 16px;
text-align: left;
color: var(--text-secondary);
border: 1px solid var(--border-color);
vertical-align: top;
}
/* Alternating row colors */
.blog-tbody .blog-tr:nth-child(even) {
background-color: var(--bg-secondary);
}
.blog-tbody .blog-tr:hover {
background-color: var(--bg-hover);
}
/* Inline code within tables */
.blog-td code,
.blog-th code {
background-color: var(--inline-code-bg);
padding: 2px 6px;
border-radius: 4px;
font-family:
SF Mono,
Monaco,
Cascadia Code,
Roboto Mono,
Consolas,
monospace;
font-size: 13px;
}
/* Code styles */
.code-block-wrapper {
position: relative;
@@ -841,13 +919,14 @@ body {
/* Responsive styles */
@media (max-width: 768px) {
.main-content {
padding: 24px 16px;
padding: 40px 16px 16px 24px;
}
.top-nav {
top: 16px;
top: 6px;
right: 16px;
gap: 12px;
padding: 6px 10px;
}
.page-nav {
@@ -904,6 +983,16 @@ body {
font-size: 13px;
}
/* Table mobile styles */
.blog-table {
font-size: 13px;
}
.blog-th,
.blog-td {
padding: 8px 12px;
}
/* Copy page dropdown mobile */
.copy-page-menu {
width: 260px;
@@ -1556,13 +1645,14 @@ body {
}
.featured-card {
display: block;
padding: 20px;
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
text-decoration: none;
transition: all 0.15s ease;
overflow: hidden;
}
.featured-card:hover {
@@ -1570,6 +1660,38 @@ body {
border-color: var(--text-muted);
}
/* Thumbnail image wrapper with square aspect ratio */
.featured-card-image-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
flex-shrink: 0;
}
/* Image displays as square regardless of original aspect ratio */
.featured-card-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transition: transform 0.2s ease;
}
.featured-card:hover .featured-card-image {
transform: scale(1.03);
}
/* Content wrapper for text below image */
.featured-card-content {
padding: 16px;
flex-grow: 1;
}
/* Cards without images get padding directly */
.featured-card:not(:has(.featured-card-image-wrapper)) {
padding: 20px;
}
.featured-card-title {
font-size: 16px;
font-weight: 500;
@@ -1722,10 +1844,14 @@ body {
gap: 12px;
}
.featured-card {
.featured-card:not(:has(.featured-card-image-wrapper)) {
padding: 16px;
}
.featured-card-content {
padding: 12px;
}
.featured-card-title {
font-size: 15px;
}
@@ -1750,6 +1876,11 @@ body {
grid-template-columns: 1fr;
}
/* On mobile single column, use smaller square aspect ratio */
.featured-card-image-wrapper {
aspect-ratio: 16 / 9;
}
.view-toggle-button {
width: 32px;
height: 32px;
@@ -1764,3 +1895,320 @@ body {
max-width: 80px;
}
}
/* ===========================================
MOBILE MENU STYLES
Left-side drawer for mobile/tablet navigation
=========================================== */
/* Hide hamburger on desktop, show on mobile/tablet */
.mobile-menu-trigger {
display: none;
}
/* Show desktop nav by default */
.desktop-only {
display: flex;
}
@media (max-width: 768px) {
.mobile-menu-trigger {
display: flex !important;
align-items: center;
}
.desktop-only {
display: none !important;
}
}
/* Hamburger button */
.hamburger-button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.hamburger-button:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
/* Mobile menu backdrop */
.mobile-menu-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 998;
opacity: 0;
visibility: hidden;
transition:
opacity 0.25s ease,
visibility 0.25s ease;
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.mobile-menu-backdrop.open {
opacity: 1;
visibility: visible;
}
/* Mobile menu drawer */
.mobile-menu-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 280px;
max-width: 85vw;
background-color: var(--bg-primary);
z-index: 999;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
overflow-y: auto;
overscroll-behavior: contain;
}
.mobile-menu-drawer.open {
transform: translateX(0);
}
/* Close button */
.mobile-menu-close {
position: absolute;
top: 12px;
right: 12px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition:
color 0.2s ease,
background-color 0.2s ease;
}
.mobile-menu-close:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
/* Menu content area */
.mobile-menu-content {
flex: 1;
padding: 60px 24px 24px;
overflow-y: auto;
}
/* Mobile navigation links */
.mobile-nav-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.mobile-nav-link {
display: block;
padding: 12px 16px;
color: var(--text-primary);
text-decoration: none;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mobile-nav-link:hover {
background-color: var(--bg-hover);
}
/* Menu footer with home link */
.mobile-menu-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
}
.mobile-menu-home-link {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
border-radius: 8px;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mobile-menu-home-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
/* Theme-specific adjustments */
:root[data-theme="dark"] .mobile-menu-backdrop {
background-color: rgba(0, 0, 0, 0.6);
}
:root[data-theme="dark"] .mobile-menu-drawer {
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
}
:root[data-theme="tan"] .mobile-menu-backdrop {
background-color: rgba(139, 115, 85, 0.25);
}
:root[data-theme="tan"] .mobile-menu-drawer {
box-shadow: 4px 0 24px rgba(139, 115, 85, 0.15);
}
:root[data-theme="cloud"] .mobile-menu-backdrop {
background-color: rgba(100, 116, 139, 0.2);
}
:root[data-theme="cloud"] .mobile-menu-drawer {
box-shadow: 4px 0 24px rgba(100, 116, 139, 0.12);
}
/* Tablet adjustments */
@media (min-width: 481px) and (max-width: 768px) {
.mobile-menu-drawer {
width: 320px;
}
}
/* Small mobile */
@media (max-width: 480px) {
.mobile-menu-drawer {
width: 100%;
max-width: 100%;
}
.mobile-menu-content {
padding: 56px 20px 20px;
}
.mobile-menu-footer {
padding: 12px 20px;
}
}
/* Desktop - hide mobile menu components */
@media (min-width: 769px) {
.mobile-menu-trigger,
.mobile-menu-backdrop,
.mobile-menu-drawer,
.hamburger-button {
display: none !important;
}
.desktop-only {
display: flex !important;
}
}
/* ===== Scroll to Top Button ===== */
.scroll-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 44px;
height: 44px;
border-radius: 50%;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: scrollToTopFadeIn 0.2s ease;
}
@keyframes scrollToTopFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.scroll-to-top:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top:active {
transform: translateY(0);
}
/* Theme-specific shadows */
:root[data-theme="dark"] .scroll-to-top {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
:root[data-theme="dark"] .scroll-to-top:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:root[data-theme="tan"] .scroll-to-top {
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.1);
}
:root[data-theme="tan"] .scroll-to-top:hover {
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.15);
}
:root[data-theme="cloud"] .scroll-to-top {
box-shadow: 0 2px 8px rgba(100, 116, 139, 0.1);
}
:root[data-theme="cloud"] .scroll-to-top:hover {
box-shadow: 0 4px 12px rgba(100, 116, 139, 0.15);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.scroll-to-top {
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
}
}
@media (max-width: 480px) {
.scroll-to-top {
bottom: 16px;
right: 16px;
}
}