Improve layout

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-11-04 14:11:33 +00:00
parent f084d37b3f
commit 2d01298ed1
71 changed files with 372 additions and 45 deletions
+1 -3
View File
@@ -7,9 +7,7 @@ export default async function Page({
}) {
const { slug } = await params;
const { default: Component } = await import(
`@/content/components/${slug}.mdx`
);
const { default: Component } = await import(`@/content/${slug}.mdx`);
return <Component />;
}
+27 -1
View File
@@ -5,10 +5,28 @@
padding-inline: 24px;
}
.content {
.contentWrapper {
display: flex;
width: 100%;
max-width: 1200px;
margin: 0 auto;
justify-content: center;
flex-direction: column;
}
.content {
display: flex;
gap: 40px;
}
.contentInner {
flex: 1;
min-width: 0;
overflow-x: hidden;
}
.toc {
display: none;
}
@media (min-width: 768px) {
@@ -18,3 +36,11 @@
margin-right: 40px;
}
}
@media (min-width: 1280px) {
.toc {
display: block;
width: 240px;
flex-shrink: 0;
}
}
+8 -2
View File
@@ -4,6 +4,7 @@ import { Toolbar } from '@/components/Toolbar';
import { StickyHeader } from '../components/StickyHeader/StickyHeader';
import { Providers } from './providers';
import { CustomTheme } from '@/components/CustomTheme';
import { TableOfContents } from '@/components/TableOfContents';
import styles from './layout.module.css';
import '../css/globals.css';
@@ -53,9 +54,14 @@ export default async function RootLayout({
<Sidebar />
{/* <StickyHeader /> */}
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.contentWrapper}>
<Toolbar version={packageVersion} />
{children}
<div className={styles.content}>
<div className={styles.contentInner}>{children}</div>
<aside className={styles.toc}>
<TableOfContents />
</aside>
</div>
</div>
</div>
<CustomTheme />
+14 -5
View File
@@ -9,15 +9,24 @@ export interface CodeBlockProps {
code?: string;
}
export async function CodeBlock({ lang = 'tsx', title, code }: CodeBlockProps) {
const out = await codeToHtml(code || '', {
lang: lang,
export async function CodeBlock(props: CodeBlockProps) {
const { lang = 'tsx', title, code } = props;
let out = await codeToHtml(code || '', {
lang,
transformers: [transformerNotationDiff({ matchAlgorithm: 'v3' })],
themes: {
light: 'min-light',
light: 'github-dark',
dark: 'min-dark',
},
transformers: [transformerNotationDiff({ matchAlgorithm: 'v3' })],
});
// Remove background-color from the pre tag to use our theme colors
out = out.replace(
/style="([^"]*?)background-color:[^;]+;?([^"]*?)"/g,
'style="$1$2"',
);
// Clean up empty style attributes
out = out.replace(/style=""\s?/g, '');
return <CodeBlockClient out={out} title={title} />;
}
@@ -1,33 +1,29 @@
.codeBlock {
border-radius: 4px;
border: 1px solid var(--border);
border-radius: 8px;
position: relative;
background: transparent;
overflow-x: auto;
font-family: var(--font-mono);
background-color: #fff;
background-color: var(--code-bg);
margin-bottom: 1rem;
}
[data-theme-mode='dark'] .codeBlock {
background-color: #121212;
}
.title {
border-bottom: 1px solid var(--border);
padding: 12px 20px;
font-size: 0.8125rem;
color: var(--primary);
color: #fff;
font-family: var(--font-regular);
font-weight: var(--font-weight-bold);
background-color: var(--code-title);
}
.title code {
background-color: var(--surface-1);
background-color: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.375rem;
border-radius: 0.25rem;
color: var(--secondary);
font-size: 0.8125rem;
color: #fff;
font-size: 0.75rem;
font-weight: var(--font-weight-bold);
}
.code {
@@ -5,9 +5,9 @@
}
.preview {
border-radius: 4px;
border-radius: 8px;
box-shadow: inset 0 0 0 1px var(--border);
background-color: var(--bg);
background-color: var(--bui-bg);
padding: 1px;
position: relative;
}
@@ -0,0 +1,93 @@
.container {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 16px 0;
}
.list {
list-style: none;
padding: 0;
margin: 0;
position: relative;
}
.list::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--border);
}
.indicator {
position: absolute;
left: 0;
width: 2px;
background-color: var(--primary);
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 1;
}
.item {
position: relative;
margin: 0;
}
.itemNested {
padding-left: 16px;
}
.link {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 6px 16px;
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease;
line-height: 1.4;
position: relative;
}
.link:hover {
color: var(--text);
}
.itemActive .link {
color: var(--primary);
font-weight: 500;
}
/* Hide scrollbar for webkit browsers */
.container::-webkit-scrollbar {
width: 4px;
}
.container::-webkit-scrollbar-track {
background: transparent;
}
.container::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.container::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive: hide on smaller screens */
@media (max-width: 1280px) {
.container {
display: none;
}
}
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import styles from './TableOfContents.module.css';
interface Heading {
id: string;
text: string;
level: number;
}
export function TableOfContents() {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>('');
const [indicatorTop, setIndicatorTop] = useState<number>(0);
const [indicatorHeight, setIndicatorHeight] = useState<number>(0);
const pathname = usePathname();
useEffect(() => {
// Extract all H2 and H3 headings from the document
const elements = Array.from(
document.querySelectorAll('h2[id], h3[id]'),
) as HTMLHeadingElement[];
const headingData: Heading[] = elements.map(element => ({
id: element.id,
text: (element.textContent || '').replace('#', '').trim(),
level: parseInt(element.tagName.substring(1)),
}));
setHeadings(headingData);
// Set initial active heading (first visible heading or first heading)
if (headingData.length > 0) {
const viewportTop = window.scrollY + 100; // offset for header
const visibleHeading = elements.find(element => {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY >= viewportTop - 200;
});
setActiveId(visibleHeading?.id || headingData[0].id);
}
// Set up IntersectionObserver to track visible headings
const observerOptions = {
rootMargin: '-80px 0px -80% 0px',
threshold: 1,
};
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
};
const observer = new IntersectionObserver(
observerCallback,
observerOptions,
);
elements.forEach(element => observer.observe(element));
return () => {
elements.forEach(element => observer.unobserve(element));
};
}, [pathname]);
// Update indicator position when activeId changes
useEffect(() => {
if (activeId) {
const activeElement = document.querySelector(
`[data-toc-id="${activeId}"]`,
) as HTMLElement;
if (activeElement) {
const list = activeElement.closest('ul');
if (list) {
const listRect = list.getBoundingClientRect();
const elementRect = activeElement.getBoundingClientRect();
setIndicatorTop(elementRect.top - listRect.top);
setIndicatorHeight(elementRect.height);
}
}
}
}, [activeId, headings]);
const handleClick = (id: string) => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Update URL without scrolling (already handled above)
window.history.pushState(null, '', `#${id}`);
}
};
if (headings.length === 0) {
return null;
}
return (
<nav className={styles.container}>
<ul className={styles.list}>
{indicatorHeight > 0 && (
<div
className={styles.indicator}
style={{
top: `${indicatorTop}px`,
height: `${indicatorHeight}px`,
}}
/>
)}
{headings.map((heading, index) => (
<li
key={`${heading.id}-${index}`}
data-toc-id={heading.id}
className={`${styles.item} ${
heading.level === 3 ? styles.itemNested : ''
} ${activeId === heading.id ? styles.itemActive : ''}`}
>
<button
onClick={() => handleClick(heading.id)}
className={styles.link}
>
{heading.text}
</button>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1 @@
export { TableOfContents } from './TableOfContents';
@@ -1,5 +1,5 @@
import { classNamePropDefs, stylePropDefs } from '../../utils/propDefs';
import type { PropDef } from '../../utils/propDefs';
import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs';
import type { PropDef } from '@/utils/propDefs';
export const buttonLinkPropDefs: Record<string, PropDef> = {
variant: {
@@ -1,5 +1,5 @@
import { classNamePropDefs, stylePropDefs } from '../../utils/propDefs';
import type { PropDef } from '../../utils/propDefs';
import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs';
import type { PropDef } from '@/utils/propDefs';
export const buttonPropDefs: Record<string, PropDef> = {
variant: {
@@ -2,7 +2,7 @@ import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '../../utils/propDefs';
} from '@/utils/propDefs';
export const tablePropDefs: Record<string, PropDef> = {
selectionBehavior: {
+4
View File
@@ -15,11 +15,15 @@
--border: #e5e5e5;
--border2: #cdcdcd;
--surface-1: #f4f4f4;
--code-bg: #3e444f;
--code-title: #505865;
}
[data-theme-mode='dark'] {
--bg: #000000;
--bg-hover: #202020;
--code-bg: #202020;
--code-title: #292929;
--primary: #fff;
--secondary: #818181;
--action: #202020;
+24
View File
@@ -49,3 +49,27 @@
.a:hover {
text-decoration: underline;
}
.headingWithAnchor {
position: relative;
scroll-margin-top: 80px;
}
.anchorLink {
color: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.anchorHash {
opacity: 0;
transition: opacity 0.2s;
font-size: 0.8em;
color: var(--text-secondary);
}
.anchorLink:hover .anchorHash {
opacity: 1;
}
+53 -2
View File
@@ -4,10 +4,61 @@ import Image, { ImageProps } from 'next/image';
import { CodeBlock } from '@/components/CodeBlock';
import styles from './css/mdx.module.css';
// Utility function to generate slug from heading text
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
}
// Component for heading with anchor link
function HeadingWithAnchor({
level,
children,
className,
}: {
level: number;
children: ReactNode;
className: string;
}) {
const text =
typeof children === 'string'
? children
: Array.isArray(children)
? children.join('')
: '';
const id = slugify(text);
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Tag id={id} className={`${className} ${styles.headingWithAnchor}`}>
<a href={`#${id}`} className={styles.anchorLink}>
{children}
<span className={styles.anchorHash}>#</span>
</a>
</Tag>
);
}
export const formattedMDXComponents: MDXComponents = {
h1: ({ children }) => <h1 className={styles.h1}>{children as ReactNode}</h1>,
h2: ({ children }) => <h2 className={styles.h2}>{children as ReactNode}</h2>,
h3: ({ children }) => <h3 className={styles.h3}>{children as ReactNode}</h3>,
h2: ({ children }) => (
<HeadingWithAnchor level={2} className={styles.h2}>
{children as ReactNode}
</HeadingWithAnchor>
),
h3: ({ children }) => (
<HeadingWithAnchor level={3} className={styles.h3}>
{children as ReactNode}
</HeadingWithAnchor>
),
p: ({ children }) => <p className={styles.p}>{children as ReactNode}</p>,
a: ({ children, href }) => (
<a href={href} className={styles.a}>
-13
View File
@@ -125,16 +125,3 @@ export const components: Page[] = [
slug: 'visually-hidden',
},
];
export type ScreenSize = {
title: string;
slug: string;
width: number;
};
export const screenSizes: ScreenSize[] = [
{ title: 'Mobile', slug: 'mobile', width: 390 },
{ title: 'Tablet', slug: 'tablet', width: 768 },
{ title: 'Desktop', slug: 'desktop', width: 1280 },
{ title: 'Wide', slug: 'wide', width: 1600 },
];