diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 3d8e8e7..c77a680 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,10 +1,179 @@
+import React, { useState } from "react";
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 { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
+import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash";
+import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
+import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript";
+import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown";
+import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff";
+import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
+import { Copy, Check } from "lucide-react";
+import { useTheme } from "../context/ThemeContext";
+import DiffCodeBlock from "./DiffCodeBlock";
import siteConfig from "../config/siteConfig";
+// Register languages for syntax highlighting
+SyntaxHighlighter.registerLanguage("bash", bash);
+SyntaxHighlighter.registerLanguage("shell", bash);
+SyntaxHighlighter.registerLanguage("sh", bash);
+SyntaxHighlighter.registerLanguage("javascript", javascript);
+SyntaxHighlighter.registerLanguage("js", javascript);
+SyntaxHighlighter.registerLanguage("typescript", typescript);
+SyntaxHighlighter.registerLanguage("ts", typescript);
+SyntaxHighlighter.registerLanguage("markdown", markdown);
+SyntaxHighlighter.registerLanguage("md", markdown);
+SyntaxHighlighter.registerLanguage("diff", diff);
+SyntaxHighlighter.registerLanguage("json", json);
+
+// Cursor Dark Theme for syntax highlighting
+const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#e4e4e7",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#e4e4e7",
+ background: "#18181b",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#71717a" },
+ punctuation: { color: "#a1a1aa" },
+ property: { color: "#93c5fd" },
+ string: { color: "#86efac" },
+ keyword: { color: "#c4b5fd" },
+ function: { color: "#fcd34d" },
+ number: { color: "#fdba74" },
+ operator: { color: "#f9a8d4" },
+ "class-name": { color: "#93c5fd" },
+ boolean: { color: "#fdba74" },
+ variable: { color: "#e4e4e7" },
+ "attr-name": { color: "#93c5fd" },
+ "attr-value": { color: "#86efac" },
+ tag: { color: "#f87171" },
+ deleted: { color: "#f87171", background: "rgba(248, 113, 113, 0.1)" },
+ inserted: { color: "#86efac", background: "rgba(134, 239, 172, 0.1)" },
+};
+
+// Cursor Light Theme for syntax highlighting
+const cursorLightTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#27272a",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#27272a",
+ background: "#f4f4f5",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#71717a" },
+ punctuation: { color: "#52525b" },
+ property: { color: "#2563eb" },
+ string: { color: "#16a34a" },
+ keyword: { color: "#7c3aed" },
+ function: { color: "#ca8a04" },
+ number: { color: "#ea580c" },
+ operator: { color: "#db2777" },
+ "class-name": { color: "#2563eb" },
+ boolean: { color: "#ea580c" },
+ variable: { color: "#27272a" },
+ "attr-name": { color: "#2563eb" },
+ "attr-value": { color: "#16a34a" },
+ tag: { color: "#dc2626" },
+ deleted: { color: "#dc2626", background: "rgba(220, 38, 38, 0.1)" },
+ inserted: { color: "#16a34a", background: "rgba(22, 163, 74, 0.1)" },
+};
+
+// Tan Theme for syntax highlighting
+const cursorTanTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#44403c",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#44403c",
+ background: "#f5f5f0",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#78716c" },
+ punctuation: { color: "#57534e" },
+ property: { color: "#1d4ed8" },
+ string: { color: "#15803d" },
+ keyword: { color: "#6d28d9" },
+ function: { color: "#a16207" },
+ number: { color: "#c2410c" },
+ operator: { color: "#be185d" },
+ "class-name": { color: "#1d4ed8" },
+ boolean: { color: "#c2410c" },
+ variable: { color: "#44403c" },
+ "attr-name": { color: "#1d4ed8" },
+ "attr-value": { color: "#15803d" },
+ tag: { color: "#b91c1c" },
+ deleted: { color: "#b91c1c", background: "rgba(185, 28, 28, 0.1)" },
+ inserted: { color: "#15803d", background: "rgba(21, 128, 61, 0.1)" },
+};
+
+// Copy button component for code blocks
+function CodeCopyButton({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ );
+}
+
// Sanitize schema for footer markdown (allows links, paragraphs, line breaks, images)
// style attribute is sanitized by rehypeSanitize to remove dangerous CSS
const footerSanitizeSchema = {
@@ -25,8 +194,23 @@ interface FooterProps {
}
export default function Footer({ content }: FooterProps) {
+ const { theme } = useTheme();
const { footer } = siteConfig;
+ // Get code theme based on current theme
+ const getCodeTheme = () => {
+ switch (theme) {
+ case "dark":
+ return cursorDarkTheme;
+ case "light":
+ return cursorLightTheme;
+ case "tan":
+ return cursorTanTheme;
+ default:
+ return cursorDarkTheme;
+ }
+ };
+
// Don't render if footer is globally disabled
if (!footer.enabled) {
return null;
@@ -81,6 +265,77 @@ export default function Footer({ content }: FooterProps) {
);
},
+ // Code blocks with syntax highlighting
+ code(codeProps) {
+ const { className, children, style, ...restProps } =
+ codeProps as {
+ className?: string;
+ children?: React.ReactNode;
+ style?: React.CSSProperties;
+ };
+ const match = /language-(\w+)/.exec(className || "");
+
+ // Detect inline code vs code blocks
+ const codeContent = String(children);
+ const hasNewlines = codeContent.includes("\n");
+ const isShort = codeContent.length < 80;
+ const hasLanguage = !!match || !!className;
+
+ // It's inline only if: no language, short content, no newlines
+ const isInline = !hasLanguage && isShort && !hasNewlines;
+
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const codeString = String(children).replace(/\n$/, "");
+ const language = match ? match[1] : "text";
+
+ // Route diff/patch to DiffCodeBlock for enhanced diff rendering
+ if (language === "diff" || language === "patch") {
+ return (
+
+ );
+ }
+
+ const isTextBlock = language === "text";
+
+ // Custom styles for text blocks to enable wrapping
+ const textBlockStyle = isTextBlock
+ ? {
+ whiteSpace: "pre-wrap" as const,
+ wordWrap: "break-word" as const,
+ overflowWrap: "break-word" as const,
+ }
+ : {};
+
+ return (
+
+ {match && {match[1]}}
+
+
+ {codeString}
+
+
+ );
+ },
}}
>
{footerContent}
diff --git a/src/config/siteConfig.ts b/src/config/siteConfig.ts
index 37bead2..8c62b23 100644
--- a/src/config/siteConfig.ts
+++ b/src/config/siteConfig.ts
@@ -272,6 +272,13 @@ export interface AskAIConfig {
models: AIModelOption[]; // Available models for Ask AI
}
+// Related posts configuration
+// Controls the display of related posts at the bottom of blog posts
+export interface RelatedPostsConfig {
+ defaultViewMode: "list" | "thumbnails"; // Default view mode for related posts
+ showViewToggle: boolean; // Show toggle button to switch between views
+}
+
// Social link configuration for social footer
export interface SocialLink {
platform:
@@ -413,13 +420,16 @@ export interface SiteConfig {
// Ask AI configuration (optional)
askAI?: AskAIConfig;
+
+ // Related posts configuration (optional)
+ relatedPosts?: RelatedPostsConfig;
}
// Default site configuration
// Customize this for your site
export const siteConfig: SiteConfig = {
// Basic site info
- name: 'markdown "sync" framework',
+ name: "markdown sync",
title: "markdown sync framework",
// Optional logo/header image (place in public/images/, set to null to hide)
logo: "/images/logo.svg",
@@ -836,6 +846,13 @@ export const siteConfig: SiteConfig = {
},
],
},
+
+ // Related posts configuration
+ // Controls the display of related posts at the bottom of blog posts
+ relatedPosts: {
+ defaultViewMode: "thumbnails", // Default view: "list" or "thumbnails"
+ showViewToggle: true, // Show toggle button to switch between views
+ },
};
// Export the config as default for easy importing
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 5ddb05c..1004783 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -276,6 +276,7 @@ interface ConfirmDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
+ onCopy: () => void;
title: string;
itemName: string;
itemType: "post" | "page";
@@ -286,11 +287,20 @@ function ConfirmDeleteModal({
isOpen,
onClose,
onConfirm,
+ onCopy,
title,
itemName,
itemType,
isDeleting,
}: ConfirmDeleteModalProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await onCopy();
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && !isDeleting) {
onClose();
@@ -309,6 +319,13 @@ function ConfirmDeleteModal({
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, onClose, isDeleting]);
+ // Reset copied state when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setCopied(false);
+ }
+ }, [isOpen]);
+
if (!isOpen) return null;
return (
@@ -340,6 +357,30 @@ function ConfirmDeleteModal({
This action cannot be undone. The {itemType} will be permanently
removed from the database.
+
+
+
+ Would you like to copy the markdown before deleting?
+
+
+
@@ -670,7 +711,8 @@ function DashboardContent() {
id: string;
title: string;
type: "post" | "page";
- }>({ isOpen: false, id: "", title: "", type: "post" });
+ item: ContentItem | null;
+ }>({ isOpen: false, id: "", title: "", type: "post", item: null });
const [isDeleting, setIsDeleting] = useState(false);
// Sync server state
@@ -862,12 +904,13 @@ function DashboardContent() {
// Show delete confirmation modal for a post
const handleDeletePost = useCallback(
- (id: string, title: string) => {
+ (item: ContentItem) => {
setDeleteModal({
isOpen: true,
- id,
- title,
+ id: item._id,
+ title: item.title,
type: "post",
+ item,
});
},
[],
@@ -875,12 +918,13 @@ function DashboardContent() {
// Show delete confirmation modal for a page
const handleDeletePage = useCallback(
- (id: string, title: string) => {
+ (item: ContentItem) => {
setDeleteModal({
isOpen: true,
- id,
- title,
+ id: item._id,
+ title: item.title,
type: "page",
+ item,
});
},
[],
@@ -889,7 +933,7 @@ function DashboardContent() {
// Close delete modal
const closeDeleteModal = useCallback(() => {
if (!isDeleting) {
- setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
+ setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
}
}, [isDeleting]);
@@ -904,7 +948,7 @@ function DashboardContent() {
await deletePageMutation({ id: deleteModal.id as Id<"pages"> });
addToast("Page deleted successfully", "success");
}
- setDeleteModal({ isOpen: false, id: "", title: "", type: "post" });
+ setDeleteModal({ isOpen: false, id: "", title: "", type: "post", item: null });
} catch (error) {
addToast(
error instanceof Error ? error.message : `Failed to delete ${deleteModal.type}`,
@@ -1005,6 +1049,14 @@ function DashboardContent() {
[],
);
+ // Copy markdown content before deletion
+ const handleCopyBeforeDelete = useCallback(async () => {
+ if (!deleteModal.item) return;
+ const markdown = generateMarkdown(deleteModal.item, deleteModal.type);
+ await navigator.clipboard.writeText(markdown);
+ addToast("Markdown copied to clipboard", "success");
+ }, [deleteModal, generateMarkdown, addToast]);
+
// Download markdown file
const handleDownloadMarkdown = useCallback(() => {
if (!editingItem) return;
@@ -1166,6 +1218,7 @@ function DashboardContent() {
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={confirmDelete}
+ onCopy={handleCopyBeforeDelete}
title="Delete Confirmation"
itemName={deleteModal.title}
itemType={deleteModal.type}
@@ -1275,7 +1328,7 @@ function DashboardContent() {
setSearchQuery(e.target.value)}
className="dashboard-search-input"
@@ -1414,6 +1467,7 @@ function DashboardContent() {
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast}
+ setActiveSection={setActiveSection}
/>
)}
@@ -1424,6 +1478,7 @@ function DashboardContent() {
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
addToast={addToast}
+ setActiveSection={setActiveSection}
/>
)}
@@ -1503,7 +1558,7 @@ function PostsListView({
posts: ContentItem[];
onEdit: (post: ContentItem) => void;
searchQuery: string;
- onDelete: (id: string, title: string) => void;
+ onDelete: (item: ContentItem) => void;
}) {
const [filter, setFilter] = useState<"all" | "published" | "draft">("all");
const [itemsPerPage, setItemsPerPage] = useState(15);
@@ -1651,7 +1706,7 @@ function PostsListView({
{post.source === "dashboard" && (
+ {/* Related Posts */}
+
+
Related Posts
+
+
+
+
+
+
+
+
+ Controls the display of related posts at the bottom of blog posts. Thumbnails view shows image, title, description and author.
+
+
+
{/* Version Control */}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 60327a9..84f98f3 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -4,6 +4,17 @@ import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
+import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash";
+import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
+import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript";
+import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown";
+import diff from "react-syntax-highlighter/dist/esm/languages/prism/diff";
+import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
+import { Copy, Check } from "lucide-react";
+import { useTheme } from "../context/ThemeContext";
import PostList from "../components/PostList";
import FeaturedCards from "../components/FeaturedCards";
import LogoMarquee from "../components/LogoMarquee";
@@ -11,8 +22,200 @@ import GitHubContributions from "../components/GitHubContributions";
import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup";
+import DiffCodeBlock from "../components/DiffCodeBlock";
import siteConfig from "../config/siteConfig";
+// Sanitize schema for home intro markdown
+const homeSanitizeSchema = {
+ ...defaultSchema,
+ attributes: {
+ ...defaultSchema.attributes,
+ span: ["className", "class", "style"],
+ },
+};
+
+// Register languages for syntax highlighting
+SyntaxHighlighter.registerLanguage("bash", bash);
+SyntaxHighlighter.registerLanguage("shell", bash);
+SyntaxHighlighter.registerLanguage("sh", bash);
+SyntaxHighlighter.registerLanguage("javascript", javascript);
+SyntaxHighlighter.registerLanguage("js", javascript);
+SyntaxHighlighter.registerLanguage("typescript", typescript);
+SyntaxHighlighter.registerLanguage("ts", typescript);
+SyntaxHighlighter.registerLanguage("markdown", markdown);
+SyntaxHighlighter.registerLanguage("md", markdown);
+SyntaxHighlighter.registerLanguage("diff", diff);
+SyntaxHighlighter.registerLanguage("json", json);
+
+// Cursor Dark Theme for syntax highlighting
+const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#e4e4e7",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#e4e4e7",
+ background: "#18181b",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#71717a" },
+ punctuation: { color: "#a1a1aa" },
+ property: { color: "#93c5fd" },
+ string: { color: "#86efac" },
+ keyword: { color: "#c4b5fd" },
+ function: { color: "#fcd34d" },
+ number: { color: "#fdba74" },
+ operator: { color: "#f9a8d4" },
+ "class-name": { color: "#93c5fd" },
+ boolean: { color: "#fdba74" },
+ variable: { color: "#e4e4e7" },
+ "attr-name": { color: "#93c5fd" },
+ "attr-value": { color: "#86efac" },
+ tag: { color: "#f87171" },
+ deleted: { color: "#f87171", background: "rgba(248, 113, 113, 0.1)" },
+ inserted: { color: "#86efac", background: "rgba(134, 239, 172, 0.1)" },
+};
+
+// Cursor Light Theme for syntax highlighting
+const cursorLightTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#27272a",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#27272a",
+ background: "#f4f4f5",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#71717a" },
+ punctuation: { color: "#52525b" },
+ property: { color: "#2563eb" },
+ string: { color: "#16a34a" },
+ keyword: { color: "#7c3aed" },
+ function: { color: "#ca8a04" },
+ number: { color: "#ea580c" },
+ operator: { color: "#db2777" },
+ "class-name": { color: "#2563eb" },
+ boolean: { color: "#ea580c" },
+ variable: { color: "#27272a" },
+ "attr-name": { color: "#2563eb" },
+ "attr-value": { color: "#16a34a" },
+ tag: { color: "#dc2626" },
+ deleted: { color: "#dc2626", background: "rgba(220, 38, 38, 0.1)" },
+ inserted: { color: "#16a34a", background: "rgba(22, 163, 74, 0.1)" },
+};
+
+// Tan Theme for syntax highlighting
+const cursorTanTheme: { [key: string]: React.CSSProperties } = {
+ 'code[class*="language-"]': {
+ color: "#44403c",
+ background: "none",
+ fontFamily: "var(--font-mono)",
+ fontSize: "0.9em",
+ textAlign: "left",
+ whiteSpace: "pre",
+ wordSpacing: "normal",
+ wordBreak: "normal",
+ wordWrap: "normal",
+ lineHeight: "1.6",
+ tabSize: 2,
+ },
+ 'pre[class*="language-"]': {
+ color: "#44403c",
+ background: "#f5f5f0",
+ padding: "1.25em",
+ margin: "0",
+ overflow: "auto",
+ borderRadius: "0.5rem",
+ },
+ comment: { color: "#78716c" },
+ punctuation: { color: "#57534e" },
+ property: { color: "#1d4ed8" },
+ string: { color: "#15803d" },
+ keyword: { color: "#6d28d9" },
+ function: { color: "#a16207" },
+ number: { color: "#c2410c" },
+ operator: { color: "#be185d" },
+ "class-name": { color: "#1d4ed8" },
+ boolean: { color: "#c2410c" },
+ variable: { color: "#44403c" },
+ "attr-name": { color: "#1d4ed8" },
+ "attr-value": { color: "#15803d" },
+ tag: { color: "#b91c1c" },
+ deleted: { color: "#b91c1c", background: "rgba(185, 28, 28, 0.1)" },
+ inserted: { color: "#15803d", background: "rgba(21, 128, 61, 0.1)" },
+};
+
+// Copy button component for code blocks
+function CodeCopyButton({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ {copied ? : }
+
+ );
+}
+
+// Inline copy button for copy-command spans
+function InlineCopyButton({ command }: { command: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ await navigator.clipboard.writeText(command);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ {copied ? : }
+
+ );
+}
+
// Local storage key for view mode preference
const VIEW_MODE_KEY = "featured-view-mode";
@@ -87,6 +290,8 @@ function HeadingAnchor({ id }: { id: string }) {
}
export default function Home() {
+ const { theme } = useTheme();
+
// Fetch published posts from Convex (only if showing on home)
const posts = useQuery(
api.posts.getAllPosts,
@@ -108,6 +313,20 @@ export default function Home() {
siteConfig.featuredViewMode,
);
+ // Get code theme based on current theme
+ const getCodeTheme = () => {
+ switch (theme) {
+ case "dark":
+ return cursorDarkTheme;
+ case "light":
+ return cursorLightTheme;
+ case "tan":
+ return cursorTanTheme;
+ default:
+ return cursorDarkTheme;
+ }
+ };
+
// Load saved view mode preference from localStorage
useEffect(() => {
const saved = localStorage.getItem(VIEW_MODE_KEY);
@@ -189,6 +408,7 @@ export default function Home() {
>
(
@@ -280,6 +500,90 @@ export default function Home() {
hr() {
return
;
},
+ // Code blocks with syntax highlighting
+ code(codeProps) {
+ const { className, children, style, ...restProps } =
+ codeProps as {
+ className?: string;
+ children?: React.ReactNode;
+ style?: React.CSSProperties;
+ };
+ const match = /language-(\w+)/.exec(className || "");
+
+ // Detect inline code vs code blocks
+ const codeContent = String(children);
+ const hasNewlines = codeContent.includes("\n");
+ const isShort = codeContent.length < 80;
+ const hasLanguage = !!match || !!className;
+
+ // It's inline only if: no language, short content, no newlines
+ const isInline = !hasLanguage && isShort && !hasNewlines;
+
+ if (isInline) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ const codeString = String(children).replace(/\n$/, "");
+ const language = match ? match[1] : "text";
+
+ // Route diff/patch to DiffCodeBlock for enhanced diff rendering
+ if (language === "diff" || language === "patch") {
+ return (
+
+ );
+ }
+
+ const isTextBlock = language === "text";
+
+ // Custom styles for text blocks to enable wrapping
+ const textBlockStyle = isTextBlock
+ ? {
+ whiteSpace: "pre-wrap" as const,
+ wordWrap: "break-word" as const,
+ overflowWrap: "break-word" as const,
+ }
+ : {};
+
+ return (
+
+ {match && {match[1]}}
+
+
+ {codeString}
+
+
+ );
+ },
+ // Span component with copy-command support
+ span({ className, children }) {
+ if (className === "copy-command") {
+ const command = getTextContent(children);
+ return (
+
+ {command}
+
+
+ );
+ }
+ return {children};
+ },
}}
>
{stripHtmlComments(homeIntro.content)}
diff --git a/src/pages/Post.tsx b/src/pages/Post.tsx
index 2a30fd0..f3eb673 100644
--- a/src/pages/Post.tsx
+++ b/src/pages/Post.tsx
@@ -15,9 +15,12 @@ import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Rss, Tag } from "lucide-react";
import { XLogo, LinkedinLogo } from "@phosphor-icons/react";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import siteConfig from "../config/siteConfig";
+// Local storage key for related posts view mode preference
+const RELATED_POSTS_VIEW_MODE_KEY = "related-posts-view-mode";
+
// Site configuration - update these for your site (or run npm run configure)
const SITE_URL = "https://www.markdown.fast";
const SITE_NAME = "markdown sync framework";
@@ -87,6 +90,28 @@ export default function Post({
const [copied, setCopied] = useState(false);
+ // State for related posts view mode toggle (list or thumbnails)
+ const [relatedPostsViewMode, setRelatedPostsViewMode] = useState<"list" | "thumbnails">(
+ siteConfig.relatedPosts?.defaultViewMode ?? "thumbnails",
+ );
+
+ // Load saved related posts view mode preference from localStorage
+ useEffect(() => {
+ const saved = localStorage.getItem(RELATED_POSTS_VIEW_MODE_KEY);
+ if (saved === "list" || saved === "thumbnails") {
+ setRelatedPostsViewMode(saved);
+ }
+ }, []);
+
+ // Toggle related posts view mode and save preference
+ const toggleRelatedPostsViewMode = useCallback(() => {
+ setRelatedPostsViewMode((prev) => {
+ const newMode = prev === "list" ? "thumbnails" : "list";
+ localStorage.setItem(RELATED_POSTS_VIEW_MODE_KEY, newMode);
+ return newMode;
+ });
+ }, []);
+
// Scroll to hash anchor after content loads
// Skip if there's a search query - let the highlighting hook handle scroll
useEffect(() => {
@@ -859,26 +884,125 @@ export default function Post({
{/* Related posts section - only shown for blog posts with shared tags */}
{relatedPosts && relatedPosts.length > 0 && (
)}
diff --git a/src/styles/global.css b/src/styles/global.css
index 90216bb..1403c01 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1774,6 +1774,33 @@ body {
font-size: var(--font-size-inline-code);
}
+/* Diff fallback styles for invalid diff format */
+.diff-fallback {
+ background: var(--code-bg);
+ padding: 1.25em;
+ margin: 0;
+ overflow: auto;
+ border-radius: 0.5rem;
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+ line-height: 1.6;
+}
+
+.diff-fallback code {
+ background: none;
+ padding: 0;
+}
+
+.diff-added {
+ color: var(--color-success, #86efac);
+ background: rgba(134, 239, 172, 0.1);
+}
+
+.diff-removed {
+ color: var(--color-error, #f87171);
+ background: rgba(248, 113, 113, 0.1);
+}
+
/* Copy command inline styles */
.copy-command {
display: inline-flex;
@@ -2180,6 +2207,128 @@ body {
white-space: nowrap;
}
+/* Related posts header with toggle */
+.related-posts-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.related-posts-header .related-posts-title {
+ margin-bottom: 0;
+}
+
+/* Related posts thumbnail view */
+.related-posts-thumbnails {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.related-post-thumbnail {
+ display: flex;
+ gap: 16px;
+ text-decoration: none;
+ color: inherit;
+ transition: opacity 0.2s ease;
+}
+
+.related-post-thumbnail:hover {
+ opacity: 0.85;
+}
+
+.related-post-thumbnail-image {
+ flex-shrink: 0;
+ width: 120px;
+ height: 120px;
+ border-radius: 8px;
+ overflow: hidden;
+ background-color: var(--bg-secondary);
+}
+
+.related-post-thumbnail-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.related-post-thumbnail-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.related-post-thumbnail-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.related-post-thumbnail-excerpt {
+ font-size: 0.9375rem;
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.related-post-thumbnail-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ margin-top: auto;
+}
+
+.related-post-thumbnail-author-image {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.related-post-thumbnail-author {
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.02em;
+}
+
+.related-post-thumbnail-date {
+ color: var(--text-muted);
+}
+
+/* Mobile responsive styles for related posts thumbnails */
+@media (max-width: 480px) {
+ .related-post-thumbnail-image {
+ width: 100px;
+ height: 100px;
+ }
+
+ .related-post-thumbnail-title {
+ font-size: 1rem;
+ }
+
+ .related-post-thumbnail-excerpt {
+ font-size: 0.875rem;
+ -webkit-line-clamp: 2;
+ }
+}
+
/* Loading and error states */
.loading,
.no-posts,
@@ -9286,6 +9435,12 @@ body {
color: var(--text-primary);
}
+.dashboard-action-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
.dashboard-action-btn.primary {
background: var(--text-primary);
color: var(--bg-primary);
@@ -9439,6 +9594,30 @@ body {
max-height: 100vh;
}
+/* Hide scrollbars but keep scroll functionality for dashboard */
+.dashboard-sidebar-left,
+.dashboard-nav,
+.dashboard-content,
+.dashboard-sidebar-right,
+.dashboard-frontmatter-fields {
+ -ms-overflow-style: none; /* IE/old Edge */
+ scrollbar-width: none; /* Firefox */
+}
+
+.dashboard-sidebar-left::-webkit-scrollbar,
+.dashboard-nav::-webkit-scrollbar,
+.dashboard-content::-webkit-scrollbar,
+.dashboard-sidebar-right::-webkit-scrollbar,
+.dashboard-frontmatter-fields::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+
+/* Prevent outer page scroll on dashboard pages */
+body:has(.dashboard-layout) {
+ overflow: hidden;
+}
+
.dashboard-frontmatter {
display: flex;
flex-direction: column;
@@ -10968,7 +11147,7 @@ body {
border-right: none;
}
-/* Quill Editor Styles */
+/* React Quill Editor Styles */
.dashboard-quill-container {
flex: 1;
display: flex;
@@ -10985,25 +11164,6 @@ body {
overflow: hidden;
}
-.dashboard-quill-container .ql-container {
- flex: 1;
- overflow: auto;
- font-family: var(--font-family-base);
- font-size: var(--font-size-base);
-}
-
-.dashboard-quill-container .ql-editor {
- min-height: 100%;
- padding: 1rem;
- color: var(--text-primary);
- background: var(--bg-secondary);
-}
-
-.dashboard-quill-container .ql-editor.ql-blank::before {
- color: var(--text-tertiary);
- font-style: normal;
-}
-
.dashboard-quill-container .ql-toolbar {
background: var(--bg-primary);
border: none;
@@ -11024,29 +11184,146 @@ body {
}
.dashboard-quill-container .ql-toolbar button:hover .ql-stroke,
-.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke {
+.dashboard-quill-container .ql-toolbar .ql-picker:hover .ql-stroke {
stroke: var(--text-primary);
}
.dashboard-quill-container .ql-toolbar button:hover .ql-fill,
-.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill {
+.dashboard-quill-container .ql-toolbar .ql-picker:hover .ql-fill {
fill: var(--text-primary);
}
-.dashboard-quill-container .ql-toolbar .ql-picker-label:hover,
-.dashboard-quill-container .ql-toolbar .ql-picker-label.ql-active {
- color: var(--text-primary);
+.dashboard-quill-container .ql-toolbar button.ql-active .ql-stroke,
+.dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-stroke {
+ stroke: var(--accent-color);
}
-.dashboard-quill-container .ql-toolbar .ql-picker-options {
- background: var(--bg-primary);
- border-color: var(--border-color);
+.dashboard-quill-container .ql-toolbar button.ql-active .ql-fill,
+.dashboard-quill-container .ql-toolbar .ql-picker.ql-active .ql-fill {
+ fill: var(--accent-color);
}
.dashboard-quill-container .ql-container {
+ flex: 1;
+ overflow: auto;
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
border: none;
}
+.dashboard-quill-container .ql-editor {
+ min-height: 100%;
+ padding: 1rem;
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+}
+
+.dashboard-quill-container .ql-editor.ql-blank::before {
+ color: var(--text-tertiary);
+ font-style: normal;
+}
+
+.dashboard-quill-container .ql-editor h1 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin: 1.5rem 0 0.75rem;
+ line-height: 1.2;
+}
+
+.dashboard-quill-container .ql-editor h2 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 1.25rem 0 0.625rem;
+ line-height: 1.3;
+}
+
+.dashboard-quill-container .ql-editor h3 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 1rem 0 0.5rem;
+ line-height: 1.4;
+}
+
+.dashboard-quill-container .ql-editor p {
+ margin: 0 0 1rem;
+ line-height: 1.6;
+}
+
+.dashboard-quill-container .ql-editor blockquote {
+ border-left: 3px solid var(--border-color);
+ margin: 1rem 0;
+ padding-left: 1rem;
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+.dashboard-quill-container .ql-editor pre.ql-syntax {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-radius: var(--border-radius-sm);
+ padding: 1rem;
+ overflow-x: auto;
+}
+
+.dashboard-quill-container .ql-editor ul,
+.dashboard-quill-container .ql-editor ol {
+ margin: 0.5rem 0 1rem 1.5rem;
+ padding: 0;
+}
+
+.dashboard-quill-container .ql-editor li {
+ margin: 0.25rem 0;
+ line-height: 1.6;
+}
+
+.dashboard-quill-container .ql-editor a {
+ color: var(--accent-color);
+ text-decoration: underline;
+}
+
+.dashboard-quill-container .ql-editor img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--border-radius-sm);
+ margin: 1rem 0;
+}
+
+/* Quill picker dropdown styling */
+.dashboard-quill-container .ql-picker-options {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-sm);
+}
+
+.dashboard-quill-container .ql-picker-item {
+ color: var(--text-secondary);
+}
+
+.dashboard-quill-container .ql-picker-item:hover {
+ color: var(--text-primary);
+}
+
+/* Quill tooltip styling */
+.dashboard-quill-container .ql-tooltip {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ box-shadow: var(--shadow-lg);
+ border-radius: var(--border-radius-sm);
+}
+
+.dashboard-quill-container .ql-tooltip input[type="text"] {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ border-radius: var(--border-radius-sm);
+}
+
+.dashboard-quill-container .ql-tooltip a.ql-action,
+.dashboard-quill-container .ql-tooltip a.ql-remove {
+ color: var(--accent-color);
+}
+
.dashboard-write-type-selector {
display: flex;
gap: 0.5rem;
@@ -11720,7 +11997,8 @@ body {
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: var(--font-size-sm);
color: var(--text-primary);
- word-break: break-all;
+ white-space: nowrap;
+ overflow-x: auto;
}
.dashboard-modal-copy-btn {
@@ -11841,6 +12119,69 @@ body {
line-height: 1.5;
}
+.dashboard-modal-copy-prompt {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: 1rem;
+ margin-top: 1rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-md);
+}
+
+.dashboard-modal-copy-prompt-text {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.dashboard-modal-copy-prompt-text svg {
+ flex-shrink: 0;
+ color: var(--accent-color);
+ margin-top: 0.125rem;
+}
+
+.dashboard-modal-copy-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.625rem 1rem;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius-sm);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+
+.dashboard-modal-copy-btn:hover:not(:disabled) {
+ background: var(--bg-tertiary);
+ border-color: var(--text-tertiary);
+}
+
+.dashboard-modal-copy-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.dashboard-modal-copy-btn.copied {
+ background: #22c55e;
+ border-color: #22c55e;
+ color: #fff;
+}
+
+.dashboard-modal-copy-btn svg {
+ flex-shrink: 0;
+}
+
.dashboard-modal-btn.danger {
display: flex;
align-items: center;