mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 12:19:18 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
144
src/components/MobileMenu.tsx
Normal file
144
src/components/MobileMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
81
src/components/ScrollToTop.tsx
Normal file
81
src/components/ScrollToTop.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user