diff --git a/playbooks/README.md b/playbooks/README.md index 34355b27..4d8a62e5 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -66,7 +66,7 @@ Write your playbook content in Markdown format. Images, tables, code, and other | **Getting Started** | First hands-on step—get users into the tool quickly | | **Core Concepts** | Teach the mental model (tables and diagrams help) | | **Main Activity** | Where users achieve the payoff moment | -| **Next Steps** | 3-5 paths forward with links to resources | +| **Next Steps** | 3-5 paths forward with links to resources and official documentation | ### OS-Specific Content diff --git a/playbooks/core/comfyui-image-gen/README.md b/playbooks/core/comfyui-image-gen/README.md index 8f56dc89..e14c75be 100644 --- a/playbooks/core/comfyui-image-gen/README.md +++ b/playbooks/core/comfyui-image-gen/README.md @@ -166,3 +166,5 @@ Workflows are self-contained—share the JSON file with colleagues, and they can - **Browse community workflows**: [ComfyUI Examples](https://github.com/comfyanonymous/ComfyUI_examples) has many ready-to-use workflows ComfyUI's strength is experimentation: connect nodes differently, adjust parameters, and observe how each change affects the output. This hands-on exploration builds intuition for how diffusion models work. + +For more information, check out the [ComfyUI Documentation](https://docs.comfy.org/). diff --git a/website/src/app/globals.css b/website/src/app/globals.css index 2fbfbaf1..cd0a79b2 100644 --- a/website/src/app/globals.css +++ b/website/src/app/globals.css @@ -323,4 +323,37 @@ body { .playbook-content > .md-h3:first-child, .playbook-content > .md-h4:first-child { margin-top: 0; +} + +/* Table of Contents Sidebar */ +.toc-sidebar { + padding: 1rem; + background: rgba(26, 26, 26, 0.6); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + backdrop-filter: blur(8px); + max-height: calc(100vh - 8rem); + overflow-y: auto; +} + +.toc-sidebar::-webkit-scrollbar { + width: 4px; +} + +.toc-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.toc-sidebar::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +.toc-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Smooth scroll for anchor links */ +.scroll-mt-28 { + scroll-margin-top: 7rem; } \ No newline at end of file diff --git a/website/src/app/playbooks/[id]/page.tsx b/website/src/app/playbooks/[id]/page.tsx index f11336c8..2cfbfb8a 100644 --- a/website/src/app/playbooks/[id]/page.tsx +++ b/website/src/app/playbooks/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, use } from "react"; +import { useState, useEffect, use, useRef } from "react"; import Link from "next/link"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -10,6 +10,87 @@ import Footer from "@/components/Footer"; import type { Playbook, Platform } from "@/types/playbook"; import { formatTime } from "@/types/playbook"; +interface TocItem { + id: string; + text: string; +} + +/** + * Extracts table of contents from markdown content (main sections only - h2) + */ +function extractToc(content: string): TocItem[] { + if (!content) return []; + + const headingRegex = /^##\s+(.+)$/gm; + const toc: TocItem[] = []; + let match; + + while ((match = headingRegex.exec(content)) !== null) { + const text = match[1].trim(); + const id = slugify(text); + toc.push({ id, text }); + } + + return toc; +} + +/** + * Generates a URL-safe slug from heading text + */ +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim(); +} + +/** + * Table of Contents Sidebar Component + */ +function TableOfContents({ + items, + activeId, + onLinkClick +}: { + items: TocItem[]; + activeId: string; + onLinkClick: (id: string) => void; +}) { + if (items.length === 0) return null; + + return ( + + ); +} + /** * Parses markdown content and filters OS-specific sections * @@ -103,6 +184,9 @@ export default function PlaybookPage({ params }: { params: Promise<{ id: string const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedPlatform, setSelectedPlatform] = useState("windows"); + const [activeHeading, setActiveHeading] = useState(""); + const contentRef = useRef(null); + const isClickScrolling = useRef(false); useEffect(() => { async function fetchPlaybook() { @@ -142,12 +226,83 @@ export default function PlaybookPage({ params }: { params: Promise<{ id: string .replace(/src=["'](?!https?:\/\/|\/)(.*?)["']/g, `src="/api/playbooks/${id}/$1"`) : ""; + // Extract table of contents from filtered content + const tocItems = extractToc(filteredContent); + + // Handle clicking a TOC link - scroll and immediately set active + const handleTocClick = (targetId: string) => { + const element = document.getElementById(targetId); + if (element) { + // Set active immediately on click + setActiveHeading(targetId); + isClickScrolling.current = true; + + const offset = 100; + const top = element.getBoundingClientRect().top + window.scrollY - offset; + window.scrollTo({ top, behavior: "smooth" }); + history.pushState(null, "", `#${targetId}`); + + // Re-enable scroll tracking after scroll animation completes + setTimeout(() => { + isClickScrolling.current = false; + }, 1000); + } + }; + + // Track active heading on scroll + useEffect(() => { + if (!contentRef.current || tocItems.length === 0) return; + + const handleScroll = () => { + // Skip scroll tracking while programmatic scroll is in progress + if (isClickScrolling.current) return; + + const headings = contentRef.current?.querySelectorAll("h2[id]"); + if (!headings || headings.length === 0) return; + + // Find the heading that's currently at or near the top of the viewport + // We look for the last heading that has scrolled past the threshold + const threshold = 150; // How far from top of viewport to consider "active" + let currentActive = ""; + + for (const heading of headings) { + const rect = heading.getBoundingClientRect(); + // If this heading is at or above the threshold, it's the current section + if (rect.top <= threshold) { + currentActive = heading.id; + } else { + // Once we find a heading below the threshold, stop + break; + } + } + + // If no heading is above threshold, use the first one if we're near the top + if (!currentActive && headings.length > 0) { + const firstHeading = headings[0] as HTMLElement; + const rect = firstHeading.getBoundingClientRect(); + if (rect.top < window.innerHeight / 2) { + currentActive = firstHeading.id; + } + } + + if (currentActive && currentActive !== activeHeading) { + setActiveHeading(currentActive); + } + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + // Initial check + handleScroll(); + + return () => window.removeEventListener("scroll", handleScroll); + }, [tocItems, activeHeading]); + return (
-
+
{/* Back Link */} - {/* Content */} -
- {filteredContent ? ( -
-

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - p: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) =>
    {children}
    , - a: ({ href, children }) => ( - - {children} - - ), - img: ({ src, alt }) => { - // Transform relative paths to use the API route - let imageSrc = typeof src === "string" ? src : ""; - if (imageSrc && !imageSrc.startsWith("http") && !imageSrc.startsWith("/")) { - imageSrc = `/api/playbooks/${id}/${imageSrc}`; - } - return ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ); - }, - code: ({ className, children }) => { - const isInline = !className; - if (isInline) { - return {children}; - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
    {children}
    - ), - hr: () =>
    , - table: ({ children }) => {children}
    , - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children}, - }} - > - {filteredContent} -
    -
    - ) : ( -
    -
    - - - + {/* Main content area with TOC sidebar */} +
    + {/* Table of Contents - Desktop only */} + {tocItems.length > 0 && ( +
    + )} + + {/* Content */} +
    +
    + {filteredContent ? ( +
    + { + const text = String(children); + const headingId = slugify(text); + return

    {children}

    ; + }, + h2: ({ children }) => { + const text = String(children); + const headingId = slugify(text); + return

    {children}

    ; + }, + h3: ({ children }) => { + const text = String(children); + const headingId = slugify(text); + return

    {children}

    ; + }, + h4: ({ children }) => { + const text = String(children); + const headingId = slugify(text); + return

    {children}

    ; + }, + p: ({ children }) =>

    {children}

    , + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) =>
    {children}
    , + a: ({ href, children }) => ( + + {children} + + ), + img: ({ src, alt }) => { + // Transform relative paths to use the API route + let imageSrc = typeof src === "string" ? src : ""; + if (imageSrc && !imageSrc.startsWith("http") && !imageSrc.startsWith("/")) { + imageSrc = `/api/playbooks/${id}/${imageSrc}`; + } + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt + ); + }, + code: ({ className, children }) => { + const isInline = !className; + if (isInline) { + return {children}; + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    {children}
    + ), + hr: () =>
    , + table: ({ children }) => {children}
    , + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + }} + > + {filteredContent} +
    +
    + ) : ( +
    +
    + + + +
    +

    Content Coming Soon

    +

    + This playbook is being prepared. Check back soon for detailed instructions. +

    +
    + )} +
    +
    )}