New FAQ article style

This commit is contained in:
headpatsyou 2026-01-07 20:07:18 +00:00
parent a4eae6f169
commit 39623235f8
5 changed files with 174 additions and 35 deletions

View file

@ -2,6 +2,8 @@
title: Como funcionam as classes de peso? title: Como funcionam as classes de peso?
readTime: 5 min readTime: 5 min
intendedFor: Atletas intendedFor: Atletas
author: João Silva
authorImage: https://via.placeholder.com/60
--- ---
As classes de peso determinam em que categoria o atleta compete. Consulte as regras da IPF para a lista atualizada de classes. As classes de peso determinam em que categoria o atleta compete. Consulte as regras da IPF para a lista atualizada de classes.

View file

@ -118,7 +118,19 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
} }
.markdown-content h1, .markdown-content h1,
.markdown-content h2, .markdown-content h2,
.markdown-content h3 { margin-top: 1.25rem; margin-bottom: 0.75rem; } .markdown-content h3 {
margin-top: 1.25rem;
margin-bottom: 0.75rem;
scroll-margin-top: 80px; /* Account for header + subnav on mobile */
}
@media (min-width: 768px) {
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
scroll-margin-top: 120px; /* More space on desktop */
}
}
.markdown-content p, .markdown-content ul, .markdown-content ol { margin-bottom: 0.75rem; } .markdown-content p, .markdown-content ul, .markdown-content ol { margin-bottom: 0.75rem; }
.markdown-content ul { padding-left: 1.25rem; list-style: disc; } .markdown-content ul { padding-left: 1.25rem; list-style: disc; }
.markdown-content ol { padding-left: 1.25rem; list-style: decimal; } .markdown-content ol { padding-left: 1.25rem; list-style: decimal; }

View file

@ -1,32 +1,91 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { parseMarkdownWithFrontmatter } from "@/lib/markdown"; import { parseMarkdownWithFrontmatter } from "@/lib/markdown";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
type Params = { params: { slug: string } }; type Params = { params: Promise<{ slug: string }> };
async function getAvailableSlugs(): Promise<string[]> {
try {
const root = process.cwd();
const dir = join(root, "content", "pt", "perguntas");
const files = await readdir(dir);
return files
.filter((f) => f.endsWith(".md"))
.map((f) => f.replace(/\.md$/, ""));
} catch {
return [];
}
}
export async function generateStaticParams() {
const slugs = await getAvailableSlugs();
return slugs.map((slug) => ({
slug,
}));
}
export default async function FaqArticlePage({ params }: Params) { export default async function FaqArticlePage({ params }: Params) {
const { slug } = await params;
const root = process.cwd(); const root = process.cwd();
const file = join(root, "content", "pt", "perguntas", `${params.slug}.md`); const file = join(root, "content", "pt", "perguntas", `${slug}.md`);
try { try {
const raw = await readFile(file); const raw = await readFile(file);
const { content, frontmatter } = parseMarkdownWithFrontmatter(raw.toString()); const { content, frontmatter } = parseMarkdownWithFrontmatter(raw.toString());
return ( return (
<article aria-labelledby="article-title" className="prose max-w-none"> <article aria-labelledby="article-title" className="max-w-3xl mx-auto">
<header className="mb-4"> <header className="mb-8 pb-6 border-b border-gray-200">
<h1 id="article-title" className="text-2xl font-bold">{String(frontmatter.title ?? "FAQ")}</h1> <h1 id="article-title" className="text-4xl font-bold mb-6 text-gray-900">
<p className="text-sm text-gray-700"> {String(frontmatter.title ?? "FAQ")}
{frontmatter.readTime ? ( </h1>
<span>Tempo de leitura: {String(frontmatter.readTime)}</span>
) : null} {/* Metadata Grid */}
{frontmatter.intendedFor ? ( <div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<span className="ml-2"> Público: {String(frontmatter.intendedFor)}</span> {frontmatter.readTime && (
) : null} <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Tempo de Leitura
</p> </p>
<p className="text-sm text-gray-700 mt-1">{String(frontmatter.readTime)}</p>
</div>
)}
{frontmatter.intendedFor && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Quem?
</p>
<p className="text-sm text-gray-700 mt-1">{String(frontmatter.intendedFor)}</p>
</div>
)}
</div>
{/* Author Section */}
{frontmatter.author && (
<div className="flex items-center gap-4 pt-4">
{frontmatter.authorImage && (
<img
src={String(frontmatter.authorImage)}
alt={String(frontmatter.author)}
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
/>
)}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Autor
</p>
<p className="text-sm font-semibold text-gray-900 mt-1">
{String(frontmatter.author)}
</p>
</div>
</div>
)}
</header> </header>
<div className="prose prose-sm md:prose md:max-w-none text-gray-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</article> </article>
); );
} catch (e) { } catch (e) {

View file

@ -1,15 +1,24 @@
import Link from "next/link"; import Link from "next/link";
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import matter from "gray-matter"; import matter from "gray-matter";
export default async function FaqIndexPage() { export default async function FaqIndexPage() {
// Simple server component that lists available FAQ markdown files // Dynamically read all FAQ markdown files
const root = process.cwd(); const root = process.cwd();
const files = ["faq-exemplo.md"]; // In a real app, glob directory const faqDir = join(root, "content", "pt", "perguntas");
let files: string[] = [];
try {
files = await readdir(faqDir);
files = files.filter((f) => f.endsWith(".md")).sort();
} catch {
files = [];
}
const entries: { slug: string; title: string; readTime?: string; intendedFor?: string }[] = []; const entries: { slug: string; title: string; readTime?: string; intendedFor?: string }[] = [];
for (const f of files) { for (const f of files) {
const raw = await readFile(join(root, "content", "pt", "perguntas", f)); const raw = await readFile(join(faqDir, f));
const { data } = matter(raw.toString()); const { data } = matter(raw.toString());
entries.push({ entries.push({
slug: f.replace(/\.md$/, ""), slug: f.replace(/\.md$/, ""),

View file

@ -5,9 +5,21 @@ type Heading = { id: string; text: string; level: number };
export function TableOfContents({ headings }: { headings: Heading[] }) { export function TableOfContents({ headings }: { headings: Heading[] }) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null); const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => { useEffect(() => {
// Disconnect old observer if it exists
if (observerRef.current) {
observerRef.current.disconnect();
}
// Only set up observer if we have headings
if (headings.length === 0) {
setActiveId(null);
return;
}
const visibleHeadings = new Map<string, number>(); const visibleHeadings = new Map<string, number>();
const callback: IntersectionObserverCallback = (entries) => { const callback: IntersectionObserverCallback = (entries) => {
@ -30,25 +42,69 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
}; };
const observer = new IntersectionObserver(callback, { const observer = new IntersectionObserver(callback, {
rootMargin: "-40% 0px -55% 0px", rootMargin: "-80px 0px -55% 0px",
threshold: 0.1, threshold: 0.1,
}); });
observerRef.current = observer; observerRef.current = observer;
headings.forEach((h) => { headings.forEach((h) => {
const el = document.getElementById(h.id); const el = document.getElementById(h.id);
if (el) observer.observe(el); if (el) observer.observe(el);
}); });
return () => observer.disconnect(); return () => observer.disconnect();
}, [headings]); }, [headings]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, headingId: string) => {
e.preventDefault();
const element = document.getElementById(headingId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL without causing a page reload
window.history.pushState(null, "", `#${headingId}`);
// Close mobile menu after click
setIsOpen(false);
}
};
return ( return (
<nav aria-label="Tabela de conteúdos" className="sticky top-4 max-h-[80vh] overflow-auto"> <>
{/* Mobile Toggle Button */}
<button
className="md:hidden w-full mb-4 px-4 py-2 bg-blue-50 text-blue-600 border border-blue-200 rounded-lg font-medium text-sm transition hover:bg-blue-100"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label="Alternar tabela de conteúdos"
>
{isOpen ? "✕ Fechar conteúdos" : "☰ Mostrar conteúdos"}
</button>
{/* Desktop Sidebar + Mobile Collapsible */}
<nav
aria-label="Tabela de conteúdos"
className={`
md:sticky md:top-4 md:max-h-[80vh] md:overflow-auto md:block
${isOpen ? "block" : "hidden md:block"}
mb-6 md:mb-0 p-4 md:p-0
bg-white md:bg-transparent
border md:border-none border-gray-200 md:border-0
rounded-lg md:rounded-none
`}
>
<ul className="space-y-1"> <ul className="space-y-1">
{headings.map((h) => ( {headings.map((h) => (
<li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}> <li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}>
<a <a
href={`#${h.id}`} href={`#${h.id}`}
className={activeId === h.id ? "font-semibold text-blue-600" : ""} onClick={(e) => handleClick(e, h.id)}
className={`
block py-1 px-2 rounded transition
${
activeId === h.id
? "font-semibold text-blue-600 bg-blue-50"
: "text-gray-700 hover:bg-gray-100"
}
`}
> >
{h.text} {h.text}
</a> </a>
@ -56,5 +112,6 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
))} ))}
</ul> </ul>
</nav> </nav>
</>
); );
} }